krishna@
10 min read#ai#tooling

Writing your own MCP server

The first MCP tool I shipped took a UUID the model never had. The same file at three stages: schema as prompt surface, errors a model can recover from, and testing with no model in the loop.

share
mcp

the tool nobody called

The first MCP tool I shipped was called getUser. One argument, userId, a UUID. It passed every test I wrote, and in three weeks of real use the model called it exactly twice — both times because I'd pasted an id into the chat myself.

Of course it did. The model never has a UUID. It has "can you check what Sabin's on this week", and no route from there to here.

That tool taught me more about MCP than the spec did. What follows is the same file at three stages, and what each rewrite forced me to learn.

what MCP actually is

One honest paragraph: MCP is JSON-RPC with a small method surface — list tools, call a tool, list resources, read a resource, list prompts — carried over stdio or streamable HTTP. That's the idea in full. There's no intelligence in it, no orchestration, no agent runtime. You could have built the same thing on OpenAPI in 2019, and people did. What's valuable is the agreement: because everyone implements the same handful of methods, a tool I write once is callable from a desktop client, from an editor, and from my own agent loop without three adapters and three auth stories. The protocol is deliberately boring, and the boring part is the whole point.

The spec does move. Transports have changed under me twice since I started. Pin the SDK version and read the changelog before you bump it.

Two of those five methods get almost no attention and shouldn't be skipped. Resources are read-only things a client can pull in without a tool call, which is the right shape for anything the model shouldn't have to decide to fetch. Prompts are named, parameterized templates the client can offer a user directly. I ignored both for months and ended up expressing everything as tools, which is how you get eleven tools.

stage one: the smallest thing that answers

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { prisma } from '../db.js';

const server = new McpServer({ name: 'directory', version: '0.1.0' });

server.registerTool(
  'getUser',
  {
    description: 'Get a user by id',
    inputSchema: { userId: z.string().uuid() },
  },
  async ({ userId }) => {
    const user = await prisma.user.findUnique({ where: { id: userId } });
    return { content: [{ type: 'text', text: JSON.stringify(user) }] };
  },
);

await server.connect(new StdioServerTransport());

Thirty seconds of work and it's a real MCP server. Two things this stage taught me, neither in the quickstart.

Everything the model receives is text in a content array. There's no typed return value and no schema on the way out — the model reads whatever string you produce. JSON.stringify(user) up there ships the password hash column, the internal id, the soft-delete timestamp and a createdAt nobody asked for, and the context window pays for every token of it. How you serialize a result is a prompt-design decision wearing a serialization costume.

And on stdio, stdout is the wire. A single console.log anywhere in the process, yours or a dependency's, writes into the JSON-RPC stream and the client drops the connection with a parse error that points at nothing useful. Everything logs to stderr. This is one of the few times I've been actively grateful for a lint rule that bans console outright.

stage two: the schema is the prompt

Second version of the file. Same server, different tool.

export const findPeopleInput = {
  query: z
    .string()
    .min(1)
    .describe(
      'Free text: a full or partial name, an email fragment, or a description like "billing lead". Prefer the user\'s own words.',
    ),
  team: z
    .string()
    .optional()
    .describe('Team slug, e.g. "platform". Only set this if the user named a team.'),
  includeInactive: z
    .boolean()
    .default(false)
    .describe('Include people who have left. Default false.'),
  limit: z.number().int().min(1).max(25).default(5),
};

server.registerTool(
  'find_people',
  {
    title: 'Find people',
    description:
      'Search the staff directory by name, email, team or role. Use this whenever you need a person and only have a partial or ambiguous reference. Returns matches ordered by confidence, each with an id you can pass to the other directory tools. Returns an empty list rather than an error when nothing matches.',
    inputSchema: findPeopleInput,
    annotations: { readOnlyHint: true, idempotentHint: true },
  },
  findPeopleHandler,
);

Three lessons, in order of what they cost me.

The description is prompt surface, not documentation. It goes into the model's context verbatim, on every turn, for the life of the session. "Get a user by id" tells the model what the function does and nothing at all about when to reach for it. The rewrite spends most of its words on when, and one clause on what comes back, because the model's real decision is which tool to pick — not what the tool technically is.

Fewer, wider tools. We started with eleven, shaped like the REST API underneath: getUser, listUsers, searchUsers, getUserByEmail, getTeam, listTeamMembers, and so on down. That's a menu, and a model handed eleven near-synonyms picks wrong a lot. We collapsed it to four, organized around what a person is trying to do rather than what the database can do. On a fixture set of 120 natural-language requests the wrong-tool rate went from 23% to 6%, and the serialized tool list dropped from roughly 2,800 tokens to 900 — tokens you were paying on every request, not once.

The rule I use now: one tool per user intent, not one per endpoint. If two tools would have descriptions differing only by a filter, they're one tool with an optional argument.

There's a limit to this, and I've hit it from the other side. Widening too far produces one directory tool with an action field, which is an RPC envelope wearing a tool costume: the model now has to get two decisions right in one shot, and the argument schema becomes a union that no description can explain clearly. Four tools was where the fixture numbers stopped improving. I don't have a principle for where the floor is, only the measurement.

Third and smallest: name things the model has already seen a thousand times. find_people beats resolveActorRef and it isn't close. Internal vocabulary is a private joke the model isn't in on.

stage three: errors it can recover from

Third version, and the handler is now its own exported function rather than a closure. I'll get to why.

import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { DirectoryUnavailableError } from './errors.js';
import { logger } from './logger.js';

const toolText = (text: string): CallToolResult => ({ content: [{ type: 'text', text }] });

const toolError = (text: string): CallToolResult => ({
  isError: true,
  content: [{ type: 'text', text }],
});

export async function findPeopleHandler(args: unknown): Promise<CallToolResult> {
  const parsed = z.object(findPeopleInput).safeParse(args);

  if (!parsed.success) {
    const detail = parsed.error.issues
      .map((i) => `${i.path.join('.') || 'input'}: ${i.message}`)
      .join('; ');
    return toolError(`Invalid arguments — ${detail}. Correct them and call find_people again.`);
  }

  const { query, team, includeInactive, limit } = parsed.data;

  try {
    const people = await directory.search({ query, team, includeInactive, limit });

    if (people.length === 0) {
      return toolText(
        `No people matched ${JSON.stringify(query)}${team ? ` in team "${team}"` : ''}. ` +
          'Try a shorter query, drop the team filter, or set includeInactive: true if they may have left.',
      );
    }

    return toolText(renderPeople(people));
  } catch (err) {
    logger.error({ err, query, team }, 'find_people failed');

    if (err instanceof DirectoryUnavailableError) {
      return toolError(
        'The directory is unreachable right now. Do not retry this tool — tell the user you could not look that person up.',
      );
    }

    throw err;
  }
}

An error message is prompt surface too. Error: NOT_FOUND leaves the model one move, which is to apologize. The empty-result message above hands it three things to try, and in fixture runs it takes one of them about two-thirds of the time instead of giving up.

Notice what throws and what doesn't. A business outcome — nothing matched, bad arguments, downstream service down — comes back as a tool result the model can read and reason about. A genuine defect gets rethrown and becomes a protocol error, which is my problem and the client's, not the model's. Get this backwards and every transient failure looks to the model like the end of the conversation.

I validate inside the handler even though the SDK validates against the declared schema. Clients enforce things inconsistently, and I'd rather return a readable message than trust every caller in the world to behave.

The annotations deserve a sentence. readOnlyHint, destructiveHint and idempotentHint are advisory; the protocol enforces none of them. My own agent runtime treats destructiveHint: true as a hard stop that ends the step and requires an explicit confirmation before anything runs. That's my policy, implemented in my loop, and it holds because I wrote both ends. Don't assume a client you didn't write does anything with those fields at all.

testing a tool with no model in the loop

The part I care about most and see least often.

The handler is a plain async function, which is exactly why stage three pulled it out of the registerTool call. Most of its behaviour tests like any other unit — arguments in, CallToolResult out, assert on isError and the text.

Unit-testing the handler skips the protocol though, and the protocol is where shape bugs live. The SDK ships an in-memory transport for this:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it } from 'vitest';
import { buildServer } from '../src/server.js';

describe('find_people over the protocol', () => {
  it('rejects a limit above the maximum', async () => {
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    const client = new Client({ name: 'test', version: '0.0.0' });

    await Promise.all([buildServer().connect(serverTransport), client.connect(clientTransport)]);

    const result = await client.callTool({
      name: 'find_people',
      arguments: { query: 'sabin', limit: 500 },
    });

    expect(result.isError).toBe(true);
    expect(String(result.content[0].text)).toContain('limit');
  });
});

Real client, real serialization, no stdio, no model, single-digit milliseconds under Vitest. That catches the whole class of "works in my handler, explodes over the wire" bugs, and it's the layer people skip.

The third layer is the fixture set I keep mentioning: 120 natural-language requests, each labelled with the tool that should be called and the arguments that should come out of it. It runs nightly against the cheapest model I have wired up, because I'm measuring the tool descriptions, not the model. That's how I learned that a two-word edit to a description had stopped the model reaching for a tool entirely. Nothing in CI would ever have told me — nothing was broken.

It's a blunt instrument. It scores tool choice and argument shape, and it says nothing about whether the answer was any good. Every attempt I've made to grade the final answer automatically has produced a number I don't trust enough to gate a deploy on. So the fixture set is a smoke alarm, not a quality bar, and I'm fine with that: the failures it does catch are the ones that would otherwise sit in production for weeks looking like the model having an off day.

what I'd do differently

Write the fixture set before the tools. I built eleven, watched them get picked wrong, then built the measurement to work out why. The right order is embarrassingly obvious in retrospect: list twenty things a person would actually ask for, then design the smallest set of tools that covers all twenty.

things the docs don't tell you

  • stdout belongs to the transport. Nothing else may write to it, including packages you didn't audit.
  • Tool descriptions and schemas sit in the context window on every turn. A long description is a recurring bill.
  • Changing the tool list invalidates prompt caches upstream. Churning descriptions costs more than the tokens they add.
  • Returning 8KB of JSON from one call is a way to spend your whole context in a single step. Paginate, and say in the response that you did.
  • Clients enforce your input schema inconsistently. Validate again in the handler.
  • Tool names collide. Two servers on one client both exposing search is a real situation with no clean answer in the protocol.
  • isError: true is a normal, expected outcome, not a last resort. Throwing is for defects.
  • Annotations are hints. Whatever safety property you actually need, enforce it in your own runtime.

by Krishna Adhikari · Jun 11, 2026
share
// related.transmissions

Keep reading.