- The official TypeScript SDK has moved to v2 split packages (@modelcontextprotocol/server and /client, 2.3.1); this guide builds a stdio server with one tool, one resource and one prompt.
- We compiled and ran this example on Node.js 22 to check that it works: a scripted client built on the official client SDK connected over both the older handshake and the new stateless 2026-07-28 protocol.
- Use serveStdio() rather than the v1-style connect() call if you want 2026-07-28 clients to reach your server, and treat every local server as code running with your privileges.
Contents
This guide builds a small, working MCP server in TypeScript that runs locally over stdio. It exposes one tool, one resource and one prompt, so you can see all three server primitives side by side. If MCP is new to you, read what MCP is first; if you're unsure which primitive to use for what, see MCP resources vs tools vs prompts.
How we checked this code. On October 8, 2026, we compiled and ran this example with @modelcontextprotocol/server 2.3.1 on Node.js 22 to check that it works. The project's lockfile pins TypeScript 7.0.2. We drove it with a test script that uses the official MCP client SDK. The script connected over both the older 2025-era handshake and the new stateless 2026-07-28 protocol. The Claude Desktop and Claude Code steps follow the official docs. No AI model was used. The source, lockfile and test output are in our code examples folder (code-examples/D4-mcp-server-example/).
Last verified: October 8, 2026
Which SDK package? v1 vs v2
If you search npm, you'll find two lines:
@modelcontextprotocol/sdk: the v1 package. Its npm "latest" tag was 1.32.1 on October 8, 2026.@modelcontextprotocol/serverand@modelcontextprotocol/client: the v2 split packages, both at 2.3.1 (published October 5, 2026).
The official SDK README now points to the v2 packages and calls v2 "the stable release line," released alongside the 2026-07-28 spec. It says v1.x will get bug fixes and security updates "for at least 6 months after v2's release." The quickstart on modelcontextprotocol.io also uses @modelcontextprotocol/server. This guide uses v2.
One wrinkle: other tools you install may still depend on v1. The Claude Agent SDK, for example, declares @modelcontextprotocol/sdk ^1.29.0 as a peer dependency (see our Agent SDK guide).
Prerequisites
- Node.js 20 or newer for the SDK (its
enginesfield says>=20). The MCP Inspector requires Node 22.19.0 or newer, so use Node 22 if you want both. - npm, and a terminal.
- Optional: Claude Desktop or Claude Code to use the server from a real host.
Step 1: Create the project
mkdir style-helper && cd style-helper
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node
mkdir srcEdit package.json to add ES module support, a binary entry and a build script (this matches the official quickstart):
{
"type": "module",
"bin": { "style-helper": "./build/index.js" },
"scripts": {
"build": "tsc && chmod 755 build/index.js"
}
}Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}Step 2: Write the server
Create src/index.ts. This is the exact file we compiled and ran:
#!/usr/bin/env node
// A minimal MCP server with one tool, one resource and one prompt.
// Built against @modelcontextprotocol/server 2.3.1 (MCP spec 2026-07-28).
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const STYLE_GUIDE = `# House style (short version)
- Lead with the point. No throat-clearing intros.
- Use explicit dates ("October 8, 2026"), never "recently".
- Attribute vendor claims ("Anthropic says...").
- American spelling.`;
// serveStdio() calls this factory to build the server for a connection.
// Register tools, resources and prompts once; the same factory serves
// clients on the 2026-07-28 revision and older 2025-era clients.
function createServer(): McpServer {
const server = new McpServer({ name: "style-helper", version: "1.0.0" });
// 1) TOOL — model-controlled: the model decides when to call it.
server.registerTool(
"word_count",
{
title: "Word counter",
description: "Count words, sentences and characters in a piece of text.",
inputSchema: z.object({
text: z.string().max(100_000).describe("The text to analyze"),
}),
outputSchema: z.object({
words: z.number(),
sentences: z.number(),
characters: z.number(),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ text }) => {
const words = text.trim() === "" ? 0 : text.trim().split(/\s+/).length;
const sentences = (text.match(/[.!?]+(\s|$)/g) ?? []).length;
const result = { words, sentences, characters: text.length };
return {
// Text block for older clients, structuredContent for newer ones.
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
},
);
// 2) RESOURCE — application-controlled: the host decides when to read it.
server.registerResource(
"style-guide",
"docs://style-guide",
{
title: "House style guide",
description: "The publication's short style guide",
mimeType: "text/markdown",
},
async (uri) => ({
contents: [{ uri: uri.href, mimeType: "text/markdown", text: STYLE_GUIDE }],
}),
);
// 3) PROMPT — user-controlled: surfaced as a slash command or menu item.
server.registerPrompt(
"review_draft",
{
title: "Review a draft against the style guide",
description: "Ask the model to review a draft using the house style guide",
argsSchema: z.object({
draft: z.string().describe("The draft text to review"),
}),
},
({ draft }) => ({
messages: [
{
role: "user",
content: {
type: "resource",
resource: {
uri: "docs://style-guide",
mimeType: "text/markdown",
text: STYLE_GUIDE,
},
},
},
{
role: "user",
content: {
type: "text",
text: `Review this draft against the style guide above. List concrete fixes.\n\n${draft}`,
},
},
],
}),
);
return server;
}
// stdout is reserved for JSON-RPC messages; log to stderr only.
serveStdio(createServer, {
onerror: (error) => console.error("MCP transport error:", error),
});
console.error("style-helper MCP server running on stdio");What each part does:
registerTooltakes a name, a config object with Zod schemas, and a handler. The SDK turns the Zod schemas into JSON Schema 2020-12, validates inputs before your handler runs, and checks your output againstoutputSchema.registerResourcetakes a name, a fixed URI (or aResourceTemplatefor parameterized URIs), metadata, and a read callback.registerPrompttakes a name, metadata with anargsSchema, and a callback returningmessages.serveStdio(factory)owns the stdio transport and decides, per connection, which protocol era to speak. More on why that matters below.
Step 3: Build
npm run buildExpected result: no output from tsc, and a build/index.js file. If you run node build/index.js directly, you'll see the stderr line style-helper MCP server running on stdio and the process will wait for JSON on stdin. Press Ctrl+C to exit.
Step 4: Test with the MCP Inspector
The Inspector is the project's official debugging tool. It has a web UI, a CLI and a terminal UI in one package. No install is needed:
# Web UI (opens a browser with a per-launch token in the URL)
npx @modelcontextprotocol/inspector node build/index.js
# CLI: list tools and exit
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
# CLI: call the tool
npx @modelcontextprotocol/inspector --cli node build/index.js \
--method tools/call --tool-name word_count --tool-arg "text=One two three."Given the server code above, the tools/call should return:
{
"content": [{ "type": "text", "text": "{\"words\":3,\"sentences\":1,\"characters\":14}" }],
"structuredContent": { "words": 3, "sentences": 1, "characters": 14 }
}The same pattern works for --method resources/read --uri docs://style-guide and --method prompts/get --prompt-name review_draft --prompt-args "draft=Hello". The Inspector docs cover the full set of CLI options, including how to choose the protocol era.
Step 5 (optional): A scripted smoke test
For CI, drive the server with the official client package (npm install -D @modelcontextprotocol/client). Our test/smoke-test.mjs in code-examples/D4-mcp-server-example/ connects twice, once with default negotiation and once pinned to the new protocol. This is a simplified version of one connection:
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const client = new Client(
{ name: "smoke-test", version: "1.0.0" },
{ versionNegotiation: { mode: { pin: "2026-07-28" } } },
);
await client.connect(new StdioClientTransport({ command: "node", args: ["build/index.js"] }));
console.log(client.getNegotiatedProtocolVersion()); // "2026-07-28"
const result = await client.callTool({ name: "word_count", arguments: { text: "Hello MCP world." } });
console.log(result.structuredContent);
await client.close();When we ran the full test script, it printed negotiated protocol version: 2025-11-25 for the default client and 2026-07-28 for the pinned one, and every list, read, get and call succeeded in both. Passing a number instead of a string came back as a tool result with isError: true and a Zod validation message, and the server kept running.
Step 6: Connect it to Claude Desktop or Claude Code
We didn't run this step; the snippets follow the official docs. Use an absolute path to build/index.js.
Claude Desktop reads claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %AppData%\Claude\). Add:
{
"mcpServers": {
"style-helper": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/style-helper/build/index.js"]
}
}
}Then fully restart Claude Desktop.
Claude Code uses claude mcp add. For stdio servers, everything after -- is the command that starts the server:
claude mcp add --transport stdio style-helper -- node /ABSOLUTE/PATH/TO/style-helper/build/index.js
claude mcp listAdd --scope project to write the server to a shared .mcp.json in your repo (Claude Code asks for approval before using project-scoped servers), or --scope user for all your projects. The default local scope is just you, just this project. Inside a session, /mcp shows status. Per Claude Code's docs, the resource is then available as an @ mention (format @server:protocol://path), and the prompt in the / menu, where Claude Code lists MCP prompts as /servername:promptname (MCP). Its docs say the /mcp__servername__promptname form also works but that some characters in server names get replaced in that form, so pick the command from the menu. Prompt arguments are passed space-separated after the command.
Troubleshooting
- "Method not found" for
server/discover, or a 2026-07-28 client can't connect. Check how the server is wired. The SDK's v2 docs describeserveStdio(createServer)as replacing the v1-styleawait server.connect(new StdioServerTransport())wiring, and as the way to serve clients on either protocol era. The example in this guide usesserveStdio, and our test script reached it over both eras. - Host says the server disconnected or sent invalid JSON. Something wrote to stdout. Use
console.error, neverconsole.log, in stdio servers. - "Cannot find module" at runtime. Check
"type": "module"inpackage.jsonand that you rannpm run buildafter editing. - Inspector refuses to start. Check
node --version; it needs 22.19.0+. - Host can't find the server. Use absolute paths; hosts don't start in your project directory. On Claude Code,
MCP_TIMEOUT=10000 clauderaises the startup timeout.
Security: what you're actually installing
A local MCP server is a program running with your user account's permissions, launched by your AI app. The spec's security guidance says clients offering one-click installs must show the full command and get explicit consent, and should sandbox servers. Treat it accordingly:
- Validate inputs (the Zod schemas do this) and cap sizes, as
max(100_000)does here. - Never read secrets from tool arguments. For stdio servers, the spec says to take credentials from the environment, not from MCP authorization. Never return secrets in tool results; they go straight into the model's context.
- Restrict file access. If you add file-reading tools, resolve paths and refuse anything outside an allowed directory. The spec requires servers to sanitize paths against directory traversal for
file://resources. - Annotations don't protect anyone.
readOnlyHint: trueis a hint to the host, not enforcement. - Remember prompt injection. If a tool returns content you didn't write (web pages, emails, issues), the model may follow instructions inside it. Keep a human approving tool calls; our case against unattended agents explains why.
- Pin versions and review updates to third-party servers. A server's tool descriptions can change after you approve it.
About this storyBased on the sources linked below. The example code was compiled and run by 9to5AI in a Linux container to check that it works; no AI model was used or evaluated. Editorial standards




