The Model Context Protocol (MCP) has a fragmentation problem in its documentation: most examples are either too minimal to be useful, or they bury the key concepts under framework boilerplate. This article walks through building a real MCP server in TypeScript — one that implements all three primitives (Tools, Resources, Prompts) and runs over both stdio and HTTP transport.
The code is production-shaped, not toy-shaped. By the end you'll understand not just what to write, but why each piece is the way it is.
The One Thing You Must Know About stdio
Before any code: if you use stdio transport, all logging must go to stderr. Not console.log — console.error.
stdio transport uses stdout as the protocol channel for JSON-RPC frames. A single console.log corrupts the frame and causes host parsing failures. The SDK won't warn you. The host just silently breaks.
// ✅ correct
console.error('Server started');
// ❌ corrupts the JSON-RPC channel
console.log('Server started');
Enter fullscreen mode Exit fullscreen mode
This trips up almost everyone the first time. Now you know.
Project Setup
An MCP server runs as an independent process — it doesn't share a process with your existing web server. Create a dedicated package:
{
"name": "@ts-ai/mcp-server",
"version": "1.0.0",
"type": "module",
"bin": {
"ts-ai-mcp": "./dist/index.js"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^3.23.0"
}
}
Enter fullscreen mode Exit fullscreen mode
The bin field matters if you plan to publish to npm — users can then run your server with npx ts-ai-mcp without installing it globally.
The Minimal Server
The SDK handles protocol details: JSON-RPC serialization, the handshake, capability negotiation, message routing. You only need to register handlers and return data.
// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = new McpServer({
name: 'ts-ai-mcp-server',
version: '1.0.0',
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP Server started');
Enter fullscreen mode Exit fullscreen mode
server.connect() is non-blocking — it takes over stdin/stdout and returns immediately. The process stays alive via Node.js's event loop as long as stdin is open. No need for setInterval or other keep-alive tricks.
The SDK also infers capabilities automatically from which registration methods you call (server.tool(), server.resource(), server.prompt()). No manual capability declaration needed.
Tools: The Core Primitive
Tools are what the LLM can call to take action. The SDK merges tool declaration and execution into a single server.tool() call:
// src/handlers/tools.ts
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
export function registerToolHandlers(server: McpServer) {
server.tool(
'query_knowledge_base',
'Search internal documents and return relevant chunks with source citations.',
{
question: z.string().describe('The question to query'),
limit: z.number().optional().describe('Max results, default 5'),
},
async ({ question, limit = 5 }) => {
try {
const response = await fetch('http://localhost:3000/api/rag/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, limit }),
});
if (!response.ok) throw new Error(`RAG service error: ${response.status}`);
const result = await response.json() as {
answer: string;
citations: Array<{ documentName: string; content: string }>;
};
const citations = result.citations
.map((c, i) => `[Source ${i + 1}] ${c.documentName}\n${c.content}`)
.join('\n\n---\n\n');
return {
content: [{
type: 'text' as const,
text: `Answer: ${result.answer}\n\nReferences:\n${citations}`,
}],
};
} catch (error) {
if (error instanceof McpError) throw error;
// Return error to the LLM instead of throwing —
// lets the LLM decide to retry, try another tool, or inform the user
return {
content: [{
type: 'text' as const,
text: `Tool failed: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
);
}
Enter fullscreen mode Exit fullscreen mode
The error handling pattern is intentional. When a tool fails, returning isError: true with a message is almost always better than throwing McpError. The LLM can read the error and decide what to do next. Throwing a protocol error just breaks the conversation.
Resources: Context Injection
Resources let the host inject content into the LLM's context. Two patterns: static URIs for fixed resources, URI templates for dynamic ones.
// src/handlers/resources.ts
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
export function registerResourceHandlers(server: McpServer) {
// Static resource: fixed URI, always available
server.resource(
'knowledge-base-stats',
'knowledge-base://stats',
{ mimeType: 'application/json' },
async (uri) => {
const response = await fetch('http://localhost:3000/api/documents');
const docs = await response.json() as Array<{ status: string; chunkCount: number }>;
const stats = {
totalDocuments: docs.length,
readyDocuments: docs.filter(d => d.status === 'ready').length,
totalChunks: docs.reduce((s, d) => s + (d.chunkCount ?? 0), 0),
};
return {
contents: [{ uri: uri.toString(), mimeType: 'application/json', text: JSON.stringify(stats, null, 2) }],
};
},
);
// Dynamic resource: one URI per document, listed at runtime
server.resource(
'knowledge-base-document',
new ResourceTemplate('knowledge-base://document/{documentId}', {
list: async () => {
const response = await fetch('http://localhost:3000/api/documents');
const docs = await response.json() as Array<{ id: string; filename: string; status: string }>;
return {
resources: docs
.filter(d => d.status === 'ready')
.map(d => ({
uri: `knowledge-base://document/${d.id}`,
name: d.filename,
mimeType: 'text/plain' as const,
})),
};
},
}),
{ mimeType: 'text/plain' },
async (uri, { documentId }) => {
const response = await fetch(`http://localhost:3000/api/documents/${documentId}/content`);
if (!response.ok) {
throw new McpError(ErrorCode.InvalidRequest, `Document not found: ${documentId}`);
}
return {
contents: [{ uri: uri.toString(), mimeType: 'text/plain', text: await response.text() }],
};
},
);
}
Enter fullscreen mode Exit fullscreen mode
The URI scheme (knowledge-base://) is yours to define. A semantic prefix avoids conflicts with resources from other MCP servers running in the same host.
Prompts: Reusable Instruction Templates
Prompts encapsulate multi-step instructions that would otherwise be written out every time. The key difference from a system prompt: they accept parameters and return a full messages array the LLM acts on.
// src/handlers/prompts.ts
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerPromptHandlers(server: McpServer) {
server.prompt(
'knowledge_query',
'Answer a question from the knowledge base with citations.',
{
question: z.string(),
format: z.enum(['detailed', 'brief']).optional(),
},
async ({ question, format = 'detailed' }) => {
const formatInstruction = format === 'brief'
? 'Answer concisely in 2–3 sentences.'
: 'Answer in detail with [Source N] citations and a reference list at the end.';
return {
messages: [{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Use the query_knowledge_base tool to answer:\n\n${question}\n\n${formatInstruction}`,
},
}],
};
},
);
}
Enter fullscreen mode Exit fullscreen mode
The messages array can include tool invocation instructions. The LLM follows them automatically — no manual guidance needed from the user each time.
One caveat: how prompts are triggered varies by host. Claude Desktop doesn't expose slash commands for MCP prompts. Claude Code does. Check your target host's documentation.
Wiring It Together
// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { registerToolHandlers } from './handlers/tools.js';
import { registerResourceHandlers } from './handlers/resources.js';
import { registerPromptHandlers } from './handlers/prompts.js';
const server = new McpServer({ name: 'ts-ai-mcp-server', version: '1.0.0' });
registerToolHandlers(server);
registerResourceHandlers(server);
registerPromptHandlers(server);
server.server.onerror = (error) => console.error('[MCP Error]', error);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('✅ ts-ai-mcp-server started');
process.on('SIGINT', async () => {
await server.close();
process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode
Debugging Without a Host
You don't need Claude Desktop to test your server. MCP Inspector is better for development:
npx @modelcontextprotocol/inspector node dist/index.js
Enter fullscreen mode Exit fullscreen mode
Inspector spawns your server as a child process and opens a browser UI where you can call tools, read resources, and fetch prompt templates interactively — plus see the raw JSON-RPC frames. No host configuration needed.
When you're ready to validate in Claude Desktop, add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"ts-ai": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/src/index.ts"]
}
}
}
Enter fullscreen mode Exit fullscreen mode
Using tsx means no build step during development. After making changes, restart Claude Desktop.
HTTP Transport for Remote Access
stdio is local-only. For remote or multi-user scenarios, use StreamableHTTPServerTransport. The critical difference: you must maintain a sessionId → transport map yourself, because MCP is stateful — all requests after initialize must route to the same transport instance.
// src/server-http.ts
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { registerToolHandlers } from './handlers/tools.js';
import { registerResourceHandlers } from './handlers/resources.js';
import { registerPromptHandlers } from './handlers/prompts.js';
const app = express();
app.use(express.json());
const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer }>();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && sessions.has(sessionId)) {
const { transport } = sessions.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
// New session: create a fresh server + transport pair
const server = new McpServer({ name: 'ts-ai-mcp-server', version: '1.0.0' });
registerToolHandlers(server);
registerResourceHandlers(server);
registerPromptHandlers(server);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => sessions.set(id, { transport, server }),
});
transport.onclose = () => {
const id = transport.sessionId;
if (id) {
sessions.get(id)?.server.close();
sessions.delete(id);
}
};
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3001, () => console.error('MCP HTTP server on port 3001'));
Enter fullscreen mode Exit fullscreen mode
If you see examples using /sse and /messages endpoints, that's the old SDK pattern. StreamableHTTPServerTransport merges both into a single POST /mcp endpoint.
Three Things That Stay Surprising
server.connect() is non-blocking. The process doesn't block at that line. Message handling runs asynchronously via the SDK's internal event loop.
Capability declaration is automatic. The SDK infers capabilities from which methods you call. No manual capabilities: { tools: {}, resources: {} } config needed in most cases.
Not all hosts support all MCP features. Resource subscriptions and Prompt slash commands work in Claude Code but not Claude Desktop. Know your target host before building against a feature.
This article is adapted from Chapter 19 of AI Engineering with TypeScript — A Comprehensive Guide to Building AI Agents at Leanpub or Amazon
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.