Model Context Protocol for AI Integration
The Model Context Protocol MCP is an open standard that solves one of the biggest challenges in AI development: connecting language models to external tools and data sources. Instead of building a custom integration for every AI provider, MCP defines a universal interface that works across Claude, and any MCP-compatible client. Anthropic introduced the protocol in late 2024, open-sourced the specification and SDKs, and it has since been adopted well beyond its origin, with desktop assistants, IDEs, and agent frameworks shipping MCP support.
Think of MCP as the USB-C of AI integrations. Before USB-C, every device had a different charging port. Before MCP, every AI tool integration was a bespoke implementation glued to one vendor’s function-calling format. This guide shows you how to build MCP servers that expose your data and capabilities to AI models through a standardized protocol, and how to harden them for production rather than leaving them as weekend demos.
How MCP Works
MCP follows a client-server architecture and speaks JSON-RPC 2.0. AI applications (such as Claude Desktop or Cursor) act as MCP clients. Your code acts as an MCP server that exposes three types of capabilities:
- Tools — Functions the AI can call (query a database, create a ticket, send an email)
- Resources — Data the AI can read (files, database records, API responses)
- Prompts — Reusable prompt templates with parameters
The distinction between tools and resources matters more than it first appears. Tools are model-controlled: the LLM decides when to invoke them, and they may have side effects. Resources are application-controlled: they are read-only context the client can attach, and they are addressed by URI. Confusing the two — for example, exposing a destructive “delete records” operation as something the model can trigger without confirmation — is a frequent design error.
┌─────────────────┐ MCP Protocol ┌─────────────────┐
│ AI Client │◄────────────────────► │ MCP Server │
│ (Claude, etc.) │ JSON-RPC over │ (Your Code) │
│ │ stdio / SSE │ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ Tool Call │──┼────────────────────────┼─►│ Database │ │
│ │ Resource │◄─┼────────────────────────┼──│ API │ │
│ │ Prompt │──┼────────────────────────┼─►│ Files │ │
│ └───────────┘ │ │ └───────────┘ │
└─────────────────┘ └─────────────────┘
Transports: stdio vs. Streamable HTTP
MCP defines two main transports, and choosing correctly shapes your deployment. The stdio transport runs the server as a local subprocess of the client and communicates over standard input and output. It is the simplest option and the right default for tools that touch the local machine — a filesystem server, a Git server, or a database client that already holds local credentials. The Streamable HTTP transport (which superseded the earlier HTTP+SSE design) runs the server as a network service, which is what you want for a shared, multi-user backend that several clients connect to. The rule of thumb is straightforward: reach for stdio when the server is personal and local, and HTTP when the server is shared and remote. Mixing them up — for instance, trying to ship a local-credential stdio tool as a public HTTP endpoint — is how secrets leak.
Building Your First MCP Server
Let’s build an MCP server that gives AI models access to a project management system. We will use the official TypeScript SDK, though Python and Rust SDKs are also available and follow the same shape.
// src/index.ts — MCP Server for Project Management
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "project-manager",
version: "1.0.0",
});
// Database simulation (replace with real DB)
const projects = new Map();
const tasks = new Map();
// ─── TOOLS: Actions the AI can perform ───
server.tool(
"create_task",
"Create a new task in a project",
{
projectId: z.string().describe("The project ID"),
title: z.string().describe("Task title"),
description: z.string().describe("Task description"),
priority: z.enum(["low", "medium", "high", "critical"]),
assignee: z.string().optional().describe("Email of assignee"),
},
async ({ projectId, title, description, priority, assignee }) => {
const task: Task = {
id: crypto.randomUUID(),
projectId, title, description, priority,
assignee: assignee || "unassigned",
status: "todo",
createdAt: new Date().toISOString(),
};
tasks.set(task.id, task);
return {
content: [{
type: "text",
text: JSON.stringify({ success: true, taskId: task.id, message: "Task created" })
}]
};
}
);
server.tool(
"search_tasks",
"Search tasks by status, assignee, or keyword",
{
query: z.string().optional(),
status: z.enum(["todo", "in_progress", "done"]).optional(),
assignee: z.string().optional(),
},
async ({ query, status, assignee }) => {
let results = Array.from(tasks.values());
if (status) results = results.filter(t => t.status === status);
if (assignee) results = results.filter(t => t.assignee === assignee);
if (query) results = results.filter(t =>
t.title.toLowerCase().includes(query.toLowerCase()) ||
t.description.toLowerCase().includes(query.toLowerCase())
);
return {
content: [{
type: "text",
text: JSON.stringify({ tasks: results, count: results.length })
}]
};
}
);
// ─── RESOURCES: Data the AI can read ───
server.resource(
"project://{projectId}",
"Get project details and statistics",
async (uri) => {
const projectId = uri.pathname.split("/").pop();
const project = projects.get(projectId);
const projectTasks = Array.from(tasks.values())
.filter(t => t.projectId === projectId);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
project,
stats: {
total: projectTasks.length,
todo: projectTasks.filter(t => t.status === "todo").length,
inProgress: projectTasks.filter(t => t.status === "in_progress").length,
done: projectTasks.filter(t => t.status === "done").length,
}
})
}]
};
}
);
// ─── Start the server ───
const transport = new StdioServerTransport();
await server.connect(transport);
Tool Descriptions Are Prompts, Not Documentation
A subtle but high-leverage point: the names, descriptions, and parameter describe() strings are not human documentation — they are the prompt the model reads to decide what to call. Vague descriptions lead to the model guessing wrong, calling the wrong tool, or hallucinating arguments. Write them imperatively and specifically (“Search tasks by status, assignee, or keyword”) and let the Zod schema enforce the shape. Constraining a field to an enum rather than a free-form string is the single cheapest way to stop the model from inventing invalid values, because the protocol surfaces the allowed options directly to the client.
Connecting to Claude Desktop
Configure Claude Desktop to discover your MCP server by adding it to the configuration file. The client launches the command, performs the JSON-RPC handshake, and the tools appear automatically:
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"project-manager": {
"command": "node",
"args": ["path/to/dist/index.js"],
"env": {
"DATABASE_URL": "postgresql://localhost/projects"
}
}
}
}
Production Patterns for MCP Servers
Building a toy MCP server is straightforward. Making it production-ready requires careful attention to authentication, rate limiting, error handling, and observability. The protocol gives you a clean boundary, but everything you would normally do for an internal API still applies.
Authentication and Authorization
For remote HTTP servers, the specification standardised on OAuth 2.1 as the authorization framework, so a well-behaved client can negotiate scoped tokens rather than passing a long-lived static key. Whichever scheme you use, authorize every individual tool call against the caller’s identity — a single global API key that grants access to every operation is the anti-pattern that turns one compromised client into a full data breach:
// Middleware pattern for MCP tool authorization
function withAuth(handler: ToolHandler): ToolHandler {
return async (params, context) => {
const token = context.meta?.authToken;
if (!token) {
return {
content: [{ type: "text", text: "Authentication required" }],
isError: true,
};
}
const user = await verifyToken(token);
const hasPermission = await checkPermission(user, params);
if (!hasPermission) {
return {
content: [{ type: "text", text: "Insufficient permissions" }],
isError: true,
};
}
return handler(params, { ...context, user });
};
}
Error Handling and Validation
Furthermore, MCP servers should return structured errors that help the AI model understand what went wrong and potentially retry with corrected parameters. Because the model reads the error text, a good error is actionable: it names the failure, explains it in one line, and suggests the next tool to call. A bare stack trace teaches the model nothing and usually triggers a useless retry:
server.tool("update_task", "Update task status", schema, async (params) => {
try {
const task = tasks.get(params.taskId);
if (!task) {
return {
content: [{ type: "text", text: JSON.stringify({
error: "TASK_NOT_FOUND",
message: "No task with that ID exists",
suggestion: "Use search_tasks to find the correct task ID"
})}],
isError: true,
};
}
// ... update logic
} catch (error) {
return {
content: [{ type: "text", text: JSON.stringify({
error: "INTERNAL_ERROR",
message: error.message,
})}],
isError: true,
};
}
});
Security: Prompt Injection and the Confused Deputy
MCP introduces a threat model that traditional APIs do not face as acutely. Because tool descriptions and returned data flow into the model’s context, a malicious document or a poisoned tool description can attempt prompt injection — instructing the model to call a sensitive tool it should not. The MCP security guidance therefore recommends treating every tool result as untrusted input, keeping destructive operations behind explicit human confirmation, and scoping each server to the narrowest set of capabilities it needs. The “confused deputy” risk is real: a server that holds broad credentials and blindly acts on model instructions can be steered into doing something the user never intended. Defence in depth here means least-privilege tokens, allow-lists for dangerous actions, and audit logging of every tool invocation.
Observability
In production teams typically instrument each tool call with structured logs and metrics — latency, error rate, and which tools the model actually uses. This data is invaluable because it reveals tools the model never calls (usually a sign the description is unclear) and tools it calls constantly (candidates for caching). Treat the server like any other backend service and wire it into your existing tracing stack.
When NOT to Use MCP
MCP adds a protocol layer that isn’t always justified, and being candid about that prevents over-engineering. Skip it when you have a single AI provider with good native integrations and no intention of switching — wiring directly into that vendor’s function-calling API is simpler. Skip it when your interaction is a single, fixed request-response that needs low-latency streaming and never benefits from a generic tool catalog. And reconsider it when exposing a tool surface meaningfully widens your attack surface for little gain. Consequently, evaluate whether direct API integration would be simpler for your specific use case before adopting the protocol. The value of MCP compounds when you have many tools, multiple clients, or a desire to avoid vendor lock-in; with one tool and one client, the abstraction can cost more than it returns. For deeper patterns on wiring these capabilities into autonomous systems, see our guide on AI Agents Tool Use and Function Calling.
Key Takeaways
MCP standardizes how AI models interact with external systems. It eliminates vendor lock-in, reduces integration maintenance, and provides a consistent developer experience. Choose stdio for local tools and Streamable HTTP for shared services, write tool descriptions as prompts, authorize every call, and treat tool results as untrusted. Start with one or two tools, validate the pattern, and expand as you identify more AI-accessible capabilities in your system.
Related Reading
External Resources
In conclusion, the Model Context Protocol is an essential building block for modern AI integration. By applying the patterns and practices covered in this guide — clear capability design, the right transport, rigorous authorization, and a security-first mindset — you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure how the model actually uses your tools to ensure you are getting the most value from these approaches.