Build a TypeScript MCP server with two tools, a text hasher and an npm version lookup, then test it in the MCP Inspector and connect it to Claude Desktop, Claude Code, VS Code, or Cursor. It is for developers who know Node.js.
Prerequisites#
- Node.js 22.19 or later. The SDK needs Node.js 20 or later, and the current MCP Inspector (2.12.0) needs 22.19.0 or later.
- npm, which ships with Node.js.
- An MCP client such as Claude Desktop, Claude Code, VS Code with GitHub Copilot, or Cursor.
- Working knowledge of TypeScript and
async/await.
Check your Node.js version:
node --versionHow MCP works#
The Model Context Protocol (MCP) is an open protocol that lets an AI application talk to outside programs in one standard way. MCP calls the AI application the host. Claude Desktop is one example. The host runs an MCP client, which connects to your MCP server and asks what it offers. A server can offer three kinds of features:
| Feature | What it is | Who controls it |
|---|---|---|
| Tools | Functions the model can call, such as "search issues" or "hash this string" | The model |
| Resources | Read-only data with a URI, such as a file or a database schema | The application |
| Prompts | Reusable message templates, often shown as slash commands | The user |
This guide covers only tools, since most servers start with them. The client and server exchange JSON-RPC messages. A local server uses the stdio transport. The host starts your program as a child process and talks to it over stdin and stdout.
Which SDK package to use#
The TypeScript SDK reached v2 on July 27, 2026. Version 2 splits the old @modelcontextprotocol/sdk package into @modelcontextprotocol/server and @modelcontextprotocol/client. It implements the 2026-07-28 spec. This guide uses @modelcontextprotocol/server 2.3.1.
The v1 package, @modelcontextprotocol/sdk, is now at 1.32.1 and still gets bug fixes and security updates. Start new code on v2. In v2, registerTool() replaces server.tool(). If you have a v1 server, follow the upgrade guide (opens in a new tab).
1. Set up the project#
Create the package#
mkdir devtools-mcp
cd devtools-mcp
npm init -y
npm pkg set type=moduleThe code uses ES module syntax, which is why the last command sets the package type to module.
Install dependencies#
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/nodeAt the time of writing, this installs @modelcontextprotocol/server 2.3.1, zod 4.6.7, typescript 7.0.2, and @types/node 26.6.5. The SDK depends on zod ^4.2.0. SDK v2 does not support Zod 3.
Configure TypeScript#
Create tsconfig.json in the project root:
1{
2 "compilerOptions": {
3 "target": "es2022",
4 "module": "nodenext",
5 "rootDir": "src",
6 "outDir": "build",
7 "strict": true,
8 "types": ["node"]
9 },
10 "include": ["src"]
11}Keep the "types": ["node"] line. The SDK's type declarations reference Node.js types such as Buffer. Since TypeScript 6.0, the types option defaults to an empty list, so the compiler no longer loads @types/* packages unless you list them.
Add scripts#
npm pkg set scripts.build="tsc" scripts.start="node build/index.js"2. Write the server#
Create src/index.ts:
1import { createHash } from 'node:crypto';
2import { McpServer } from '@modelcontextprotocol/server';
3import { serveStdio } from '@modelcontextprotocol/server/stdio';
4import * as z from 'zod/v4';
5
6function createServer(): McpServer {
7 const server = new McpServer({ name: 'devtools', version: '1.0.0' });
8
9 server.registerTool(
10 'hash-text',
11 {
12 title: 'Hash text',
13 description: 'Return the hex digest of a string',
14 inputSchema: z.object({
15 text: z.string().describe('The text to hash'),
16 algorithm: z
17 .enum(['sha256', 'sha512', 'md5'])
18 .default('sha256')
19 .describe('Hash algorithm to use'),
20 }),
21 annotations: { readOnlyHint: true },
22 },
23 async ({ text, algorithm }) => {
24 const digest = createHash(algorithm).update(text).digest('hex');
25 return { content: [{ type: 'text', text: `${algorithm}: ${digest}` }] };
26 },
27 );
28
29 server.registerTool(
30 'npm-latest',
31 {
32 title: 'Latest npm version',
33 description: 'Look up the latest published version of an npm package',
34 inputSchema: z.object({
35 name: z.string().min(1).describe('Package name, for example zod or @modelcontextprotocol/server'),
36 }),
37 outputSchema: z.object({ name: z.string(), version: z.string() }),
38 annotations: { readOnlyHint: true, openWorldHint: true },
39 },
40 async ({ name }) => {
41 const url = `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
42 const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
43 if (!res.ok) {
44 return {
45 content: [{ type: 'text', text: `npm registry returned HTTP ${res.status} for ${name}` }],
46 isError: true,
47 };
48 }
49 const data = (await res.json()) as { version: string };
50 const output = { name, version: data.version };
51 return {
52 content: [{ type: 'text', text: `${name}@${data.version}` }],
53 structuredContent: output,
54 };
55 },
56 );
57
58 return server;
59}
60
61serveStdio(createServer);
62console.error('devtools MCP server running on stdio');What the code does#
new McpServer({ name, version })creates the server. The name and version identify it to clients.registerTool(name, config, handler)adds a tool. The config holds atitle, adescriptionthe model reads, and aninputSchema.inputSchemais a Zod object. From it the SDK builds the JSON Schema the model sees, validates each call before your handler runs, and types the handler arguments. Text from.describe()goes into that JSON Schema, so write it for the model..default('sha256')makesalgorithmoptional. If the caller leaves it out, the handler still getssha256.- The handler returns
content, a list of typed blocks. SetisError: trueon failures the model should read, such as an HTTP 404 from the registry. - Together,
outputSchemaandstructuredContentgive clients a typed result next to the text. The SDK checksstructuredContentagainstoutputSchemabefore the result leaves the server. annotationsare hints for the client.readOnlyHint: truetells the client the tool changes nothing. Hints never change how the SDK runs the tool.serveStdio(createServer)reads requests on stdin, writes responses to stdout, and calls your factory to build the server for the connection.
serveStdio or StdioServerTransport#
The Getting Started example in the SDK README connects the transport by hand. In this project, that version replaces the serveStdio import and the serveStdio(createServer); line:
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
// createServer() stays the same
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);This still works. According to the SDK docs, a server connected this way speaks only the 2025-era protocol, so a client pinned to 2026-07-28 fails to connect. serveStdio serves both protocol eras from the same factory, so use it for new servers.
Keep stdout clean#
With the stdio transport, stdout carries the JSON-RPC messages. A single console.log writes a line the client cannot parse. Send logs to stderr with console.error, as the last line of the file does.
3. Build and run#
npm run build
npm startThe server prints devtools MCP server running on stdio to stderr and then waits for a client on stdin. That is all the output you should see. Press Ctrl+C to stop it.
4. Test with the MCP Inspector#
The MCP Inspector is the official tool for calling a server's tools without an AI client. It runs your server command and connects to it over stdio.
Use the web UI#
npx @modelcontextprotocol/inspector node build/index.jsThe Inspector prints a local URL in the form http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=... and opens it in your browser. The token protects the Inspector's local API, so do not paste it anywhere public.
- On the Servers screen, find the
nodeserver card and turn on its switch. The card then shows Connected. - Open the Tools tab. It lists Hash text and Latest npm version.
- Select Hash text, type
helloin the text field, and click Execute Tool. - The Results panel shows
sha256: 2cf24dba.... The Messages list in the sidebar logs each request, includingtools/listandtools/call.
Use CLI mode#
CLI mode prints JSON, so you can use it in scripts and CI jobs. List the tools:
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/listCall npm-latest:
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name npm-latest --tool-arg name=zodThe result contains both the text block and the structured output:
{
"content": [{ "type": "text", "text": "zod@4.6.7" }],
"structuredContent": { "name": "zod", "version": "4.6.7" }
}Now send an argument the schema rejects:
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name hash-text --tool-args-json '{"text":"hello","algorithm":"sha1"}'Your handler never runs. The SDK returns a result with isError: true and the message Input validation error: Invalid arguments for tool hash-text: algorithm: Invalid option: expected one of "sha256"|"sha512"|"md5". The Inspector CLI exits with code 5 for any isError result, so a CI job can catch it.
5. Connect the server to an MCP client#
Each client needs the command that starts your server, plus its arguments. Claude Desktop expects absolute paths in its config. Print the absolute path to the built server from the project root:
echo "$(pwd)/build/index.js"Claude Desktop#
- Open the Claude menu in your system menu bar and choose Settings.
- Go to the Developer tab and click Edit Config.
- Claude Desktop opens
claude_desktop_config.json. On macOS the file is at~/Library/Application Support/Claude/claude_desktop_config.json. On Windows it is at%APPDATA%\Claude\claude_desktop_config.json.
Add the server, using the path you printed:
1{
2 "mcpServers": {
3 "devtools": {
4 "command": "node",
5 "args": ["/Users/you/devtools-mcp/build/index.js"]
6 }
7 }
8}Quit Claude Desktop completely and open it again. Click Add files, connectors, and more at the bottom left of the message box. Hover over Connectors and click Manage connectors. Select devtools to see its two tools.
If your server later needs a secret, do not hard-code it in the source. Pass it through an env object in the same entry and read it with process.env in your code:
1{
2 "mcpServers": {
3 "devtools": {
4 "command": "node",
5 "args": ["/Users/you/devtools-mcp/build/index.js"],
6 "env": { "REGISTRY_TOKEN": "<your-token>" }
7 }
8 }
9}Claude Code, VS Code, and Cursor#
In Claude Code, you register servers from the command line. Everything after -- is the command that starts the server:
claude mcp add --transport stdio devtools -- node /Users/you/devtools-mcp/build/index.jsRun /mcp inside Claude Code to confirm that devtools is connected.
VS Code reads .vscode/mcp.json, which has its own format with a servers key. By default VS Code runs a stdio server in the workspace folder, so a relative path works:
1{
2 "servers": {
3 "devtools": {
4 "type": "stdio",
5 "command": "node",
6 "args": ["build/index.js"]
7 }
8 }
9}VS Code asks you to trust the server before starting it. The MCP configuration reference (opens in a new tab) lists every field.
Cursor reads .cursor/mcp.json, which uses the same mcpServers shape as Claude Desktop. Cursor also accepts ${workspaceFolder} in args.
Verify it works#
Open a new chat in your client and ask: "What is the latest version of the zod package on npm?"
The model picks npm-latest based on its name, description, and schema, so you do not need to name the tool. Your client may ask you to approve the call. The answer then comes from your handler. At the time of writing it is 4.6.7. Next, ask "Hash the word hello with sha512" to test the enum argument.
Claude Desktop writes each server's stderr to a log file. Follow the MCP logs on macOS:
tail -n 20 -f ~/Library/Logs/Claude/mcp*.logThe file mcp-server-devtools.log should contain the devtools MCP server running on stdio line. On Windows the logs are in %APPDATA%\Claude\logs.
Troubleshooting#
SyntaxError: Unexpected token ... is not valid JSON#
Something wrote a non-JSON line to stdout. Find the console.log call in your code or in a dependency and change it to console.error. The quoted token is the first character of the stray line, and it often points to the call that wrote it.
TS2591: Cannot find name 'Buffer' during the build#
TypeScript is not loading Node.js types. Install @types/node as a dev dependency and add "types": ["node"] to compilerOptions.
TS2589: Type instantiation is excessively deep and possibly infinite#
You have two copies of Zod in the dependency tree. List them:
npm ls zodForce a single Zod 4 version with an overrides entry in package.json, then reinstall:
{
"overrides": {
"zod": "^4.2.0"
}
}The server does not appear in the client#
Run the exact command from your config in a terminal, for example node /Users/you/devtools-mcp/build/index.js. If it crashes, fix that error first. Check that the config is valid JSON and that every path is absolute. If the log says node was not found, set "command" to the full path that which node prints. Restart the client after every config change.
Next steps#
- Expose read-only data with
registerResource()and reusable message templates withregisterPrompt(). The SDK guides for resources (opens in a new tab) and prompts (opens in a new tab) cover both. - Serve the same
createServerfactory over Streamable HTTP (opens in a new tab) so remote clients can connect. - Call your tools from a real
Clientin the same process, with no port or host. The SDK page Test a server (opens in a new tab) shows how. - Read the 2026-07-28 specification (opens in a new tab) for the full protocol.
- To run a model on your own machine and call it from your code, see Run a local LLM with Ollama and call it from your code.