Hosts

MCP

createMcpServer builds an MCP server on SDK v2 for any list of tools. With the error handling the SDK's own McpServer gets wrong
IDmcp01 / 04@modelcontextprotocol/server
Host / MCP

MCP server

An MCP server on SDK v2, one list and one call handler for every tool.

title
Text Slug
hints
readOnly, idempotent
inputSchema
text, separator
failure
isError: true

Access

Importimport { createMcpServer } from "@agntn/tools/mcp"
Peer@modelcontextprotocol/server >=2.2.0 <3
FailsisError: true
tsserver.ts
import { createMcpServer } from "@agntn/tools/mcp";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { tools } from "./tools.ts";

const server = createMcpServer(
  {
    name: "mypackage",
    version: "1.0.0",
    title: "My Package",
    description: "Turns titles into slugs.",
    icons: [{ src: "https://example.com/mypackage/icon.svg", mimeType: "image/svg+xml", sizes: ["any"] }],
    websiteUrl: "https://example.com/mypackage",
  },
  tools,
);
await server.connect(new StdioServerTransport());

createMcpServer(info, tools) gives you an unconnected server. One tools/list handler, one tools/call handler, every tool in the list. Connect it to stdio, to Streamable HTTP or to a pair in memory for tests. This site does the last one, in your tab.

info needs a name and a version, that's it. Want a nicer label? title is the name a client can put on screen and websiteUrl is where it can link. A connector card wants more than a name, though. description gives it a line and icons give it a face. It all goes out exactly as you wrote it. name is still the word in the unknown tool error, so the pretty one never ends up there.

What the client sees

The schema goes out exactly as you wrote it, additionalProperties: false and pattern included. A tool's title goes out twice, beside the name and in the annotations. Why twice? Claude Code reads only the second one, so without it your nice Decode WIF shows up as keys_wif_decode. The rest of the annotations come from effect:

  • readOnlyHint when the effect is read
  • destructiveHint when it's destructive
  • idempotentHint from idempotent, which defaults to reads
  • openWorldHint from openWorld, off unless you say so

Gave a tool its own icons? They ride along in tools/list. No icons, no icons key. Pi, OMP and the AI SDK draw no pictures, so they never see them.

By default details stays home and structuredContent is never set. Clients that see structured output tend to prefer it over the text and hide the readable answer, so the text is the answer. Everything the next call needs goes in it.

When a program reads the answer too

Sometimes the reader isn't a model. ChatGPT's mention search answers with structuredContent. So does its settings page, and there the read tool must declare an outputSchema. Give the tool an output schema. tools/list lists it as outputSchema. Every successful call sends details as structuredContent, right next to the text. A returned isError still goes out as text alone.

The details pass that schema before they leave. Renamed a field and forgot the schema? The client gets <tool> returned details that don't match its output schema, one line per problem. Not data that lies about its own shape. Better you hear it than ChatGPT.

meta goes out as the tool's _meta, as written. That's where ui and openai/extensions live. Pi, OMP and the AI SDK never see it.

Why not McpServer

SDK v2's McpServer.registerTool would take the schema. It also answers a call to toString, constructor or __proto__ with "Tool toString disabled", because its lookup reaches Object.prototype. It echoes a tool name with a newline and an escape code straight back to the client. It glues validation problems into one line and passes a thrown message through with its newline intact.

So the adapter sits on the low-level Server, which the SDK marks @deprecated, and does those parts itself. An unknown name, toString included, gets Unknown <server> tool: "<name>" with the name escaped. Validation gets one line per problem. A throw becomes <tool> failed: <message> on one line.

Progress

Ask for progress, get progress. When a tools/call carries _meta.progressToken, each progress line from the tool goes out as notifications/progress, with total when the tool knows it. No token, no callback. The tool sees progress as undefined and doesn't waste a breath on it.

A notification that fails to send lands in server.onerror. The answer still arrives, and that's the part the model actually needs.

Questions

A client that declares elicitation gets ask. Any other client gets undefined there. A tool that calls it with ?. just carries on.

Under the hood it's the 2026 way. The call answers input_required with one elicitation/create inside. The answers so far ride along in requestState. On a 2025 connection the SDK plays the client's part. It sends a real elicitation/create and runs the handler again. Same code, both revisions. Each answer is checked against its form. One that doesn't fit gets asked again, a tampered requestState too. Same for an answer whose question changed in between. A yes to "Apply to A?" never turns into a yes to B.

Why no signature on that state? Whoever can forge it can forge the reply next to it. Nothing to protect.

Claude Code declares elicitation, so it gets the form. In claude -p there's nobody to fill it, and the tool reads cancel. An Elicitation hook can answer for the person there.

Errors

A bad input, a returned isError and a thrown error all come back as isError: true with text. Nothing escapes as a JSON-RPC error, so the model always gets something to read and a reason to try again.

No Server in sight

Some transports don't want a Server at all. They bring their own JSON-RPC and ask for a listing and a handler, that's it. So the two halves come out on their own:

tsanswers.ts
import { callTool, listTools } from "@agntn/tools/mcp";
import { tools } from "./tools.ts";

const listing = listTools(tools);
const answer = await callTool({ name: "mypackage" }, tools, "mypackage_lookup", { word: "marmot" });

listTools(tools) is what tools/list answers. callTool(info, tools, name, args, context) is what tools/call answers, unknown names and bad input included. It never throws. Want the abort signal or progress to reach the executor? Pass { signal, progress } last.

createMcpServer is built on these two. Your stdio server and your docs page can't drift apart anymore, because there's nothing left to copy by hand.

On H3

h3-mcp speaks MCP over HTTP on its own, no SDK involved. It wants a list of its tool definitions, and toH3Tools hands it one:

tsserver.ts
import { H3, serve } from "h3";
import { defineMcpHandler } from "h3-mcp";
import { toH3Tools } from "@agntn/tools/h3";
import { tools } from "./tools.ts";

const app = new H3().all(
  "/mcp",
  defineMcpHandler({ name: "mypackage", version: "1.0.0", tools: toH3Tools({ name: "mypackage" }, tools) }),
);
serve(app);

Each entry is what listTools lists, and its handler is callTool. So the text, the validation lines and the cleaned up errors match the stdio server word for word. The request's abort signal reaches execute, and progress turns into notifications/progress when the client sent a token.

The schema goes to h3-mcp as plain JSON Schema. h3-mcp doesn't validate that, it just passes the arguments on, and the core checks them like on every other host.

Questions work on the 2026 revision only. h3-mcp has no 2025 fallback for input_required, so an older request gets ask as undefined.

One difference. An unknown tool name never reaches the adapter. h3-mcp answers it itself, with a JSON-RPC Tool not found error instead of the isError text.

@agntn/tools/h3 imports only types from h3-mcp. Install h3-mcp and h3, and the MCP SDK stays out of your node_modules.

On a Docus /mcp route

Docus serves MCP through @nuxtjs/mcp-toolkit. It wants its own tool definitions, and toToolkitTools builds them:

tsserver/mcp/index.ts
import { toToolkitTools } from "@agntn/tools/toolkit";
import { tools } from "mypackage/tools";

export default defineMcpHandler({
  name: "mypackage",
  version: "1.0.0",
  tools: toToolkitTools({ name: "mypackage" }, tools),
});

Same deal as on H3. Each entry lists what listTools lists and answers through callTool, so the text and the error lines match stdio word for word. The abort signal reaches execute. output and meta ride along too, which hand copies of this route never bothered with.

Now the ugly part. The toolkit hands every schema to SDK v1's McpServer, and that one takes Zod or nothing. So each schema turns into a z.looseObject({}) that lists your JSON Schema as written and waves any object through. The core does the real checking, like everywhere else. Zod has no public hook for this, so the adapter pokes at _zod. Still ugly? Sure. But now it's ugly in one place, and the tests go red first when Zod moves things. Install zod next to the toolkit, it's a peer.

A few things the toolkit does its own way:

  • SDK v1 adds $schema to every listed schema and execution: { taskSupport: "forbidden" } to every tool. Harmless, just don't be surprised.
  • Tool icons stay home. The toolkit never passes them to the SDK. Server icons go in defineMcpHandler as usual.
  • Progress needs sessions. The stateless default answers in plain JSON, so a notification has nowhere to ride. Set mcp: { sessions: { enabled: true } } in nuxt.config.ts and progress shows up like on stdio.
  • An unknown name never reaches the adapter. SDK v1 answers it, and yes, it answers toString with "Tool toString disabled". The bug from Why not McpServer, one major version older.

Can it ask the person?

It can. Hand it the toolkit's own useMcpElicitation:

tsserver/mcp/index.ts
export default defineMcpHandler({
  name: "mypackage",
  version: "1.0.0",
  tools: toToolkitTools({ name: "mypackage" }, tools, { elicitation: useMcpElicitation }),
});

SDK v1 has never heard of input_required. So the question goes out as a real elicitation/create on the call's own stream, and the call just waits for it. One run per call, like in Pi. Only supports("form") comes from the composable. The form is ours, built and checked the same way as on stdio.

It needs two switches in nuxt.config.ts:

tsnuxt.config.ts
export default defineNuxtConfig({
  mcp: { sessions: { enabled: true } },
  nitro: { experimental: { asyncContext: true } },
});

Forget sessions and every request gets a fresh server that never saw initialize. It can't know the client fills forms, so ask stays undefined. Quiet, and instant. No request hanging somewhere. Forget asyncContext and it gets loud. useMcpElicitation can't find the request, so every call answers "Nitro request context is not available". Annoying? Sure. But it names the flag.

The question follows the call's abort signal, so a cancelled call takes its form back. Unless the client runs SDK v1 and it's the session's first request. That client skips a cancel for request id 0, so the form stays up with nobody waiting for it. SDK v2 clients get it right. One more catch. SDK v1 gives every request 60 seconds, a form too. A person who thinks longer makes ask throw.