MCP Model Context Protocol in TypeScript: build portable tools across Claude, GPT, and local models

typescript dev.to

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;
}>;
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"],
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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:


This article was originally published on juanchi.dev

Source: dev.to

arrow_back Back to Tutorials