I built an MCP server without the @modelcontextprotocol/sdk β here's what I learned
Bypassing the MCP SDK: A Wire-First Approach to JSON-RPC Tool Servers
Current Situation Analysis
The Model Context Protocol (MCP) has rapidly become the standard interface for connecting AI agents to external data sources and execution environments. With its adoption, a silent assumption has taken root in developer workflows: implementing an MCP server requires installing the official @modelcontextprotocol/sdk. This assumption is rarely questioned because the SDK is prominently featured in documentation, tutorial scaffolds, and reference implementations.
The friction emerges when your existing architecture doesn't align with the SDK's design constraints. The official SDK abstracts the wire protocol behind a Transport layer that heavily favors Express-style request/response objects and manages its own session lifecycle. If you're running on edge runtimes, using a micro-framework like Hono, Fastify, or Elysia, or simply want to compose MCP endpoints alongside existing OAuth, rate-limiting, and observability middleware, the SDK's HTTP transport becomes a liability. You're forced to either write adapter layers that translate framework contexts into Express shapes, or spin up a parallel HTTP server solely to satisfy the SDK's transport requirements.
This problem is overlooked because developers conflate the protocol with its reference implementation. The MCP specification is fundamentally a JSON-RPC 2.0 contract. For a tools-only server, the protocol surface area is remarkably small. The SDK bundles session resumption, Server-Sent Events (SSE) streaming, and batch orchestration that most synchronous tool servers never utilize. When the abstraction layer costs more in cognitive overhead and framework incompatibility than the features it provides, the rational engineering decision is to drop the SDK and speak directly to the wire.
WOW Moment: Key Findings
The decision to implement MCP at the wire level isn't about rejecting abstractions; it's about matching implementation complexity to actual requirements. The following comparison illustrates the operational trade-offs between the SDK-driven approach and a direct JSON-RPC dispatcher.
| Approach | Framework Compatibility | Streaming/SSE Support | Testing Complexity | Bundle Overhead | Spec Drift Maintenance |
|---|---|---|---|---|---|
| Official SDK | Express-centric; requires adapters for Hono/Fastify/Edge | Native support via StreamableHTTPServerTransport |
High (requires mocking transport layer, binding ports) | High (session manager, SSE polyfills, schema validators) | Low (SDK updates handle new methods automatically) |
| Custom Dispatcher | Framework-agnostic; mounts natively on any router | Manual implementation required | Low (pure function dispatch, no I/O mocking) | Minimal (only framework + routing logic) | Medium (manual switch-case updates for new spec methods) |
| Hybrid Bridge | SDK handles STDIO subprocess; custom handles HTTP | SDK for STDIO, custom for HTTP | Medium (test bridge separately from dispatcher) | Moderate (SDK only in subprocess binary) | Low-Medium (SDK for STDIO, manual for HTTP) |
This finding matters because it decouples protocol compliance from framework lock-in. A wire-first implementation reduces the attack surface, eliminates unnecessary runtime dependencies, and transforms MCP integration into a standard HTTP routing problem. It enables teams to deploy MCP endpoints on serverless platforms, edge networks, or existing monoliths without restructuring their middleware stack.
Core Solution
Building an MCP server without the SDK requires implementing three JSON-RPC 2.0 methods: initialize, tools/list, and tools/call. The implementation should be framework-agnostic at the core, with routing handled by a thin adapter layer.
Step 1: Define the JSON-RPC Envelope
JSON-RPC 2.0 mandates a strict response shape. We'll define explicit types for requests, responses, and error codes. This eliminates runtime type guessing and enforces contract compliance at compile time.
export type JsonRpcId = string | number | null;
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: JsonRpcId;
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcSuccessResponse<T = unknown> {
jsonrpc: "2.0";
id: JsonRpcId;
result: T;
}
export interface JsonRpcErrorResponse {
jsonrpc: "2.0";
id: JsonRpcId;
error: {
code: number;
message: string;
data?: unknown;
};
}
export const RPC_ERROR_CODES = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
} as const;
Step 2: Build a Tool Registry with Schema Contracts
Tools are registered as plain objects containing metadata, input validation schemas, and execution handlers. The registry maps tool names to definitions for O(1) lookups during dispatch.
export interface ToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
}
export class ToolRegistry {
private readonly catalog: Map<string, ToolDefinition>;
constructor(tools: ToolDefinition[]) {
this.catalog = new Map(tools.map((t) => [t.name, t]));
}
public list(): Pick<ToolDefinition, "name" | "description" | "inputSchema">[] {
return Array.from(this.catalog.values()).map(({ name, description, inputSchema }) => ({
name,
description,
inputSchema,
}));
}
public get(name: string): ToolDefinition | undefined {
return this.catalog.get(name);
}
}
Step 3: Implement the Pure Dispatch Function
The dispatcher is a pure function that accepts a parsed JSON-RPC request and returns a response object. It handles method routing, parameter validation, and error wrapping. By keeping it pure, we eliminate I/O dependencies and make unit testing trivial.
export async function dispatchMcpRequest(
request: JsonRpcRequest,
registry: ToolRegistry,
protocolVersion: string,
serverMeta: { name: string; version: string }
): Promise<JsonRpcSuccessResponse | JsonRpcErrorResponse> {
const { id, method, params } = request;
switch (method) {
case "initialize": {
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion,
serverInfo: serverMeta,
capabilities: { tools: { listChanged: false } },
},
};
}
case "tools/list": {
return {
jsonrpc: "2.0",
id,
result: { tools: registry.list() },
};
}
case "tools/call": {
if (!params || typeof params.name !== "string") {
return {
jsonrpc: "2.0",
id,
error: {
code: RPC_ERROR_CODES.INVALID_PARAMS,
message: "Missing or invalid 'name' parameter in tools/call",
},
};
}
const tool = registry.get(params.name);
if (!tool) {
return {
jsonrpc: "2.0",
id,
error: {
code: RPC_ERROR_CODES.METHOD_NOT_FOUND,
message: `Tool not found: ${params.name}`,
},
};
}
try {
const output = await tool.execute(params.arguments ?? {});
return { jsonrpc: "2.0", id, result: output };
} catch (err) {
return {
jsonrpc: "2.0",
id,
error: {
code: RPC_ERROR_CODES.INTERNAL_ERROR,
message: err instanceof Error ? err.message : "Tool execution failed",
},
};
}
}
default:
return {
jsonrpc: "2.0",
id,
error: {
code: RPC_ERROR_CODES.METHOD_NOT_FOUND,
message: `Unsupported method: ${method}`,
},
};
}
}
Step 4: Mount on a Lightweight Router
The routing layer handles HTTP parsing, batch request support, and serialization. This example uses Hono, but the logic translates directly to Express, Fastify, or native Node http.
import { Hono } from "hono";
import { dispatchMcpRequest, JsonRpcRequest, RPC_ERROR_CODES } from "./dispatcher.js";
import { registry } from "./tools.js";
const MCP_VERSION = "2025-06-18";
const SERVER_META = { name: "custom-mcp-server", version: "1.0.0" };
export const mcpRouter = new Hono();
mcpRouter.post("/rpc", async (ctx) => {
let rawBody: unknown;
try {
rawBody = await ctx.req.json();
} catch {
return ctx.json(
{ jsonrpc: "2.0", id: null, error: { code: RPC_ERROR_CODES.PARSE_ERROR, message: "Invalid JSON" } },
400
);
}
const requests = Array.isArray(rawBody) ? rawBody : [rawBody];
const validatedRequests = requests.filter(
(r): r is JsonRpcRequest =>
typeof r === "object" && r !== null && r.jsonrpc === "2.0" && typeof r.method === "string"
);
if (validatedRequests.length === 0) {
return ctx.json(
{ jsonrpc: "2.0", id: null, error: { code: RPC_ERROR_CODES.INVALID_REQUEST, message: "No valid RPC requests" } },
400
);
}
const responses = await Promise.all(
validatedRequests.map((req) => dispatchMcpRequest(req, registry, MCP_VERSION, SERVER_META))
);
return ctx.json(Array.isArray(rawBody) ? responses : responses[0]);
});
Architecture Decisions & Rationale
- Pure Dispatch Function: Separating routing from business logic allows the dispatcher to be tested without mocking HTTP servers, ports, or transport streams. You pass a JSON object, assert the output. This reduces test execution time and eliminates flaky I/O tests.
- Explicit Error Wrapping: JSON-RPC 2.0 requires specific error codes. Catching exceptions at the tool execution layer and mapping them to
-32603prevents stack traces from leaking to clients while maintaining protocol compliance. - Batch Request Support: The JSON-RPC spec allows arrays of requests. The router normalizes single and batch payloads, processes them concurrently, and returns responses in the same shape. This matches client expectations without adding complexity to the dispatcher.
- Framework Decoupling: The dispatcher has zero dependencies on HTTP libraries. It only requires a
ToolRegistry, a protocol version string, and server metadata. This makes migration between runtimes (Node, Bun, Deno, Cloudflare Workers) a configuration change, not a rewrite.
Pitfall Guide
1. Ignoring JSON-RPC 2.0 Batch Requests
Explanation: Clients may send arrays of requests to reduce latency. If your router only handles single objects, batched calls will fail or return malformed responses. Fix: Normalize incoming payloads to arrays, process each request independently, and return an array if the input was an array. Never mix single and batch response shapes.
2. Hardcoding Protocol Versions
Explanation: The MCP spec evolves. Pinning to a static string without a versioning strategy breaks compatibility when clients negotiate newer protocol features.
Fix: Store the protocol version in environment configuration or a versioned constant file. Implement a negotiation fallback if the client sends a protocolVersion you don't support, returning a compatible version or a clear error.
3. Mixing Sync/Async Handler Returns Without Uniform Awaiting
Explanation: Some tools perform synchronous lookups while others call external APIs. If the dispatcher doesn't await uniformly, synchronous handlers may resolve before async ones, causing race conditions or unhandled promise rejections.
Fix: Always wrap tool execution in await. JavaScript's await on a non-Promise value is a no-op, guaranteeing consistent async flow regardless of handler implementation.
4. Skipping Input Schema Validation
Explanation: The inputSchema field is a contract, not a suggestion. Clients rely on it for auto-completion and validation, but malformed arguments can still reach your handler if you don't validate at runtime.
Fix: Integrate a validation library (Zod, Valibot, or Ajv) at the dispatcher layer. Validate params.arguments against tool.inputSchema before execution. Return -32602 with detailed validation errors if the payload fails.
5. Misusing JSON-RPC Error Codes
Explanation: Developers often return generic 500 HTTP status codes or custom error shapes. MCP clients expect strict JSON-RPC error envelopes with specific numeric codes.
Fix: Map internal errors to the standard JSON-RPC code set. Use -32700 for malformed JSON, -32600 for missing jsonrpc field or invalid structure, -32601 for unknown methods, -32602 for invalid parameters, and -32603 for handler exceptions. Always return 200 OK with the error inside the JSON body unless the HTTP layer itself fails.
6. Overcomplicating Transport Abstractions
Explanation: Building custom SSE streams, session ID generators, or WebSocket bridges when your tools are synchronous adds maintenance debt without functional benefit. Fix: Start with stateless HTTP POST endpoints. Only introduce streaming or session management when a tool explicitly requires long-running execution, partial result emission, or stateful conversation context.
7. Neglecting Idempotency & Request Deduplication
Explanation: AI agents may retry failed requests or send duplicate calls due to timeout handling. Without idempotency keys or deduplication, tools performing mutations (database writes, API calls) may execute multiple times.
Fix: Implement request deduplication using a short-lived cache keyed by id + method + params hash. Return cached responses for duplicate requests within a configurable window. For stateful tools, design handlers to be idempotent by default.
Production Bundle
Action Checklist
- Define JSON-RPC 2.0 types and error constants before writing routing logic
- Implement a pure dispatch function that accepts requests and returns responses without I/O
- Register tools with explicit
inputSchemacontracts and async-safe handlers - Add batch request normalization at the HTTP router layer
- Integrate runtime validation against
inputSchemabefore tool execution - Configure error mapping to standard JSON-RPC codes instead of HTTP status codes
- Add request deduplication or idempotency handling for mutation-heavy tools
- Write unit tests targeting the dispatcher directly, bypassing HTTP mocks
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Synchronous tools, existing Hono/Fastify/Edge stack | Custom Dispatcher | Zero framework adaptation, native middleware composition, minimal bundle | Low (development time) |
| Desktop client integration (Claude Desktop, Cursor) | SDK STDIO Bridge | STDIO transport is SDK-native; subprocess management is complex to implement manually | Medium (requires separate binary) |
| Long-running tools requiring partial results | SDK StreamableHTTP or Custom SSE | Streaming requires connection lifecycle management; SDK provides battle-tested SSE implementation | High (infrastructure + dev time) |
| Enterprise environment with strict schema validation | SDK or Custom + Valibot/Zod | SDK ships Zod schemas; custom approach requires explicit validation layer but offers tighter control | Medium (validation overhead) |
| Multi-tenant SaaS with OAuth/RBAC | Custom Dispatcher | Middleware composition is cleaner without transport abstraction; auth flows naturally with existing stack | Low |
Configuration Template
// mcp.config.ts
import { ToolRegistry } from "./dispatcher.js";
import { getCurrencyTool } from "./tools/currency.js";
import { getEntityTool } from "./tools/entity.js";
export const MCP_CONFIG = {
protocolVersion: "2025-06-18",
serverInfo: {
name: "production-mcp-gateway",
version: "2.1.0",
},
registry: new ToolRegistry([
getCurrencyTool(),
getEntityTool(),
]),
http: {
basePath: "/api/v1/mcp",
maxBatchSize: 10,
requestTimeoutMs: 5000,
},
security: {
requireAuth: true,
rateLimit: { windowMs: 60000, max: 30 },
},
} as const;
Quick Start Guide
- Initialize the dispatcher: Copy the
dispatchMcpRequestfunction and error constants into your project. Ensure TypeScript strict mode is enabled. - Register your tools: Create tool definitions matching the
ToolDefinitioninterface. Implementexecutemethods that return JSON-serializable data. - Mount the route: Add the HTTP POST handler to your existing router. Configure JSON parsing, batch normalization, and response serialization.
- Validate locally: Send a
POSTrequest with{"jsonrpc":"2.0","id":1,"method":"initialize"}. Verify the response containsprotocolVersionandcapabilities. - Test tool execution: Call
tools/callwith a registered tool name and arguments. Confirm the dispatcher returns the expected result or a standard JSON-RPC error envelope.
