Model Context Protocol (MCP) in Practice: Building Interoperable Tool Integrations for AI Agents
MCP is Anthropic's open standard for connecting AI models to external tools and data sources. This guide covers the architecture, how it differs from raw function calling, building a custom MCP server in TypeScript, authentication, testing, and when MCP is actually worth the overhead.
Most teams building AI agents wire tools the same way: define a JSON schema, write a dispatch function, handle errors inline. This works until you have multiple agents, multiple models, and a growing catalog of integrations that all implement the same glue code differently. When you swap Claude for GPT-4o, or add a second agent that needs the same Jira integration, you rewrite the boundary layer. Again.
Model Context Protocol (MCP) is Anthropic’s answer to this. It is an open standard that defines how AI models connect to external tools and data sources through a structured client-server architecture. Instead of embedding tool logic inside your agent code, you expose it through an MCP server that any MCP-compatible client can consume. Claude, Cursor, Zed, Cline, and a growing list of agentic frameworks all speak MCP. If you build the server once, every compatible client gets the integration for free.
The real question engineers are asking is not “what is MCP” but “when is it worth the overhead over direct function calling?” This article answers that question, walks through the architecture with real TypeScript code, and covers the production considerations that the official docs skip.
What MCP Actually Is
MCP is a JSON-RPC 2.0 protocol. An MCP server exposes capabilities (tools, resources, prompts) over a transport layer. An MCP client (the agent framework or model host) connects to the server, discovers capabilities, and invokes them during inference. The model never calls the server directly; the client mediates everything.
There are two built-in transports:
- stdio: The client spawns the server as a subprocess and communicates over stdin/stdout. This is the default for local tools like filesystem access, shell commands, and local database clients.
- HTTP with SSE: The server runs as an HTTP service. The client connects over HTTP and receives streaming responses via Server-Sent Events. This is the transport for remote services, shared infrastructure, and anything that needs to serve multiple clients concurrently.
A third transport, Streamable HTTP, was introduced in the March 2025 MCP spec revision. It replaces the older HTTP+SSE transport with a bidirectional streaming model over a single HTTP connection. The SDK supports it, but client adoption is still catching up as of early 2026.
The protocol itself defines three capability primitives:
- Tools: Functions the model can invoke. Equivalent to function calling, but declared and dispatched through the MCP layer.
- Resources: Data the model can read. Static files, live database records, API responses formatted as context. Resources are URI-addressed and optionally subscribable for change notifications.
- Prompts: Reusable prompt templates with arguments, exposed to the client for injection into conversations. Less commonly used than tools or resources, but useful for standardizing system prompts across agent deployments.
MCP vs Direct Function Calling
Direct function calling is tighter coupling with less overhead. You define a schema inline, the model returns a tool call, your code dispatches it. No subprocess, no transport layer, no capability negotiation. For a single agent with a handful of tools, this is almost always the right choice.
MCP earns its overhead in specific scenarios:
Multiple agents, shared tools. If your Jira integration, Slack integration, and database tools each need to work with three different agents across two model providers, building each as an MCP server means you write the integration once and mount it to any client that speaks the protocol. Without MCP, every agent reimplements the same boundaries.
Third-party tool distribution. If you are shipping a tool integration for others to use (a company building an MCP server for their product’s API), the protocol gives you a standard interface. Customers drop your server into their Claude Desktop config or their Cursor settings without writing glue code.
Resource-heavy context injection. MCP resources let agents pull structured data by URI without you hard-coding retrieval logic into each agent. An agent can request repo://src/auth/middleware.ts and get the file content without you managing how that content is fetched per-agent.
Tool catalog management at scale. When a team has 40+ tools that evolve independently, managing them as MCP servers with clear versioning boundaries is more maintainable than a monolithic tool registry.
Where MCP is overhead without benefit: single-agent systems with fewer than 10 tools, tools that are tightly coupled to agent-specific state, or tools that need low-latency dispatch (the subprocess spawn and JSON-RPC round trip adds 10-50ms per call).
Building a Custom MCP Server in TypeScript
The @modelcontextprotocol/sdk package handles transport negotiation, capability declaration, and request routing. You implement the handlers.
Here is a minimal MCP server that exposes a GitHub integration as two tools and one resource:
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "github-integration",
version: "1.0.0",
});
// Tool: search issues
server.tool(
"search_issues",
"Search GitHub issues by query string within a repository.",
{
owner: z.string().describe("Repository owner (org or user)"),
repo: z.string().describe("Repository name"),
query: z.string().describe("Search query string"),
state: z.enum(["open", "closed", "all"]).default("open"),
},
async ({ owner, repo, query, state }) => {
const token = process.env.GITHUB_TOKEN;
if (!token) {
return {
content: [{ type: "text", text: "Error: GITHUB_TOKEN not configured" }],
isError: true,
};
}
const url = new URL("https://api.github.com/search/issues");
url.searchParams.set("q", `${query} repo:${owner}/${repo} state:${state}`);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
},
});
if (!response.ok) {
return {
content: [{ type: "text", text: `GitHub API error: ${response.status}` }],
isError: true,
};
}
const data = (await response.json()) as {
total_count: number;
items: Array<{
number: number;
title: string;
state: string;
html_url: string;
body: string | null;
}>;
};
const formatted = data.items.slice(0, 10).map((issue) => ({
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
body: issue.body?.slice(0, 500) ?? "",
}));
return {
content: [
{
type: "text",
text: JSON.stringify({ total: data.total_count, issues: formatted }, null, 2),
},
],
};
}
);
// Tool: create issue comment
server.tool(
"create_issue_comment",
"Post a comment on a GitHub issue.",
{
owner: z.string(),
repo: z.string(),
issue_number: z.number().int().positive(),
body: z.string().min(1).max(65536),
},
async ({ owner, repo, issue_number, body }) => {
const token = process.env.GITHUB_TOKEN;
if (!token) {
return {
content: [{ type: "text", text: "Error: GITHUB_TOKEN not configured" }],
isError: true,
};
}
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
}
);
if (!response.ok) {
return {
content: [{ type: "text", text: `GitHub API error: ${response.status}` }],
isError: true,
};
}
const comment = (await response.json()) as { id: number; html_url: string };
return {
content: [
{
type: "text",
text: `Comment created: ${comment.html_url}`,
},
],
};
}
);
// Resource: fetch a specific file from a repository
server.resource(
"repo-file",
new ResourceTemplate("github://{owner}/{repo}/file/{path}", { list: undefined }),
async (uri, { owner, repo, path }) => {
const token = process.env.GITHUB_TOKEN;
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
{
headers: {
Authorization: `Bearer ${token ?? ""}`,
Accept: "application/vnd.github.v3+json",
},
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const data = (await response.json()) as { content?: string; encoding?: string; name: string };
const content =
data.encoding === "base64" && data.content
? Buffer.from(data.content, "base64").toString("utf-8")
: "";
return {
contents: [
{
uri: uri.toString(),
mimeType: "text/plain",
text: content,
},
],
};
}
);
// Start the server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
The server runs as a process. When connected via stdio, the client spawns it and communicates over stdin/stdout. When you register it in Claude Desktop’s claude_desktop_config.json, Claude’s app layer handles the process lifecycle:
{
"mcpServers": {
"github": {
"command": "node",
"args": ["/path/to/github-server/dist/index.js"],
"env": {
"GITHUB_TOKEN": "ghp_your_token_here"
}
}
}
}
For HTTP transport, swap StdioServerTransport for StreamableHTTPServerTransport and wrap it in an Express or Hono server. This is the right choice when the server needs to serve multiple clients or when you are deploying it as a shared service.
Connecting to Programmatic MCP Clients
Claude Desktop and Cursor handle connection automatically. For programmatic use in your own agent code, you use the MCP client SDK:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import Anthropic from "@anthropic-ai/sdk";
// Start the MCP server as a subprocess
const transport = new StdioClientTransport({
command: "node",
args: ["./dist/github-server/index.js"],
env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "" },
});
const mcpClient = new Client({ name: "agent-client", version: "1.0.0" });
await mcpClient.connect(transport);
// Discover available tools
const { tools } = await mcpClient.listTools();
// Convert MCP tool definitions to Anthropic tool format
const anthropicTools: Anthropic.Tool[] = tools.map((tool) => ({
name: tool.name,
description: tool.description ?? "",
input_schema: tool.inputSchema as Anthropic.Tool["input_schema"],
}));
const anthropic = new Anthropic();
// Run an agent loop
async function runAgentLoop(userMessage: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
while (true) {
const response = await anthropic.messages.create({
model: "claude-opus-4-5",
max_tokens: 4096,
tools: anthropicTools,
messages,
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") {
const textBlock = response.content.find((b) => b.type === "text");
return textBlock?.type === "text" ? textBlock.text : "";
}
if (response.stop_reason !== "tool_use") break;
// Dispatch tool calls through MCP
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
const result = await mcpClient.callTool({
name: block.name,
arguments: block.input as Record<string, unknown>,
});
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result.content
.filter((c) => c.type === "text")
.map((c) => (c.type === "text" ? c.text : ""))
.join("\n"),
is_error: result.isError === true,
});
}
messages.push({ role: "user", content: toolResults });
}
return "";
}
const answer = await runAgentLoop(
"Search for open bugs in the letsbuildsolutions/platform repo and summarize the top 5."
);
console.log(answer);
await mcpClient.close();
The MCP client layer here is purely a transport adapter. You still manage the agent loop, tool result formatting, and message accumulation yourself. MCP does not replace that logic; it standardizes the tool invocation boundary.
Authentication and Security
MCP does not define an authentication mechanism at the protocol level. Authentication is your responsibility and it depends on the transport.
For stdio servers: secrets arrive as environment variables set by the parent process. Never read secrets from tool arguments. The model can be prompted to pass a token; that token will appear in logs and conversation history. Use environment variables exclusively.
For HTTP servers: the March 2025 MCP spec added an OAuth 2.1 authorization framework. The spec defines a flow where MCP servers can act as OAuth resource servers, with clients obtaining tokens through a standard authorization code flow. In practice, most teams use a simpler bearer token approach: the client sends an Authorization: Bearer <token> header, the server validates it before processing any requests. The SDK does not enforce this; you add it as middleware on your HTTP handler:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
// Auth middleware: validate bearer token before MCP handling
app.use("/mcp", (req, res, next) => {
const authHeader = req.headers.authorization;
const expectedToken = process.env.MCP_SERVER_TOKEN;
if (!expectedToken) {
res.status(500).json({ error: "Server not configured" });
return;
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing authorization header" });
return;
}
const token = authHeader.slice(7);
// Use timing-safe comparison in production
if (token !== expectedToken) {
res.status(403).json({ error: "Invalid token" });
return;
}
next();
});
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
app.post("/mcp", (req, res) => transport.handleRequest(req, res));
Beyond authentication, scope your tools conservatively. An MCP server that exposes both read and write operations to the same level should require separate credentials or explicit confirmation for mutations. Treat the model as an untrusted caller: validate all inputs, reject requests outside expected ranges, log every mutation.
Testing MCP Servers
The MCP Inspector (npx @modelcontextprotocol/inspector) is the first tool to reach for. It connects to your server via stdio or HTTP and gives you a UI to list tools, invoke them with arbitrary arguments, and inspect responses. Use it during development to verify your tool schemas and response shapes before connecting a real model.
For automated testing, test the server’s logic directly without going through the transport layer:
import { describe, it, expect, vi } from "vitest";
// Mock the GitHub API calls
vi.mock("node-fetch", () => ({
default: vi.fn(),
}));
describe("search_issues tool", () => {
it("returns formatted issues on success", async () => {
const { default: fetch } = await import("node-fetch");
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
total_count: 2,
items: [
{ number: 42, title: "Auth timeout on login", state: "open", html_url: "https://...", body: "Steps to reproduce..." },
{ number: 43, title: "Token refresh race condition", state: "open", html_url: "https://...", body: null },
],
}),
});
// Import and call the handler directly
const { handleSearchIssues } = await import("../src/handlers/search-issues.js");
const result = await handleSearchIssues({
owner: "myorg",
repo: "platform",
query: "auth",
state: "open",
});
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text as string);
expect(parsed.total).toBe(2);
expect(parsed.issues).toHaveLength(2);
expect(parsed.issues[0].number).toBe(42);
});
it("returns isError true when GitHub API fails", async () => {
const { default: fetch } = await import("node-fetch");
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, status: 403 });
const { handleSearchIssues } = await import("../src/handlers/search-issues.js");
const result = await handleSearchIssues({ owner: "myorg", repo: "platform", query: "auth", state: "open" });
expect(result.isError).toBe(true);
});
});
Structure your server with handlers extracted from the registration calls so they are testable without a live transport. The handler function is pure business logic; the server.tool() call is just registration.
Tradeoffs
| Dimension | MCP | Direct Function Calling |
|---|---|---|
| Setup overhead | Medium (server process, transport config) | Low (inline schema + dispatch) |
| Reusability | High: one server, many clients | Low: reimplemented per agent |
| Protocol coupling | JSON-RPC 2.0; clients must speak MCP | Provider-specific (OpenAI / Anthropic / etc.) |
| Latency | Adds 10-50ms per call (process spawn + IPC) | Minimal; in-process dispatch |
| Debugging | MCP Inspector + structured logs | Direct function traces |
| Distribution | Package as npm module, deploy as service | N/A: always embedded |
| Auth model | DIY (env vars for stdio, bearer/OAuth for HTTP) | DIY |
| Ecosystem | Growing: Claude Desktop, Cursor, Zed, Cline | Universal: any model with function calling |
Production Considerations
Process lifecycle management. When using stdio transport, the client owns the server process. If the client crashes, the server dies with it. For HTTP transport, you need a separate process manager (systemd, Docker, a managed container service). Decide which transport matches your deployment model before you build.
Tool versioning. The MCP spec does not define versioning semantics for individual tools. If you change a tool’s schema, clients may break silently because they cached the old schema or are still using the old argument structure. Adopt a convention: version tool names explicitly (search_issues_v2) or communicate breaking changes through your server’s version field and a changelog. For internal servers, coordinate deploys with client updates.
Timeout and retry behavior. The SDK does not enforce timeouts on tool calls. A slow external API will block the agent loop indefinitely. Add timeouts in your handlers using AbortController:
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 10000): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(id);
}
}
Observability. Log every tool invocation with: tool name, input arguments (redact secrets), duration, outcome (success/error), and error message if applicable. If you are running multiple agents against shared MCP servers, add a caller ID to the log context so you can trace which agent triggered which tool call. Structured logs over plain text: you will query these when an agent loop produces unexpected behavior.
Resource subscriptions. The MCP spec supports resource change notifications via subscriptions. In practice, most servers implement resources as pull-only (the client requests the current state). Push-based subscriptions require the server to maintain connection state per subscriber, which adds significant complexity. Start with pull-only resources; add subscriptions only when you have a concrete use case for live context updates.
Cold starts with stdio transport. If your MCP server loads heavy dependencies (database clients, large SDK initializations), the subprocess cold start adds latency to the first tool call. Pre-warm the process at agent startup, not on first invocation.
When to Use MCP
Start with direct function calling. It is simpler, has no transport overhead, and works with every model that supports tools.
Reach for MCP when you have a tool that multiple agents or multiple clients need to share, when you are distributing a tool integration to external users, or when your team has a large tool catalog that benefits from independent deployment and versioning. The protocol is genuinely useful in those cases. The overhead is not.
The ecosystem is also still maturing. The spec has had three significant revisions since its November 2024 release. Client support for newer features like Streamable HTTP and OAuth is uneven. Build against the current SDK, test against the clients you actually use, and stay conservative about which spec features you depend on in production.
MCP does not change how models think about tools. It standardizes the plumbing that connects them to the world outside the context window. That is the right abstraction to standardize, and the protocol is heading in a good direction.
More in AI / ML
How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models
A deep dive into MoE architecture: how the gating network routes tokens to experts, top-k selection, load balancing losses, capacity factor, token dropping, expert parallelism for serving, and the real production tradeoffs between dense transformers and sparse MoE models.
AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems
A practical comparison of CrewAI, LangGraph, AutoGen, and Mastra for building production AI agent systems. Covers architecture philosophy, state management, tool integration, observability, and deployment patterns with TypeScript code examples.
Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems
A deep dive into Google's Agent2Agent (A2A) protocol covering agent cards, task lifecycle, message parts, streaming via SSE, push notifications, and how A2A complements MCP. Includes TypeScript implementation examples, comparison with MCP and direct API integration, and production deployment patterns for multi-vendor agent ecosystems.
How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs
A technical deep dive into the Transformer architecture: tokenization, positional encoding, self-attention with Q/K/V matrices, multi-head attention, the encoder-decoder split, training dynamics, and what it all means for engineers building on top of LLMs.