Guide · TypeScript SDK

MCP TypeScript SDK internals

The MCP TypeScript SDK is intentionally minimal — it handles the protocol layer and leaves the rest to you. Understanding how the internals work — the two-class design, transport abstraction, request routing, and the in-memory transport available for testing — lets you extend SDK behavior, debug protocol-level issues, and write tests that catch what vitest mocks miss.

TL;DR

The SDK has two layers: the low-level Server class (raw JSON-RPC handler, session management, protocol negotiation) and the high-level McpServer wrapper (type-safe tool/resource/prompt registration built on top of Server). Transports implement a Transport interface with four methods: start(), close(), send(message), and an onmessage callback. Tool calls flow as JSON-RPC 2.0 requests, dispatched by method name through the Server's request handler registry. Test with InMemoryTransport.createLinkedPair() to exercise the full protocol stack without an HTTP server. Wire AliveMCP for production protocol monitoring — InMemoryTransport tests catch code bugs, external probing catches deployment and configuration failures.

The two-class design: Server vs McpServer

The SDK exports two server classes that serve different purposes:

ClassPackage exportPurposeWhen to use
Server@modelcontextprotocol/sdk/server/index.jsRaw JSON-RPC handler; manages protocol negotiation, session state, and message routingBuilding custom frameworks; implementing MCP capabilities the high-level API doesn't expose
McpServer@modelcontextprotocol/sdk/server/mcp.jsHigh-level wrapper; type-safe .tool(), .resource(), .prompt() registration on top of ServerAlmost all production use cases

McpServer holds a reference to an internal Server instance. Every server.tool() call registers a request handler on that internal Server for the method tools/call. The high-level API is a convenience layer — nothing it does is magic that you can't replicate with the low-level Server.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";

// McpServer wraps a Server internally — you can access it:
const mcp = new McpServer({ name: "my-server", version: "1.0.0" });
// mcp.server is the underlying Server instance
// (unofficial API — SDK internals can change across minor versions)

If you need direct access to the underlying session negotiation, capabilities, or request/response interception, use Server directly and register handlers with server.setRequestHandler(schema, handler).

Server class lifecycle

A Server (or McpServer) goes through four lifecycle states:

  1. Constructed — handlers are registered, but no transport is connected. The server has no active session.
  2. Connectedserver.connect(transport) is called. The server attaches to the transport's onmessage callback and calls transport.start(). When a client connects, the initialize handshake runs immediately: the server reads the client's clientInfo and capabilities, responds with its own capabilities, and awaits the client's notifications/initialized notification before processing any other methods.
  3. Running — the handshake is complete. Tool calls, resource reads, and prompt lookups are dispatched to registered handlers. The Server.onerror callback fires on protocol errors.
  4. Closedserver.close() disconnects the transport. Any in-flight requests receive an error response or are silently dropped (depends on transport implementation). You must call server.close() in shutdown hooks to release resources.
// Full lifecycle in code
const server = new McpServer({ name: "example", version: "1.0.0" });
server.tool("ping", "Returns pong", {}, async () => ({
  content: [{ type: "text", text: "pong" }]
}));

// State: Constructed
const transport = new StdioServerTransport();
await server.connect(transport);
// State: Connected → Running (after initialize handshake)

// In SIGTERM handler:
process.on("SIGTERM", async () => {
  await server.close();  // State: Closed
  process.exit(0);
});

One important constraint: a Server instance can only be connected to one transport at a time. For servers that handle multiple simultaneous client connections (e.g., an HTTP server where each request gets a new session), create a new McpServer instance per session — or use the stateless StreamableHTTPServerTransport pattern where each request is a self-contained session.

Transport abstraction

Every transport implements the Transport interface:

interface Transport {
  // Called by Server.connect() — start accepting/sending messages
  start(): Promise<void>;

  // Called by Server.close() — flush and tear down
  close(): Promise<void>;

  // Called by Server internals to send a response or notification to the client
  send(message: JSONRPCMessage): Promise<void>;

  // Set by Server.connect() — called by the transport when it receives a message from the client
  onmessage?: (message: JSONRPCMessage) => void;

  // Set by Server.connect() — called by the transport when the connection closes
  onclose?: () => void;

  // Set by Server.connect() — called by the transport on I/O errors
  onerror?: (error: Error) => void;
}

The SDK ships three production transports:

TransportImport pathUse case
StdioServerTransport.../server/stdio.jsLocal tools, Claude Desktop plugins, CLI integrations
SSEServerTransport.../server/sse.jsLegacy HTTP streaming (persistent process required)
StreamableHTTPServerTransport.../server/streamableHttp.jsModern HTTP; stateless or stateful; works on edge runtimes

Writing a custom transport is straightforward — implement the four methods above. This is useful for testing, for embedding an MCP server inside a larger application, or for unusual transport requirements (WebSockets, Unix domain sockets, shared-memory IPC).

Tool call dispatch: how requests reach your handler

When a client sends a tools/call request, the dispatch path is:

  1. The transport's onmessage callback fires with the raw JSON-RPC object.
  2. Server validates the message structure (must be a JSON-RPC 2.0 request, response, or notification).
  3. For requests, the server looks up the method name ("tools/call") in its internal handler registry, which is a Map<string, RequestHandler>.
  4. The handler validates the request parameters against the method's Zod schema. If validation fails, the server responds with a JSON-RPC error (-32602 InvalidParams) immediately — your tool handler is never called.
  5. If validation passes, the handler is called with the validated params object and a request context that includes the client's identity from the initialize handshake.
  6. The handler's return value is serialized and sent as a JSON-RPC result via transport.send(). If the handler throws, the error is mapped to a JSON-RPC error response or, for CallToolResult, to isError: true with the error message as text content.
// What McpServer.tool() registers under the hood (simplified)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  const tool = toolRegistry.get(name);
  if (!tool) {
    throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`);
  }
  const validated = tool.inputSchema.parse(args);  // Zod parse
  const result = await tool.handler(validated);
  return result;  // CallToolResult
});

The critical insight: schema validation happens in the SDK before your handler runs. You don't need to validate args inside your tool handler — by the time it's called, args is already a fully-typed object matching your Zod schema. Add handler-level validation only for business rules the schema can't express (cross-field constraints, database lookups to verify referential integrity).

Testing with InMemoryTransport

InMemoryTransport creates a linked pair of in-process transports — a client transport and a server transport — connected by an in-memory message queue. This lets you exercise the full MCP protocol stack (initialization handshake, tool call dispatch, schema validation, error mapping) without an HTTP server or network I/O:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
import { describe, it, expect, beforeEach, afterEach } from "vitest";

describe("lookup_user tool", () => {
  let server: McpServer;
  let client: Client;

  beforeEach(async () => {
    server = new McpServer({ name: "test-server", version: "1.0.0" });
    server.tool(
      "lookup_user",
      "Look up a user by ID",
      { id: z.string().uuid() },
      async ({ id }) => {
        if (id === "00000000-0000-0000-0000-000000000001") {
          return { content: [{ type: "text", text: JSON.stringify({ id, name: "Alice" }) }] };
        }
        return { isError: true, content: [{ type: "text", text: `User not found: ${id}` }] };
      }
    );

    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    client = new Client({ name: "test-client", version: "1.0.0" }, {});
    // Connect both ends — initialize handshake runs automatically
    await Promise.all([
      client.connect(clientTransport),
      server.connect(serverTransport),
    ]);
  });

  afterEach(async () => {
    await client.close();
    await server.close();
  });

  it("returns user for a known ID", async () => {
    const result = await client.callTool({
      name: "lookup_user",
      arguments: { id: "00000000-0000-0000-0000-000000000001" },
    });
    expect(result.isError).toBeFalsy();
    const user = JSON.parse((result.content[0] as { text: string }).text);
    expect(user.name).toBe("Alice");
  });

  it("returns isError:true for unknown IDs", async () => {
    const result = await client.callTool({
      name: "lookup_user",
      arguments: { id: "00000000-0000-0000-0000-000000000999" },
    });
    expect(result.isError).toBe(true);
  });

  it("rejects invalid UUID — SDK schema validation fires before handler", async () => {
    await expect(
      client.callTool({ name: "lookup_user", arguments: { id: "not-a-uuid" } })
    ).rejects.toThrow(); // InvalidParams from SDK, handler never called
  });
});

InMemoryTransport tests catch: schema validation behavior, handler logic, error mapping (throw vs isError: true), tool registration mistakes (wrong name, wrong schema), and initialization handshake issues. They don't catch: HTTP routing errors, TLS configuration, production environment variable issues, or failures that only appear under real network conditions.

Extending SDK behavior

The SDK is designed for extension at the handler level, not via subclassing. Three patterns for adding cross-cutting behavior:

Handler wrapper: The simplest pattern — wrap each tool handler with a higher-order function that adds logging, auth, or error normalization:

function withAuth<T extends Record<string, unknown>>(
  handler: (args: T, ctx: { userId: string }) => Promise<CallToolResult>
) {
  return async (args: T): Promise<CallToolResult> => {
    const userId = extractUserIdFromContext(); // your auth logic
    if (!userId) return { isError: true, content: [{ type: "text", text: "Unauthorized" }] };
    return handler(args, { userId });
  };
}

server.tool("get_profile", "Get user profile", { targetId: z.string() },
  withAuth(async ({ targetId }, { userId }) => {
    // userId is available and verified
    const profile = await db.getProfile(targetId, userId);
    return { content: [{ type: "text", text: JSON.stringify(profile) }] };
  })
);

Low-level request interception: Use the underlying Server class to intercept requests before they reach tool handlers. Call server.server.setRequestHandler() on the McpServer's internal Server instance — but note this is an unstable internal API. A more stable approach: handle authentication at the HTTP middleware layer before requests reach the MCP transport.

Custom transport for in-process embedding: If you're embedding an MCP server inside a larger application (as a sidecar or plugin system), implement a custom transport that routes messages through an event emitter or message bus instead of network I/O. This avoids the overhead of HTTP serialization for same-process communication.

What InMemoryTransport tests miss — and why AliveMCP fills the gap

InMemoryTransport tests run in the same process as your server. That means they can't catch:

These failure modes are exactly what AliveMCP catches: it probes the real MCP initialize endpoint every 60 seconds from outside your infrastructure, verifies the protocol response, tracks the tools/list hash for schema drift, and alerts you before users notice. Use InMemoryTransport tests for logic correctness; use AliveMCP for runtime health.

# Quick protocol health check — same path AliveMCP uses:
curl -sX POST https://your-mcp-server.com/ \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"health-check","version":"1.0"}}}' \
  | jq '.result.serverInfo.name'

Frequently asked questions

When should I use Server directly instead of McpServer?

Use Server directly when you need to register handlers for MCP methods that McpServer doesn't expose through its high-level API — for example, custom notification handlers, capability negotiation hooks, or protocol methods added in newer MCP spec versions that the SDK's high-level API doesn't support yet. In most cases McpServer is correct; reach for Server only when you've confirmed the high-level API can't do what you need.

Can I share a single McpServer instance across multiple concurrent HTTP connections?

No — a McpServer (and its underlying Server) holds per-session state from the initialize handshake. Sharing one instance across concurrent HTTP connections would mix session state. For HTTP servers handling multiple concurrent clients, create a new McpServer instance per session (keyed on a session ID), or use StreamableHTTPServerTransport in stateless mode where each request is self-contained and no session state is held.

What happens if my tool handler throws an unhandled exception?

Unhandled throws from tool handlers are caught by the SDK's request dispatcher and mapped to an isError: true result with the error message as text content — they don't crash the server or produce a JSON-RPC error code. The Server.onerror callback also fires. Prefer returning { isError: true, content: [{ type: "text", text: error.message }] } explicitly so you control the message format; unhandled throws expose stack traces and raw error messages that may leak implementation details.

Can I use InMemoryTransport in a jest/vitest worker thread?

Yes. InMemoryTransport is a plain TypeScript class with no network I/O, no native modules, and no global side effects. It works in any Node.js execution context including worker threads and test runners with isolated VM contexts. The only constraint is that the linked pair must be connected in the same process — you can't split the client and server across separate worker threads because the in-memory queue is shared via JavaScript object references.

How do I test notifications from server to client?

Use InMemoryTransport with the client's setNotificationHandler to capture notifications. After calling the tool that triggers the notification, check what the client received. For progress notifications: register a handler for notifications/progress before calling the tool, then verify the handler received the expected messages. The in-memory transport delivers notifications synchronously in the same event loop tick, so you don't need to poll.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free