How to Build an MCP Server: A Step-by-Step Tutorial for 2026
An MCP server is a small JSON-RPC 2.0 process that exposes tools, resources and prompts to any MCP-compatible AI client. This tutorial builds one from scratch with the official TypeScript SDK, version 1.29.0, the latest stable release on npm as of 24 July 2026. You will register a typed tool, add a resource and a prompt, run the server over stdio, test it with MCP Inspector, wire it into Claude Desktop, then move it to Streamable HTTP for remote use. The security step is the one most tutorials skip and the one that matters most: tool descriptions are untrusted input to a model, and model-supplied arguments must never be trusted by your handler. Every API call below was verified against the SDK source and docs at tag v1.29.0.
Step 1. Understand what MCP is and what you are building
The Model Context Protocol is an open standard that defines how an AI application talks to external capabilities over JSON-RPC 2.0. A server offers three things to a client: resources (context and data), prompts (templated messages and workflows), and tools (functions the model can execute); clients can offer sampling, roots and elicitation back. The leverage is simple - write a capability once as an MCP server and any compliant client can use it, instead of rebuilding the same integration inside every product. The current specification revision is 2025-11-25, which is what the SDK used here negotiates by default.
You will build a small server called task-server with one tool, two resources and one prompt, run it over stdio, test it with MCP Inspector, connect it to Claude Desktop, expose the same server over Streamable HTTP, harden it, and publish it.
Prerequisites: Node.js 18 or newer (the SDK declares an engines field of node >=18), npm, working TypeScript knowledge, and one MCP client to test against - Claude Desktop or Claude Code both work.
For conceptual grounding first, start with our complete guide to the Model Context Protocol. This piece assumes you already know why you want a server.
Step 2. Set up the project and install the SDK
The official TypeScript package is published on npm as @modelcontextprotocol/sdk. The latest stable version is 1.29.0, published 30 March 2026 and still tagged latest as of 24 July 2026. It is MIT licensed with a required peer dependency on zod; the official quickstart pins zod v3, so that is what we install.
~~~bash
mkdir task-server && cd task-server
npm init -y
npm install @modelcontextprotocol/sdk@1.29.0 zod@3
npm install -D @types/node typescript
mkdir src
~~~
Update package.json so Node treats the output as ES modules and you get a build script:
~~~json
{
"type": "module",
"bin": {
"task-server": "./build/index.js"
},
"scripts": {
"build": "tsc"
},
"files": ["build"]
}
~~~
Then add tsconfig.json, taken from the official build-a-server quickstart:
~~~json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
~~~
Gotcha worth knowing before you write a line: Node16 module resolution means import paths carry a .js extension even in TypeScript source, and the SDK is imported through deep subpaths ending in .js. That is not a typo in the snippets below.
Step 3. Define a tool with a typed input schema
Tools are the primitive that makes a server useful, so start there. The v1.29.0 API is registerTool(name, config, handler), where config accepts title, description, inputSchema, outputSchema, annotations and _meta.
The detail that trips up almost everyone: inputSchema is a raw Zod shape - a plain object whose values are Zod types - not a z.object() wrapper. Wrapping it produces confusing type errors and a tool the client cannot call.
Create src/server.ts:
~~~ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
type Task = {
id: number;
title: string;
priority: 'low' | 'normal' | 'high';
done: boolean;
};
const tasks: Task[] = [];
let nextId = 1;
export function buildServer() {
const server = new McpServer({
name: 'task-server',
version: '1.0.0'
});
server.registerTool(
'create_task',
{
title: 'Create task',
description: 'Create a task in the local task list and return it.',
inputSchema: {
title: z.string().min(1).max(200).describe('Short description of the task'),
priority: z.enum(['low', 'normal', 'high']).default('normal').describe('Task priority')
},
outputSchema: {
id: z.number(),
title: z.string(),
priority: z.string(),
done: z.boolean()
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false
}
},
async ({ title, priority }) => {
const task: Task = { id: nextId++, title, priority, done: false };
tasks.push(task);
return {
content: [{ type: 'text', text: 'Created task ' + task.id + ': ' + task.title }],
structuredContent: task
};
}
);
return server;
}
~~~
Three details matter. The content array is what the model reads. The structuredContent object is the machine-readable result, and the SDK validates it against your outputSchema, so mismatched data fails the call. The annotations object carries behavioural hints clients use to decide how loudly to ask for approval - readOnlyHint, destructiveHint, idempotentHint and openWorldHint are all defined in the SDK types.
The SDK also parses incoming arguments against inputSchema before your handler runs, raising an InvalidParams error if they do not match. Useful, but that is validation, not authorization. Step 8 covers the difference.
Step 4. Add a resource and a prompt
A server with only tools has thrown away two thirds of the protocol. The three primitives differ mainly in who is in control.
| Primitive | Controlled by | Side effects | Typical use | SDK method |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| Tool | The model, usually with user approval | Expected | Create a ticket, run a query, send a message | registerTool |
| Resource | The client application | Should have none | Config files, documents, records the user attaches | registerResource |
| Prompt | The user, explicitly | None | A slash command or template the user picks from a menu | registerPrompt |
Add all three inside buildServer, before the return:
~~~ts
server.registerResource(
'task-list',
'tasks://all',
{
title: 'Task list',
description: 'The full task list as JSON',
mimeType: 'application/json'
},
async uri => ({
contents: [{ uri: uri.href, text: JSON.stringify(tasks, null, 2) }]
})
);
server.registerResource(
'task',
new ResourceTemplate('tasks://{taskId}', { list: undefined }),
{
title: 'Single task',
mimeType: 'application/json'
},
async (uri, { taskId }) => {
const found = tasks.find(t => String(t.id) === String(taskId));
return {
contents: [
{
uri: uri.href,
text: JSON.stringify(found ?? { error: 'not found' })
}
]
};
}
);
server.registerPrompt(
'triage-tasks',
{
title: 'Triage tasks',
description: 'Ask the model to triage the open task list',
argsSchema: {
focus: z.string().describe('What to prioritise for, e.g. shipping this week')
}
},
({ focus }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Task list: ' + JSON.stringify(tasks) + ' -- triage it with this focus: ' + focus
}
}
]
})
);
~~~
Note the two resource forms. A static resource takes a fixed URI string. A dynamic one takes a ResourceTemplate whose URI pattern contains named variables, and those variables arrive as the second argument to the read callback; passing list: undefined means the server does not advertise an enumerable list for that template.
Prompt arguments use argsSchema, not inputSchema, and a prompt is something a human picks from a menu - keep the argument list short and readable. If you are building toward multi-step autonomy, our guide to agentic AI covers where these primitives sit in a wider loop.
Step 5. Run the server over stdio and test it
stdio is the transport for local integrations: the client spawns your server as a child process and speaks JSON-RPC over stdin and stdout. Create src/index.ts:
~~~ts
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { buildServer } from './server.js';
async function main() {
const server = buildServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('task-server running on stdio');
}
main().catch(error => {
console.error('Fatal error in main():', error);
process.exit(1);
});
~~~
The console.error call is not a typo either. In a stdio server, stdout is the protocol channel: a single stray console.log inserts non-JSON into the stream and the client drops the connection, usually with an error that points nowhere near the real cause. All diagnostics go to stderr.
Build and inspect:
~~~bash
npm run build
npx @modelcontextprotocol/inspector node build/index.js
~~~
MCP Inspector is the official debugging client, published as @modelcontextprotocol/inspector, currently at version 1.0.0. It starts a web UI on port 6274 and a proxy on 6277, prints a session token that must be sent as a bearer token, and opens a browser with the token pre-filled. Go to the Tools tab, call create_task, and confirm both the text content and the structured result come back.
There is a headless mode too, which is what you want in CI:
~~~bash
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name create_task --tool-arg title=Write-the-docs
~~~
If the server refuses to start, or starts then disconnects, work through our MCP connection failure troubleshooting guide before changing code - most of these failures are path, build or stdout problems rather than protocol bugs.
Step 6. Connect it to a real client
Claude Desktop reads a JSON file listing the servers it should spawn. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json and on Windows at %APPDATA%\Claude\claude_desktop_config.json. Open it from Settings, then the Developer tab, then Edit Config.
Add your server under the mcpServers key:
~~~json
{
"mcpServers": {
"task-server": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/task-server/build/index.js"]
}
}
}
~~~
Each entry takes a command, an args array, and an optional env object for secrets and API keys; the key name is just the label the client shows. Two rules the docs are emphatic about: paths must be absolute, not relative, and you must fully quit and relaunch Claude Desktop rather than closing the window. If nothing appears, the logs are the fastest route - Claude writes a general mcp.log plus a per-server file, under ~/Library/Logs/Claude on macOS and the Claude logs folder under %APPDATA% on Windows.
Claude Code takes the same server from the command line, which is quicker to iterate on:
~~~bash
claude mcp add task-server -- node /ABSOLUTE/PATH/TO/task-server/build/index.js
~~~
Everything after the double dash is the command and its arguments. The same CLI adds remote servers with a transport flag, which is what Step 7 produces.
Step 7. Move to Streamable HTTP when you need it remotely
Streamable HTTP is the modern remote transport: request and response over HTTP POST, optional server-to-client notifications over SSE, optional JSON-only mode, and optional session management with resumability. The older HTTP plus SSE transport still ships in the SDK but only for backwards compatibility.
| stdio | Streamable HTTP | |
|---|---|---|
| --- | --- | --- |
| Who runs the process | The client spawns it locally | You host it |
| Users per instance | One, the local user | Many, so you need tenancy |
| Authorization | Inherits the user's OS privileges | OAuth, tokens, per-request checks |
| Network exposure | None | Public or internal endpoint |
| Best for | Local files, local databases, dev tooling | SaaS integrations, shared team servers |
| Main risk | A malicious server runs as you | Session hijacking, confused deputy, SSRF |
The stateless pattern below follows the SDK's own example: build a fresh server per request and tear it down when the response closes. Express ships as a direct dependency of the SDK, but add its types for the annotations to compile - npm install -D @types/express.
~~~ts
import { Request, Response } from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { buildServer } from './server.js';
const app = createMcpExpressApp();
app.post('/mcp', async (req: Request, res: Response) => {
const server = buildServer();
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null
});
}
}
});
app.listen(3000, () => {
console.error('task-server listening on port 3000');
});
~~~
Setting sessionIdGenerator to undefined is the explicit opt-in to stateless mode: no session ID is issued and none is validated. For stateful mode you pass a generator - the SDK documents randomUUID - and the transport then issues session IDs, rejects unknown ones with 404, and rejects non-initialization requests arriving without one.
createMcpExpressApp is doing real work here. Local MCP servers on HTTP are exposed to DNS rebinding attacks, and this helper enables host header validation by default with 127.0.0.1 as the default host. Bind to 0.0.0.0 and that automatic protection does not apply, so you own the validation. For how MCP compares to other agent transports, see our A2A versus MCP breakdown.
Step 8. Harden the server before anyone else runs it
Read this step twice. An MCP server is arbitrary code execution wired to a language model, and the specification says so plainly: tools represent arbitrary code execution and must be treated with caution, and descriptions of tool behaviour including annotations should be considered untrusted unless they come from a trusted server.
Tool poisoning and description-level injection. Invariant Labs published the first proof of concept in April 2025: malicious instructions hidden in a tool description are read by the model as ground truth during planning, even though the user interface never shows them. OWASP tracks the same class as MCP03 in its MCP Top 10. Your exposure as a server author is twofold. Your own descriptions should be plain, literal and free of anything resembling an instruction. More importantly, any text your tool returns - a fetched web page, an issue body, a customer email - is attacker-influenced content that lands in the model context. Label it as data, bound its size, and never let it become the sole basis for a privileged follow-up action. Our guide to AI guardrails covers the containment patterns.
Validation is not authorization. The SDK checks arguments against your Zod schema before the handler runs. That stops malformed input; it does nothing about a well-formed request to read a file the caller should not see. Every argument is model-supplied, which means user-influenced and potentially attacker-influenced. Re-derive authority on your side:
~~~ts
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const ALLOWED_ROOT = path.resolve(process.env.TASK_NOTES_DIR ?? './data');
server.registerTool(
'read_note',
{
title: 'Read note',
description: 'Read a note file from the configured notes directory.',
inputSchema: {
relativePath: z.string().max(255).describe('Path relative to the notes directory')
},
annotations: { readOnlyHint: true, openWorldHint: false }
},
async ({ relativePath }) => {
const target = path.resolve(ALLOWED_ROOT, relativePath);
const inside = target === ALLOWED_ROOT || target.startsWith(ALLOWED_ROOT + path.sep);
if (!inside) {
return {
content: [{ type: 'text', text: 'Refused: path resolves outside the notes directory.' }],
isError: true
};
}
const text = await readFile(target, 'utf8');
return { content: [{ type: 'text', text }] };
}
);
~~~
Returning isError true rather than throwing is the documented way to signal a failed tool call, and it gives the model a readable reason instead of an opaque protocol error.
Confused deputy. If your server proxies a third-party API, you are a deputy holding credentials on someone else's behalf. The specification describes the attack in detail: a proxy using a static client ID with a third-party authorization server, combined with dynamic client registration and a consent cookie, lets an attacker obtain authorization codes without the user ever seeing a consent screen. The mitigations are specific - keep a registry of approved client IDs per user and check it before forwarding to the third party, match redirect URIs by exact string comparison with no wildcards, generate a cryptographically random single-use state value, and do not set the consent cookie or session until after the user has actually approved.
Token passthrough is forbidden. The specification states that MCP servers must not accept tokens that were not explicitly issued for the MCP server. Forwarding a client token straight to a downstream API destroys your audit trail, bypasses rate limiting and validation, and turns your server into an exfiltration proxy for anyone holding a stolen token.
Sessions are not authentication. For HTTP servers the rule is blunt: servers implementing authorization must verify every inbound request, and must not use sessions for authentication. Session IDs must be non-deterministic and generated from a secure random source, and the spec recommends binding them to user-specific data using a key format such as user ID followed by session ID, so guessing an ID does not yield impersonation.
Least privilege, deliberately. The specification's scope-minimization guidance names the common mistakes: publishing every possible scope in the supported list, using wildcard or omnibus scopes, and bundling unrelated privileges to preempt future prompts. Start from a minimal read-only scope set and escalate on demand. The same instinct applies to the tools - four narrow tools are easier to reason about than one general execute tool.
Step 9. Publish and distribute
The simplest distribution is a package registry. Publish to npm and users can run your server through npx with no install step, which is exactly what the standard client config expects. Python servers publish to PyPI and run through uvx.
The MCP Registry is a metadata index on top of that, and its own documentation states it is currently in preview with breaking changes or data resets possible. Publishing has four moving parts:
- Add an mcpName property to package.json. This is the ownership proof; for GitHub authentication it must start with io.github. followed by your username.
- Publish to npm first - the registry stores metadata, not artifacts.
- Install the mcp-publisher CLI, then run its init, login and publish commands.
- Edit the generated server.json so its name matches mcpName exactly and the package version matches what you published.
A generated server.json looks like this:
~~~json
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.my-username/task-server",
"description": "A task list MCP server.",
"repository": {
"url": "https://github.com/my-username/task-server",
"source": "github"
},
"version": "1.0.1",
"packages": [
{
"registryType": "npm",
"identifier": "@my-username/task-server",
"version": "1.0.1",
"transport": {
"type": "stdio"
}
}
]
}
~~~
Beyond npm, the registry documents package types for PyPI, NuGet, Cargo, Docker or OCI images, and MCPB bundles, each with its own ownership-verification mechanism; remote servers are listed with a URL rather than a package. Pick the one your users already have tooling for - a Rust binary shipped as an MCPB release spares them a toolchain, while an OCI image suits teams already running containers.
How This Guide Was Built
Every API call in this tutorial was checked against primary sources rather than written from memory.
- SDK version: @modelcontextprotocol/sdk v1.29.0 - confirmed as the npm latest tag, published 30 March 2026, MIT licensed, engines node >=18, peer dependency zod ^3.25 or ^4.0, via the npm package page and registry metadata.
- API surface: docs/server.md at tag v1.29.0 for registerTool, registerResource, ResourceTemplate, registerPrompt and StdioServerTransport; src/server/mcp.ts at the same tag for the registerTool config object (title, description, inputSchema, outputSchema, annotations, _meta); src/types.ts for the annotation hint names; src/examples/server/simpleStatelessStreamableHttp.ts for the Streamable HTTP wiring.
- Project scaffolding: package.json and tsconfig.json blocks taken from the official build an MCP server quickstart.
- Client config: the claude_desktop_config.json shape and log locations from connect to local MCP servers; the CLI form from the Claude Code MCP documentation.
- Specification: revision 2025-11-25, cross-checked against the LATEST_PROTOCOL_VERSION constant in the SDK source. Security requirements quoted from MCP security best practices.
- Tooling and distribution: MCP Inspector v1.0.0 per its npm metadata and README; publishing steps and server.json example from the MCP Registry publishing quickstart.
- Threat references: the Invariant Labs tool poisoning notification and OWASP MCP Top 10 entry MCP03.
Honest limits: this code was assembled from and line-by-line verified against the official SDK documentation, examples and published type signatures at tag v1.29.0. It has not been run in production, benchmarked, or operated at scale, and no first-party performance claims are made anywhere in this guide. The MCP SDKs have changed API shape across versions - if a call here disagrees with the docs for the version you install, follow the docs.
Conclusion
A working MCP server is a small amount of code and a large amount of judgement. The SDK reduces the mechanical part to a handful of register calls and a transport; what remains is deciding which capabilities deserve to be tools at all, keeping resources side-effect free, and treating every model-supplied argument as untrusted. Build over stdio, prove it with Inspector, wire it into one real client, and only then reach for HTTP - and read the security best practices document end to end before you expose a port.
This is an editorial synthesis of official documentation, SDK source code, and public security research; see our [methodology](/methodology). Verify current details with each project.
Key Takeaways
- The current MCP specification revision is 2025-11-25, and @modelcontextprotocol/sdk v1.29.0 negotiates it by default.
- registerTool takes a raw Zod shape as inputSchema, not a z.object wrapper - this is the single most common copy-paste failure.
- Never write to stdout in a stdio server: stdout is the JSON-RPC channel, so all logging goes to stderr.
- Tools take actions, resources return data without side effects, and prompts are user-invoked templates - clients surface all three differently.
- Use stdio for local single-user servers and Streamable HTTP for anything remote or multi-tenant.
- The SDK validates arguments against your inputSchema, but validation is not authorization: you still need allowlists, path checks and least-privilege scopes.
- The MCP specification explicitly forbids token passthrough and requires per-client consent in proxy servers to avoid confused-deputy attacks.
Frequently Asked Questions
Should I use the TypeScript SDK or the Python SDK?
Both are official and both track the same specification. This tutorial uses TypeScript because the SDK ships the fullest set of runnable server examples, and because npx distribution makes local install trivial for readers. The Python package is published on PyPI as mcp, with version 1.28.1 as its latest release at the time of writing and a floor of Python 3.10. If your tool wraps a Python data stack, use the Python SDK - the concepts in this guide map across, but the exact function names do not, so follow the Python docs rather than translating this code by hand.
What is the actual difference between a tool and a resource?
A tool is model-controlled: the model decides to call it, usually with user approval, and it may have side effects. A resource is application-controlled read-only context that a client can attach to a conversation without the model deciding anything. The official server docs are explicit that resources should not perform heavy computation or side effects. If your operation writes, deletes, pays, posts or sends, it is a tool - not a resource.
Why is my server connecting but no tools appear in the client?
The usual causes are: the build step was skipped so the compiled entry file does not exist, the config uses a relative path instead of an absolute one, or the process writes to stdout and corrupts the JSON-RPC stream. Check the client logs first - Claude Desktop writes MCP logs to a per-server file under the Claude logs directory. Run the same command manually in a terminal to see whether it even starts.
Do I need OAuth for a local stdio server?
No. A stdio server is spawned as a child process by a client on the same machine and inherits that user's privileges, so there is no remote authorization step. That is also its main security property and its main risk: the specification recommends stdio precisely because it limits access to just the spawning client, but a malicious stdio server runs as you. Authorization only becomes a requirement when you move to an HTTP transport.
Can one codebase serve both stdio and Streamable HTTP?
Yes, and it is the recommended shape. Put your registerTool, registerResource and registerPrompt calls in a factory function that returns a configured McpServer, then have two entry points: one that connects a StdioServerTransport, and one that mounts a StreamableHTTPServerTransport on an HTTP route. The SDK examples do exactly this, constructing a fresh server per request in the stateless HTTP case.
How do I stop a model calling a destructive tool by accident?
Layer three things. First, tool annotations such as readOnlyHint and destructiveHint, which the SDK exposes on the tool config and clients use to shape their approval UI - treat them as hints, not enforcement. Second, hard server-side checks: allowlists, scoped credentials, and refusal paths that return isError rather than throwing. Third, client-side human approval for anything irreversible. Do not rely on the tool description to keep the model in line.
Is the MCP Registry required to distribute my server?
No. Publishing the package to npm or PyPI is enough for anyone to run it via npx or uvx, and many servers are distributed only through a GitHub repo. The registry is a metadata index that helps clients discover servers, and it is still labelled as being in preview by its own documentation, so expect it to change. Publish to a package registry first, then add the registry entry if you want discoverability.
What is tool poisoning and does it affect servers I write myself?
Tool poisoning is prompt injection delivered through tool metadata: an attacker writes instructions into a tool description or schema that the model reads as ground truth during planning. Invariant Labs published the first proof of concept in April 2025, and OWASP tracks it as MCP03 in its MCP Top 10. It mostly affects hosts that load third-party servers, but it affects you too if your tool returns attacker-controlled text - a web page, an issue body, a customer email - because that content lands in the model context as well.
About the Author
Elena Rodriguez
Developer Experience Editorial Desk
Developer Experience Editorial Desk · Web3AIBlog
Elena Rodriguez is a pen name for our developer-experience editorial desk. Posts under this byline are written and reviewed by working engineers covering full-stack development, Web3 dApp architecture, deployment workflows, build tooling, and developer productivity. The desk specializes in turning real production debugging — failed deploys, flaky tests, memory leaks, broken migrations — into reproducible field manuals. Code samples in our tutorials are run end-to-end before publication.