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
- Import
import { createMcpServer } from "@agntn/tools/mcp" - Peer
@modelcontextprotocol/server >=2.2.0 <3 - Fails
isError: true
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", 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. Both go out exactly as you wrote them. 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:
readOnlyHintwhen the effect isreaddestructiveHintwhen it'sdestructiveidempotentHintfromidempotent, which defaults to readsopenWorldHintfromopenWorld, off unless you say so
details never reaches the client 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.
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.
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. @nuxtjs/mcp-toolkit on a Docus /mcp route asks for a listing and a handler, and that's it. So the two halves come out on their own:
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:
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.
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.