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" }, 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.

What the client sees

The schema goes out exactly as you wrote it, additionalProperties: false and pattern included. 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

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.

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.