MCP Model Context Protocol in TypeScript: build portable tools across Claude, GPT, and local models
Most MCP tutorials start with npm install @anthropic-ai/sdk and by the third code block they already have business logic coupled to the Anthropic client. You read that right: they teach you the portability protocol using code that isn't portable. And that completely changes how you end up designing your tools when you need to move them.
My thesis is simple and I'll defend it from the design level: the central mistake when implementing MCP tools isn't syntactic or a configuration issue — it's coupling. You put logic inside the SDK handler, and what should be a universal contract becomes code that only works with one provider. The official MCP Specification describes a model-agnostic protocol. Almost nobody designs it that way from day one.
What the MCP spec says — and what it deliberately doesn't
Before any code, it's worth reading the spec for what it actually is: a communication contract, not an implementation framework.
MCP defines three fundamental primitives (per the official documentation):
- Tools: functions the model can invoke with structured parameters
- Resources: data the server exposes for the model to read
- Prompts: reusable templates with arguments
What the spec does not define is how you implement the internal logic of a tool. It doesn't say you have to use the Anthropic SDK. It doesn't say the handler needs to know which model called it. It doesn't say the response has to have a proprietary format.
A tool in MCP has this logical shape:
// Minimum contract defined by the spec — provider-agnostic
interface MCPTool {
name: string; // unique tool identifier
description: string; // what it does, so the model knows when to use it
inputSchema: { // strict JSON Schema for the input
type: "object";
properties: Record<string, unknown>;
required: string[];
};
}
// The handler receives validated input and returns structured content
type ToolHandler = (input: Record<string, unknown>) => Promise<{
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}>;That's everything the protocol guarantees. content is a typed array, isError is optional. If you design within those boundaries, the tool is portable.
The input/output contract that makes or breaks portability
Here's where the real friction lives. When a developer starts with the official Anthropic SDK example (@anthropic-ai/sdk), the sample code usually looks like this:
// ❌ Coupled pattern: logic lives inside the SDK flow
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// Tool defined inline, handler knows about the client
const tools: Anthropic.Tool[] = [
{
name: "get_weather",
description: "Gets the current weather for a city",
input_schema: {
type: "object" as const,
properties: {
city: { type: "string", description: "Name of the city" },
},
required: ["city"],
},
},
];
// Processing is mixed with the provider's message loop
async function processResponse(response: Anthropic.Message) {
if (response.stop_reason === "tool_use") {
const toolUse = response.content.find(
(block) => block.type === "tool_use"
) as Anthropic.ToolUseBlock;
// ⚠️ This is where the problem starts: business logic inside the Anthropic handler
if (toolUse.name === "get_weather") {
const city = (toolUse.input as { city: string }).city;
// fetch, logic, transformation... all tangled with the SDK
}
}
}See where it breaks? The Anthropic.ToolUseBlock type, the stop_reason field, the input_schema field with snake_case — all of that is Anthropic's dialect. If tomorrow you want to use OpenRouter with a local model, you have to rewrite the entire handler because the contract got buried inside provider types.
The portable pattern separates three layers:
// ✅ Portable pattern: three layers with distinct responsibilities
// --- Layer 1: Schema definition (provider-independent) ---
import { z } from "zod"; // Zod for runtime validation
const weatherInputSchema = z.object({
city: z.string().min(1).describe("Name of the city"),
unit: z.enum(["celsius", "fahrenheit"]).default("celsius"),
});
type WeatherInput = z.infer<typeof weatherInputSchema>;
// --- Layer 2: Pure handler (knows nothing about SDKs) ---
async function getWeatherHandler(rawInput: unknown): Promise<{
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}> {
// Validate input with Zod before using it
const parsed = weatherInputSchema.safeParse(rawInput);
if (!parsed.success) {
return {
content: [{ type: "text", text: `Invalid input: ${parsed.error.message}` }],
isError: true,
};
}
const { city, unit } = parsed.data;
// Business logic — has no idea which model called it
const result = await fetchExternalWeather(city, unit);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
}
// --- Layer 3: Per-provider adapters (finite and thin) ---
// The adapter translates between the SDK's dialect and the pure handler
function toAnthropicTool(): Anthropic.Tool {
return {
name: "get_weather",
description: "Gets the current weather for a city",
input_schema: {
type: "object" as const,
properties: {
city: { type: "string" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["city"],
},
};
}
// For a provider compatible with the OpenAI spec (OpenRouter, GPT, etc.)
function toOpenAITool(): { type: "function"; function: object } {
return {
type: "function",
function: {
name: "get_weather",
description: "Gets the current weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["city"],
},
},
};
}The pure handler is identical in both cases. Only the adapters change. That's real portability.
The three gotchas nobody mentions in tutorials
1. input_schema vs parameters: they are not interchangeable
Anthropic uses input_schema with snake_case. The OpenAI spec (and compatible providers like OpenRouter) uses parameters. There's no auto-conversion. If you don't have an adapter layer, the first provider switch blows up at runtime without a clear error — the model just doesn't find the tool or calls it wrong.
2. isError: true does not stop agent execution
This one is subtle. When you return isError: true in a tool response, the MCP spec says that does not interrupt the agent's flow — it signals to the model that an error occurred in the tool, but the model can keep reasoning. That means your handler has to return an error message that's readable for the model, not just for you. A raw stacktrace is useless; something like "City 'Baires' not found. Check the exact name." actually helps.
3. Zod at runtime vs JSON Schema in the definition
Zod is great for runtime validation inside the handler. But the inputSchema you register on the MCP server has to be plain JSON Schema — you can't pass a ZodSchema directly. Libraries like zod-to-json-schema handle the conversion, but the extra dependency has a cost. On small projects, keeping both in sync manually is sometimes simpler. On larger projects, automating the conversion is worth it.
// Conversion with zod-to-json-schema (if you want to automate it)
import { zodToJsonSchema } from "zod-to-json-schema";
const jsonSchema = zodToJsonSchema(weatherInputSchema, {
$refStrategy: "none", // avoid $ref in MCP schemas — some clients don't resolve them
});Design checklist: before you write the handler
Before touching any provider's SDK, run through this:
| Question | Green signal | Red signal |
|---|---|---|
Does the handler receive unknown and validate internally? | Yes, with Zod or own schema | No, receives SDK types directly |
Does the handler return pure { content, isError? }? | Yes | No, returns provider types |
| Does the tool definition have a per-provider adapter? | Yes, separate layer | No, hardcoded to the SDK |
| Is the error message readable for the model? | Yes, descriptive text | No, stacktrace or raw code |
Does the schema use $ref? | No, inlined | Yes — verify client compatibility |
| Does business logic import anything from the SDK? | No | Yes — coupling |
All green means the tool survives a provider switch without touching the handler. Red signals mean the migration cost lands on business logic, which is where it hurts.
What this guide can't conclude for you
Clear limits here, because I don't want to sell certainty I don't have:
-
Portability performance: I don't have my own benchmarks comparing adapter layer latency vs direct handler. It's a thin type-translation layer — in practice it should be negligible, but I won't state that as fact without production measurements of my own.
-
Local model behavior (Ollama, LM Studio): Tool calling compatibility in local models varies a lot by model and version. Some interpret the schema correctly, others ignore fields. That's not a tool design problem — it's a model limitation. This architecture gives you the right structure; it doesn't guarantee the model on the other end uses it well.
-
MCP over HTTP vs stdio: The spec supports both transports. The examples here are transport-agnostic, but there are differences in how server lifecycle is handled. With
stdio, the process is ephemeral. With HTTP, the server is persistent. That affects tool state design — something that deserves its own post.
FAQ
Is MCP only for Claude or does it work with any model?
The MCP protocol is model-agnostic. Any client that implements the protocol can use it — Claude, GPT-4o via OpenRouter, local models through compatible clients. What varies is each model's tool calling quality, not the protocol itself. The official spec doesn't mention any specific model in its primitive definitions.
Do I need @anthropic-ai/sdk to implement MCP tools?
Not necessarily. You need the Anthropic SDK if your client (the one calling the model) is Claude. But the MCP server — where the tools live — can be implemented with any compatible library or even from scratch if you follow the transport protocol. The official SDK has MCP helpers, but they're optional on the server side.
Is Zod required or just a preference?
Strong preference, not a spec requirement. MCP defines the schema as JSON Schema. Zod is useful because you get runtime validation + TypeScript type inference from the same definition. You can use ajv, manual validation, or any other library. What is important — and this is structural — is validating rawInput inside the handler before using it, regardless of how.
How do I handle authentication in an MCP tool?
The spec doesn't define authentication within the tool contract. If the tool needs credentials (an API key, a token), those have to come through server initialization context, not through the tool's input parameters. Passing secrets as input exposes that information to the model and potentially to the conversation log.
Can I have state between tool calls in the same conversation?
Depends on the transport. With stdio (one process per conversation), you can keep state in process memory. With HTTP (persistent server), you need to correlate by session explicitly. By default, design tools as pure stateless functions — it's the safest and most portable pattern.
Does this architecture scale to dozens of tools?
The three-layer pattern scales well because each tool is an independent module: schema + handler + adapters. What doesn't scale without discipline is registration: if you have 30 tools and each adapter is duplicated, maintenance gets messy fast. A common solution is a central registry that maps toolName → handler and generates per-provider adapters automatically from the schema.
The spec gives you the contract — you decide whether to respect it
I've designed MCP tools in projects with Claude and OpenRouter. What I learned is that portability isn't an automatic benefit of the protocol — it's a design decision you either make or don't make in the first few hours.
The MCP spec gives you the contract: name, description, input schema, response content. If you embed business logic inside SDK types, you break that contract and nobody warns you. The error is silent — the tool works perfectly with one provider and fails or requires a full rewrite with another.
My position: three layers, always. Schema with Zod, pure handler, thin adapter per provider. The upfront cost is a bit more structure. The return is not having to rewrite handlers when you switch models or providers — something that in an ecosystem moving as fast as agents, will happen more often than you think.
If you're coming from reading the post about system prompts for agents in production, this is the natural next step: once the agent knows what to do, the tools need to be designed so they don't couple to whoever's executing them.
And if you're starting to think about rate limiting for these tools exposed as endpoints, this analysis of what to protect first has criteria that apply directly.
Original sources:
- Anthropic MCP Specification: https://modelcontextprotocol.io/introduction
- Anthropic Claude SDK (npm): https://www.npmjs.com/package/@anthropic-ai/sdk
Related Articles
Web Crypto API in the browser vs Node.js: the differences that will burn you
Web Crypto API looks like one thing — until you try to reuse the same encryption code across browser, Node.js, and Next.js edge runtime. The differences are subtle, they're documented, and almost nobody reads the docs until something blows up.
React 19 Server Components and caching: the mental model I was missing after reading the docs
This isn't another RSC tutorial. It's the conceptual map I built after reading the official docs and understanding why the folklore around 'always use use client' is wrong — and what actually happens when you put Server Components in a real layout with dynamic data.
Cline in VS Code: I used it two weeks on a TypeScript project and this survived
Two weeks using Cline as an autonomous coding agent on a TypeScript project. What tasks I delegated, where it screwed up, how it compares to Claude Code, and which workflows I'd never hand over to it. Architecture-grade analysis, not hype.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.