Skip to content

MCP Protocol Deep Dive: Building AI Tools That Work Across Any Client

16 min read3190 words

I wrote a practical guide to building MCP servers earlier this year. It covers the basics — setting up a project, defining tools, connecting to Claude Code. If you have not read it, start there.

This post goes deeper. I want to talk about MCP as a protocol, not just as a way to build tools. The distinction matters because when you understand the protocol, you build servers that work across any MCP-compatible client — Claude Code, Cursor, Windsurf, custom agents — and you unlock capabilities most developers never touch: resources, prompts, sampling, dynamic tool registration, and authentication flows.

I have been building and composing MCP servers for the last several months as part of my AI engineering work, and the patterns I will cover here are the ones that actually matter in production.


Why Protocol-Level Thinking Matters

Most MCP tutorials teach you to call server.tool() and stop there. That is like learning HTTP by only knowing how to make GET requests. You can do useful work, but you are missing the full picture.

MCP defines a complete protocol for how AI systems interact with external capabilities. The specification includes:

  • Three primitives — tools, resources, and prompts — each designed for a different interaction pattern
  • Two transport layers — stdio and HTTP with Server-Sent Events (SSE) — for different deployment models
  • Capability negotiation — clients and servers declare what they support during initialization
  • Lifecycle management — initialization, operation, and shutdown phases with proper error handling
  • Sampling — a mechanism for servers to request LLM completions from the client

When you think at the protocol level, you stop building "a tool for Claude Code" and start building "a capability that any AI client can use." That is a fundamentally different design mindset.


The Three Primitives: Tools, Resources, and Prompts

My basic guide covers tools in detail. Here I will focus on the advanced patterns and the two primitives most developers skip: resources and prompts.

Tools: Advanced Patterns

You already know how to define a basic tool. Let me show you the patterns that matter at scale.

Dynamic tool registration allows your server to add or remove tools at runtime based on context. For example, a database server might register different tools depending on which tables exist:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 
const server = new McpServer({
  name: "dynamic-db-server",
  version: "1.0.0",
});
 
async function registerTableTools(tables: string[]) {
  for (const table of tables) {
    server.tool(
      `query_${table}`,
      `Query the ${table} table with a WHERE clause`,
      {
        where: { type: "string", description: "SQL WHERE clause" },
        limit: { type: "number", description: "Max rows to return" },
      },
      async ({ where, limit }) => {
        const rows = await db.query(
          `SELECT * FROM ${table} WHERE ${where} LIMIT ${limit ?? 100}`
        );
        return {
          content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
        };
      }
    );
  }
 
  // Notify connected clients that the tool list changed
  await server.server.sendToolListChanged();
}

The key line is sendToolListChanged(). This fires a notifications/tools/list_changed notification, telling the client to re-fetch the tool list. Without this, clients will not know about your new tools.

Batch operations are another pattern I use constantly. Instead of making the AI call a tool ten times in a loop, expose a single tool that handles batches:

server.tool(
  "bulk_update_tickets",
  "Update multiple Jira tickets at once",
  {
    updates: {
      type: "array",
      items: {
        type: "object",
        properties: {
          ticketId: { type: "string" },
          status: { type: "string" },
          comment: { type: "string" },
        },
        required: ["ticketId"],
      },
      description: "Array of ticket updates to apply",
    },
  },
  async ({ updates }) => {
    const results = await Promise.allSettled(
      updates.map((update) => jira.updateTicket(update))
    );
 
    const summary = results.map((r, i) => ({
      ticketId: updates[i].ticketId,
      status: r.status === "fulfilled" ? "updated" : "failed",
      error: r.status === "rejected" ? r.reason.message : undefined,
    }));
 
    return {
      content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
    };
  }
);

This matters for performance. Every tool call is a round-trip between the client and server. Batch operations reduce that overhead significantly.

Resources: URI-Based Data Access

Resources are the most underused MCP primitive. They provide read-only data to the client through a URI-based scheme. Unlike tools, the client can read resources without the AI explicitly calling them — they serve as context.

Here is how to expose resources with different URI schemes:

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
 
const server = new McpServer({
  name: "resource-server",
  version: "1.0.0",
});
 
// Static resource — always available
server.resource("project-config", "config://project", async (uri) => ({
  contents: [
    {
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(await loadProjectConfig()),
    },
  ],
}));
 
// Dynamic resource with URI template
server.resource(
  "db-table-schema",
  new ResourceTemplate("db://schema/{tableName}", { list: undefined }),
  async (uri, { tableName }) => {
    const schema = await db.getTableSchema(tableName as string);
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(schema, null, 2),
        },
      ],
    };
  }
);
 
// File-like resource with subscription support
server.resource(
  "live-logs",
  new ResourceTemplate("logs://app/{level}", { list: undefined }),
  async (uri, { level }) => {
    const logs = await getRecentLogs(level as string, 50);
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/plain",
          text: logs.join("\n"),
        },
      ],
    };
  }
);

Resources shine when you want to provide context without using tool calls. A database schema resource lets the AI understand your data model before it writes queries. A project config resource gives it architecture context. Log resources help with debugging.

Resource subscriptions allow clients to watch for changes. When you update a resource, subscribed clients get notified:

// When the resource data changes
await server.server.sendResourceUpdated(
  new URL("db://schema/users")
);

The client can then re-fetch the resource to get the updated data. This is useful for live data — monitoring dashboards, deployment status, log streams.

Prompts: Reusable Templates

Prompts are pre-defined prompt templates that your server exposes. They standardize common interactions and let clients surface them in their UI (Claude Code shows them as slash commands).

server.prompt(
  "code-review",
  "Structured code review with security and performance checks",
  {
    filePath: {
      type: "string",
      description: "Path to the file to review",
      required: true,
    },
    focus: {
      type: "string",
      description: "Review focus: security, performance, readability, or all",
      required: false,
    },
  },
  async ({ filePath, focus }) => {
    const fileContent = await fs.readFile(filePath, "utf-8");
    const focusArea = focus ?? "all";
 
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Review the following code with a focus on ${focusArea}.
 
File: ${filePath}
 
\`\`\`
${fileContent}
\`\`\`
 
Provide:
1. A severity-ranked list of issues found
2. Specific line numbers for each issue
3. Suggested fixes with code examples
4. An overall quality score (1-10)`,
          },
        },
      ],
    };
  }
);

Prompts are different from tools because they return messages that become part of the conversation, not results that the AI interprets. The client inserts the prompt's output directly into the chat. This makes them ideal for standardizing workflows across a team — everyone uses the same review prompt, the same debugging template, the same deployment checklist.


Transport Layers: stdio vs HTTP/SSE

MCP supports two transport mechanisms, and choosing the right one matters.

stdio Transport

The server runs as a child process. The client spawns it, communicates via stdin/stdout, and kills it when done.

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
const transport = new StdioServerTransport();
await server.connect(transport);

Use stdio when:

  • The server runs locally on the developer's machine
  • You need zero configuration (no ports, no URLs)
  • You want process isolation — the client manages the server's lifecycle
  • You are building personal or team developer tools

This is what Claude Code uses by default. It spawns your server process, sends JSON-RPC messages over stdin, and reads responses from stdout. Simple, reliable, no networking involved.

Streamable HTTP Transport

The server runs as an HTTP service. Clients connect to it over the network using Server-Sent Events for streaming.

import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
 
const app = express();
app.use(express.json());
 
app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  await server.connect(transport);
  await transport.handleRequest(req, res);
});
 
app.listen(3001, () => {
  console.log("MCP server listening on http://localhost:3001/mcp");
});

Use HTTP/SSE when:

  • Multiple clients need to connect to the same server
  • The server runs on a remote machine or in the cloud
  • You need authentication and authorization
  • You are building shared team infrastructure
  • You want to deploy the server independently of any client

The HTTP transport is where MCP moves from a developer tool to team infrastructure. A single server can serve an entire engineering team, with authentication controlling who accesses what.


Advanced Patterns

Sampling: Server-Initiated AI Calls

Sampling is the most powerful and least understood MCP feature. It lets your server ask the client's LLM to do work on the server's behalf.

Why would you want this? Consider a server that indexes a codebase. When a new file is added, you want to generate a summary. Instead of calling a separate LLM API (and managing keys, costs, rate limits), your server asks the client's model to summarize it:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
 
const server = new Server(
  { name: "indexing-server", version: "1.0.0" },
  { capabilities: { sampling: {} } }
);
 
async function summarizeFile(filePath: string, content: string) {
  const result = await server.createMessage({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Summarize this file in 2-3 sentences for a code index.
                 Focus on what it does and its key exports.
                 File: ${filePath}\n\n${content}`,
        },
      },
    ],
    maxTokens: 200,
  });
 
  return result.content.type === "text" ? result.content.text : "";
}

The client controls the actual model call. It can apply its own safety filters, rate limits, and model selection. Your server does not need API keys or model access — it delegates to whatever model the client is using.

Important caveat: Not all clients support sampling yet. Check the client's capabilities during initialization before relying on it.

Dynamic Tools: Runtime Registration

I showed dynamic tool registration earlier. Let me expand on the architecture pattern.

A common scenario: you have an MCP server that connects to a SaaS platform with multiple modules. Different users have access to different modules. Instead of exposing every possible tool and hoping the AI picks the right ones, you register tools based on the user's permissions:

async function initializeForUser(userId: string) {
  const permissions = await getPermissions(userId);
 
  if (permissions.includes("analytics")) {
    server.tool("run_report", "Run an analytics report", {
      reportId: { type: "string" },
      dateRange: { type: "string" },
    }, async ({ reportId, dateRange }) => {
      return { content: [{ type: "text", text: await runReport(reportId, dateRange) }] };
    });
  }
 
  if (permissions.includes("admin")) {
    server.tool("manage_users", "Create, update, or delete users", {
      action: { type: "string", enum: ["create", "update", "delete"] },
      userData: { type: "object" },
    }, async ({ action, userData }) => {
      return { content: [{ type: "text", text: await manageUser(action, userData) }] };
    });
  }
 
  await server.server.sendToolListChanged();
}

This keeps the tool list clean. The AI only sees tools it can actually use, which reduces confusion and improves tool selection accuracy.

Composing Multiple MCP Servers

In practice, you will run multiple MCP servers. I currently have six configured in my Claude Code setup: GitHub, Notion, Perplexity, a custom knowledge base, a Figma integration, and an internal team tools server.

The client handles composition — it connects to each server independently and merges their capabilities. But you need to design for this:

Namespace your tools. If two servers both expose a search tool, the AI cannot distinguish them. Use prefixes:

// In your knowledge base server
server.tool("kb_search", "Search the internal knowledge base", ...);
 
// In your docs server
server.tool("docs_search", "Search public documentation", ...);

Keep servers focused. One server per domain. Do not build a monolith server that handles GitHub, Jira, Slack, and your database. Split them:

ServerResponsibilityTools
github-serverRepository operationsgh_search_code, gh_create_pr, gh_list_issues
jira-serverProject managementjira_get_ticket, jira_update_status, jira_search
db-serverDatabase queriesdb_query, db_schema, db_explain
notify-serverNotificationsslack_send, email_send, teams_post

This makes each server easier to test, deploy, and maintain. It also lets different team members own different servers.


Authentication and Security for Remote MCP Servers

Local stdio servers inherit the user's permissions — whatever the user can access, the server can access. Remote HTTP servers need explicit authentication.

The MCP specification supports OAuth 2.0 for remote servers. Here is the pattern:

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import jwt from "jsonwebtoken";
 
const app = express();
 
// Middleware to verify JWT tokens
function authenticateMCP(req: express.Request, res: express.Response, next: express.NextFunction) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing authorization header" });
  }
 
  try {
    const token = authHeader.split(" ")[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET!);
    req.user = decoded;
    next();
  } catch {
    return res.status(403).json({ error: "Invalid token" });
  }
}
 
app.post("/mcp", authenticateMCP, async (req, res) => {
  // User context is available for permission-based tool registration
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  await server.connect(transport);
  await transport.handleRequest(req, res);
});

Security principles for remote MCP servers:

  1. Always authenticate. No anonymous access to production MCP servers.
  2. Scope tool access. Use the user's permissions to control which tools are available (dynamic registration pattern above).
  3. Validate all inputs. The AI generates tool arguments. Treat them like user input — validate and sanitize everything.
  4. Rate limit aggressively. A runaway agent loop can generate hundreds of tool calls per minute. Protect your backend.
  5. Log everything. Every tool call, every result, every error. You need this for debugging and auditing.

Real-World Architecture: Internal Team Tools

Let me walk through the architecture of an MCP server I built for a team's internal tools. This is the kind of thing that saves hours per week once it is running.

The scenario: An engineering team needs AI access to their deployment pipeline, incident management, feature flags, and internal documentation.

                    +-----------------+
                    |  Claude Code /  |
                    |  Cursor / Any   |
                    |  MCP Client     |
                    +--------+--------+
                             |
                    (stdio or HTTP/SSE)
                             |
                    +--------+--------+
                    |   MCP Gateway   |
                    |   (auth + routing)|
                    +--------+--------+
                             |
              +--------------+--------------+
              |              |              |
     +--------+--+  +-------+---+  +-------+---+
     | Deploy     |  | Incidents  |  | Feature   |
     | Server     |  | Server     |  | Flags     |
     +------------+  +------------+  +-----------+

The gateway pattern is optional but useful for large teams. It handles authentication once and routes requests to the appropriate backend server. For smaller teams, connecting directly to individual servers works fine.

The deploy server exposes tools like deploy_status, deploy_trigger, and deploy_rollback. The incidents server exposes incident_list, incident_create, and incident_update. Each server owns its domain and can be developed and deployed independently.


Testing MCP Servers

Testing MCP servers requires a different approach than testing typical APIs.

MCP Inspector

The MCP Inspector is the first tool to reach for. It connects to your server and lets you browse tools, call them manually, and inspect the JSON-RPC messages:

npx @modelcontextprotocol/inspector node dist/index.js

This opens a web UI where you can see every tool, resource, and prompt your server exposes. You can call tools with custom arguments and see the raw responses. I use it constantly during development.

Automated Tests

For automated testing, create a test client that connects to your server programmatically:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { describe, it, expect } from "vitest";
 
describe("MCP Server", () => {
  let client: Client;
 
  beforeAll(async () => {
    const transport = new StdioClientTransport({
      command: "node",
      args: ["dist/index.js"],
    });
    client = new Client(
      { name: "test-client", version: "1.0.0" },
      { capabilities: {} }
    );
    await client.connect(transport);
  });
 
  afterAll(async () => {
    await client.close();
  });
 
  it("should list all expected tools", async () => {
    const { tools } = await client.listTools();
    const toolNames = tools.map((t) => t.name);
    expect(toolNames).toContain("kb_search");
    expect(toolNames).toContain("kb_index");
  });
 
  it("should return results for a valid search", async () => {
    const result = await client.callTool({
      name: "kb_search",
      arguments: { query: "deployment process" },
    });
    expect(result.content).toHaveLength(1);
    expect(result.content[0].type).toBe("text");
  });
 
  it("should handle errors gracefully", async () => {
    const result = await client.callTool({
      name: "kb_search",
      arguments: { query: "" },
    });
    expect(result.isError).toBe(true);
  });
});

CI/CD Integration

Run your MCP server tests in CI just like any other test suite. The server is a Node.js process — no special infrastructure needed:

# .github/workflows/test-mcp.yml
name: Test MCP Server
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci
      - run: npm run build
      - run: npm test

The key insight is that MCP servers are just programs. You test them by being a client. The SDK gives you both server and client libraries for exactly this reason.


The MCP Ecosystem: What Is Coming

MCP is moving fast. Here is what I am watching:

Server registries are emerging. Instead of manually configuring each server, clients will discover servers from registries — similar to how package managers work. Anthropic has launched an official registry, and community registries are growing.

Streamable HTTP is replacing SSE. The newest version of the spec introduces Streamable HTTP transport, which simplifies the HTTP-based communication model. If you are building a new remote server today, use StreamableHTTPServerTransport.

Multi-modal tools are coming. Tools that accept and return images, audio, and other media types. The protocol already supports image content types in tool results:

server.tool("generate_chart", "Create a chart from data", {
  data: { type: "object" },
  chartType: { type: "string" },
}, async ({ data, chartType }) => {
  const imageBuffer = await renderChart(data, chartType);
  return {
    content: [{
      type: "image",
      data: imageBuffer.toString("base64"),
      mimeType: "image/png",
    }],
  };
});

Composability standards are being discussed. Right now, composing multiple servers is ad hoc. The community is working on patterns for server-to-server communication, shared context, and coordinated tool execution.

Enterprise adoption is accelerating. Teams are building MCP servers for internal tools, and companies are shipping MCP servers alongside their APIs. If you build SaaS tools, shipping an MCP server is becoming as expected as shipping a REST API.


Where to Go from Here

If you are new to MCP, start with my basic guide and build a simple tool server. Get it working with Claude Code. Then come back here and add resources, prompts, and the advanced patterns.

If you are already building MCP servers, the highest-leverage moves are:

  1. Add resources to your existing servers. Give the AI context without burning tool calls.
  2. Namespace your tools if you run multiple servers. Prevent collisions.
  3. Write automated tests using the MCP client SDK. Catch regressions early.
  4. Consider HTTP transport if your team is growing. A shared remote server beats everyone running their own local copy.

MCP is the protocol layer that makes AI tools interoperable. The servers you build today will work with clients that do not exist yet. That is the power of protocol-level thinking — you are building for an ecosystem, not a single product.