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:
| Class | Package export | Purpose | When to use |
|---|---|---|---|
Server | @modelcontextprotocol/sdk/server/index.js | Raw JSON-RPC handler; manages protocol negotiation, session state, and message routing | Building custom frameworks; implementing MCP capabilities the high-level API doesn't expose |
McpServer | @modelcontextprotocol/sdk/server/mcp.js | High-level wrapper; type-safe .tool(), .resource(), .prompt() registration on top of Server | Almost 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:
- Constructed — handlers are registered, but no transport is connected. The server has no active session.
- Connected —
server.connect(transport)is called. The server attaches to the transport'sonmessagecallback and callstransport.start(). When a client connects, theinitializehandshake runs immediately: the server reads the client'sclientInfoandcapabilities, responds with its own capabilities, and awaits the client'snotifications/initializednotification before processing any other methods. - Running — the handshake is complete. Tool calls, resource reads, and prompt lookups are dispatched to registered handlers. The
Server.onerrorcallback fires on protocol errors. - Closed —
server.close()disconnects the transport. Any in-flight requests receive an error response or are silently dropped (depends on transport implementation). You must callserver.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:
| Transport | Import path | Use case |
|---|---|---|
StdioServerTransport | .../server/stdio.js | Local tools, Claude Desktop plugins, CLI integrations |
SSEServerTransport | .../server/sse.js | Legacy HTTP streaming (persistent process required) |
StreamableHTTPServerTransport | .../server/streamableHttp.js | Modern 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:
- The transport's
onmessagecallback fires with the raw JSON-RPC object. Servervalidates the message structure (must be a JSON-RPC 2.0 request, response, or notification).- For requests, the server looks up the method name (
"tools/call") in its internal handler registry, which is aMap<string, RequestHandler>. - 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. - If validation passes, the handler is called with the validated params object and a request context that includes the client's identity from the
initializehandshake. - 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, forCallToolResult, toisError: truewith 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:
- Transport-layer failures — TLS certificate expiry, HTTP routing misconfiguration, Caddy/nginx proxy issues that cause 502 errors instead of 200s
- Environment failures — missing or rotated API keys, wrong database connection strings in production, secret injection failures in Docker/Kubernetes
- Protocol version drift — a dependency upgrade that silently changes the
initializeresponse format, breaking clients that checkprotocolVersion - Cold start failures on edge runtimes — the first invocation after a cold start may fail due to resource initialization order issues that don't appear in warm-process tests
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
- MCP server SDK comparison — TypeScript vs Python vs Go
- Building MCP servers in TypeScript — getting started guide
- Debugging TypeScript MCP servers — stdio, inspector, and protocol traces
- MCP server testing — unit, integration, and transport-layer strategies
- MCP server transport selection — SSE vs StreamableHTTP vs stdio
- MCP server session lifecycle — connect, initialize, and cleanup
- AliveMCP — production protocol monitoring for MCP servers