Skip to content

Engineering

How to build an MCP server that a model actually uses correctly

Most MCP tutorials in circulation use an API that is now deprecated, and almost all of them skip the part that decides whether your server is any good: how you describe your tools.

Taegan Murphy9 min read

The short version

  • The SDK deprecated server.tool(), server.resource() and server.prompt(). Current code uses registerTool, registerResource and registerPrompt. Most tutorials you will find have not caught up.
  • A tool description is not documentation, it is the prompt the model reads when deciding whether to call it. Say what it does, when to reach for it, and what comes back.
  • Annotate tools with readOnlyHint and idempotentHint so the host can auto-approve reads and ask before writes.
  • On the stdio transport, stdout is the protocol stream. One stray console.log corrupts it. Log to stderr.
  • Test by connecting a real client to a real server over an in-memory transport pair. It exercises the protocol without spawning a process.

Every code sample here is taken from a working repository, verified against @modelcontextprotocol/sdk@1.30.0 on Node 20, with an integration test that passes. If you would rather read the finished thing, it is at ImTaegan/mcp-server-starter.

Start with the thing that will waste your afternoon otherwise. The SDK deprecated server.tool(), server.resource() and server.prompt() in favour of registerTool, registerResource and registerPrompt. Most tutorials, most blog posts, and a good deal of what an assistant will confidently tell you still use the old ones. They work today and they emit deprecation warnings.

What MCP actually is

The Model Context Protocol is a JSON-RPC contract that lets a model host discover and use capabilities you expose, without anyone writing integration code for your specific service.

That is the whole idea. You could expose the same functionality as a REST API and nothing about the transport would be meaningfully different. The difference is who does the wiring. A REST API needs a developer to integrate it into each client. An MCP server is added by configuration, and the host then knows how to list your tools, read their schemas, decide when to call them, and surface your prompts as slash commands.

There are three primitives, and picking the right one matters:

PrimitiveWho decides to use itUse it for
ToolThe modelActions and lookups
ResourceThe user or clientContext to attach
PromptThe userTemplates, usually slash commands

If the model should decide, it is a tool. If a human should decide, it is a resource or a prompt. Most servers reach for tools by reflex and end up with a model that has to guess its way to information the user could simply have attached.

A minimal server

Two dependencies, and the SDK brings its own validation:

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Keep the server construction separate from the transport wiring. It costs nothing now and it is what makes the server testable later:

// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function createServer(): McpServer {
  const server = new McpServer({ name: "notes", version: "1.0.0" });

  server.registerTool(
    "search_notes",
    {
      title: "Search notes",
      description: "...", // covered properly below
      inputSchema: {
        query: z.string().min(1).describe("Keywords to match."),
        limit: z.number().int().min(1).max(50).default(10),
      },
      annotations: { readOnlyHint: true, idempotentHint: true },
    },
    async ({ query, limit }) => ({
      content: [{ type: "text", text: `searched for ${query}, max ${limit}` }],
    }),
  );

  return server;
}

Note that inputSchema takes a plain object of Zod validators rather than a Zod object. The SDK converts it to JSON Schema for the wire and hands your callback typed arguments.

The transport wiring is then genuinely tiny:

// src/index.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

const server = createServer();
await server.connect(new StdioServerTransport());
console.error("notes MCP server ready on stdio");

That console.error is not a typo. On the stdio transport, stdout is the protocol stream. A single console.log anywhere in your server, or in a dependency, writes garbage into the JSON-RPC channel and the client disconnects with an error that names none of this. Log to stderr, always.

Designing tools a model calls correctly

This is the part that decides whether your server is good, and it is the part almost every tutorial skips.

A tool description is not documentation. It is the prompt the model reads when deciding whether to call this tool instead of another one, or instead of answering from memory. Judge it as a prompt, not as a docstring.

// Accurate. Useless.
description: "Searches notes."

// What the model needs
description:
  "Search the user's saved notes by keyword, optionally filtered to a " +
  "single tag. Use this before answering any question about what the user " +
  "has written down, and before adding a note, to avoid creating a " +
  "duplicate. Returns matching notes ordered by relevance, most relevant " +
  "first."

The second one answers three questions the first does not:

  • What does it do, specifically enough to distinguish it from neighbouring tools
  • When should it be reached for, including its relationship to other tools. “Before adding a note” is doing real work there.
  • What comes back, so the model knows whether one call is enough

Parameters need the same treatment. Every field gets a .describe(), because the model sees those too:

inputSchema: {
  query: z.string().min(1)
    .describe("Keywords to match against note titles, bodies, and tags."),
  tag: z.string().optional()
    .describe("Restrict results to notes carrying this exact tag."),
  limit: z.number().int().min(1).max(50).default(10)
    .describe("Maximum number of notes to return."),
}

Three practices that fix most “the agent called the wrong tool” complaints, in order of effect:

  1. Have fewer tools. Twelve overlapping tools perform worse than five clear ones. Every tool you add makes every other tool harder to choose correctly.
  2. Disambiguate in the description itself. If two tools could plausibly apply, each should say when to prefer the other.
  3. Return text a model can act on. Returning raw JSON is a habit from API design. Returning readable text with the ids still in it usually produces better behaviour.

Also worth setting: annotations. The readOnlyHint and idempotentHint flags tell the host how risky a call is, which is what lets a client auto-approve reads and stop to confirm writes. Leave them off and everything looks equally dangerous, so the user gets prompted constantly and starts approving without reading.

Resources and prompts, and when they beat tools

A resource is context the user attaches. There is no decision for the model to get wrong, which makes it both cheaper and more predictable than a tool for anything the user already knows they want:

server.registerResource(
  "all-notes",
  "notes://all",
  {
    title: "All notes",
    description: "Every saved note, newest last, as markdown.",
    mimeType: "text/markdown",
  },
  async (uri) => ({
    contents: [{ uri: uri.href, mimeType: "text/markdown", text: await renderAll() }],
  }),
);

A prompt is a template the user invokes, surfaced as a slash command in most clients. Prompts are not instructions the model picks up on its own, which is the most common misunderstanding about them:

server.registerPrompt(
  "summarise-notes",
  {
    title: "Summarise my notes",
    description: "Produce a short digest of notes matching a tag.",
    argsSchema: { tag: z.string().describe("The tag to summarise.") },
  },
  ({ tag }) => ({
    messages: [{
      role: "user",
      content: { type: "text", text: `Search my notes for "${tag}" and digest them.` },
    }],
  }),
);

Safety, before you connect it to anything

An MCP server is code a language model can invoke. Treat every tool argument as untrusted input, because it is: the model produced it, and the model was influenced by whatever it read.

  • Validate at the boundary and mean it. Your Zod schema is the security boundary, not a convenience. Constrain enums, cap array lengths, bound numbers.
  • Never interpolate arguments into a shell or a query. Same discipline as any user input, because it is less trustworthy than user input.
  • Scope credentials to the server, not the user's whole account. If it only needs read access to one thing, give it only that.
  • Be careful what you return.Tool output lands in the model's context, and anything in it can influence later behaviour. Returning raw third-party content is how prompt injection gets in.

Testing it properly

Calling your handler functions directly tests your functions. It does not test whether the server advertises them correctly, whether the schemas survive conversion, or whether the client can actually reach them. Connect a real client to a real server over an in-memory transport pair instead:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

const server = createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test", version: "1.0.0" });

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

const { tools } = await client.listTools();
const res = await client.callTool({
  name: "search_notes",
  arguments: { query: "sitemap" },
});

No process spawning, no fixtures, and it runs in milliseconds. The assertion worth writing first is not that a tool returns the right answer, it is that every tool has a description long enough to be useful:

for (const tool of tools) {
  assert.ok(
    (tool.description ?? "").length > 40,
    `${tool.name} needs a description the model can act on`,
  );
}

That test has caught more real problems for me than any assertion about return values, because a tool with a weak description fails silently: everything works, the model just never calls it.

Shipping it

Build, then point a client at the compiled entry point. For Claude Desktop, in claude_desktop_config.json:

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"],
      "env": { "NOTES_FILE": "/absolute/path/to/notes.json" }
    }
  }
}

For Claude Code it is one command:

claude mcp add notes -- node /absolute/path/to/dist/index.js

Paths must be absolute, since the host does not run your process from your project directory. If the server fails to appear, check stderr first: it is almost always an unresolved path or something writing to stdout.

Stdio is right for anything touching a developer's own machine. For a hosted server, the SDK ships a streamable HTTP transport, at which point authentication and multi-tenancy stop being optional and become the bulk of the work.

The complete server, with the tests, is at ImTaegan/mcp-server-starter. Clone it, strip the notes logic, keep the shape.

FAQ

Common questions

What can an MCP server do that a REST API cannot?

Nothing, at the transport level. The difference is that MCP is a contract a model host already knows how to consume: it can discover your tools, read their schemas, decide when to call them, and surface your prompts as slash commands, without anyone writing integration code. A REST API needs a developer to wire it into each client. An MCP server is wired in by configuration.

Do I have to use TypeScript?

No. There are official SDKs for Python, Java, Kotlin, C# and others, and the protocol is JSON-RPC so you can implement it directly. TypeScript is the most complete SDK today and the one most examples use.

How do I stop the model calling the wrong tool?

Write better descriptions. This is almost never a model problem. Say what the tool does, when it should be reached for relative to the others, and what it returns. Give every parameter its own description. If two tools overlap, say in each description when to prefer the other one. Reducing the number of tools also helps more than people expect.

Can an MCP server run remotely instead of locally?

Yes. Stdio is the local transport where the host spawns your process, and it is the right default for anything touching a developer's own machine. For a hosted server the SDK provides a streamable HTTP transport, which adds authentication and multi-tenancy as real concerns rather than optional ones.

What is the difference between tools, resources and prompts?

Tools are actions the model decides to take. Resources are context the user or client attaches, which the model reads without choosing to. Prompts are templates the user invokes, usually as slash commands. If the model should decide, it is a tool. If the human should decide, it is a resource or a prompt.

How do I debug an MCP server?

Log to stderr, never stdout, because stdout is the protocol stream on stdio. Beyond that, an integration test over an in-memory transport catches more than manual poking at a client does, because you can assert on what the server actually advertises rather than squinting at a UI.

Photo of Taegan Murphy

Written by Taegan Murphy

Web developer. I build production apps in Next.js, React, and TypeScript, and ship real AI features on top of them. I wrote this because I am usually on the other side of the conversation it describes, and the questions clients wish they had asked come up often enough to be worth writing down.

Get in touch

Tell me what you are building

Two sentences is plenty. I read every one of these myself and reply within a day.

Rather book a call?
Scope it out together

I usually reply within a day.