MCP Server Development with TypeScript SDK
MCP Server Development - with TypeScript SDK
Section titled “MCP Server Development - with TypeScript SDK”Introduction and Setup
Section titled “Introduction and Setup”This tutorial walks you through building MCP (Model Context Protocol) servers using the TypeScript SDK. MCP servers expose Resources (data sources), Prompts (interfaces to generative models), and Tools (callable functions) to MCP clients.
Developing MCP servers with the TypeScript SDK benefits from strong typing, the async/await model for handling concurrent requests efficiently, and a clear structure for defining server capabilities.
Prerequisites:
- Node.js (version 18.x or later recommended).
- A package manager like npm or yarn.
Installation:
Install the MCP TypeScript SDK:
# Using npmnpm install @modelcontext/mcp-sdk
# Or using yarnyarn add @modelcontext/mcp-sdkInitializing the Server: The core of an MCP server is an MCPServer instance, created using createServer. You then define its capabilities and start it listening for client connections.
import { createServer, MCPServer, ResourceContext, PromptContext, ToolContext, MCPErrorData } from '@modelcontext/mcp-sdk';
async function initializeAndStartServer() { console.log('Initializing TypeScript MCP Server...'); const server: MCPServer = createServer({ serverId: 'my-first-ts-mcp-server', serverDescription: 'A demonstration MCP server built with TypeScript.', // Define what this server offers. Clients can see this. capabilities: { resources: [ { id: 'serverTime', description: 'Provides the current server time.' }, // More resources can be declared here ], prompts: [ { id: 'simpleEcho', description: 'Echoes back the provided text.' }, // More prompts can be declared here ], tools: [ { id: 'stringUtil', description: 'Performs operations on strings.' }, // More tools can be declared here ] } });
// Handlers for resources, prompts, and tools will be added in subsequent sections. // For now, let's add a placeholder for where they'd go. addServerHandlers(server);
const port = 8080; const host = '0.0.0.0'; // Listen on all available network interfaces
try { await server.listen({ port, host }); console.log(`TypeScript MCP Server listening on ws://${host}:${port}${server.path}`); console.log('Registered capabilities:', JSON.stringify(server.getCapabilities(), null, 2)); } catch (error) { console.error('Failed to start MCP server:', error); process.exit(1); }
// Handle graceful shutdown (optional but good practice) process.on('SIGINT', async () => { console.log('\nGracefully shutting down MCP server...'); await server.close(); console.log('Server closed.'); process.exit(0); });
return server;}
function addServerHandlers(server: MCPServer) { // We will populate this function in the following sections. console.log('Placeholder for adding resource, prompt, and tool handlers.');}
// To run the server (handlers will be defined later):// initializeAndStartServer();Exposing Resources
Section titled “Exposing Resources”Resources are data providers. A resource handler is an async function that receives a ResourceContext object (containing parameters from the client, client ID, etc.) and returns data. You can return a single payload or stream data back to the client.
Use server.addResource<ParamsType, PayloadType>(resourceId, handler) to define a resource. ParamsType is the expected type of parameters from the client, and PayloadType is the type of the data you’ll send back in the payload field.
// In your addServerHandlers function or similar setup location:// server: MCPServer
// Example 1: A simple resource returning current server time (single response)interface ServerTimeParams {} // No parameters expectedinterface ServerTimePayload { currentTime: string; timezone: string; }
server.addResource<ServerTimeParams, ServerTimePayload>('serverTime', async (context: ResourceContext<ServerTimeParams>) => { console.log(`Resource 'serverTime' requested by client: ${context.clientId}`); return { payload: { currentTime: new Date().toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone } }; });
// Example 2: A resource that streams lines from a mock file (streaming response)interface FileStreamParams { filePath: string; lines?: number }interface FileLinePayload { line: string; lineNumber: number }
server.addResource<FileStreamParams, FileLinePayload>('fileStream', async (context: ResourceContext<FileStreamParams>) => { const { filePath, lines = 5 } = context.params; console.log(`Resource 'fileStream' for '${filePath}' requested by: ${context.clientId}`);
// This is a streaming resource, so we don't return directly. // We use context.stream.write() and context.stream.end(). (async () => { // IIFE to allow async operations within the synchronous handler registration try { for (let i = 0; i < lines; i++) { // Simulate reading lines with a delay await new Promise(resolve => setTimeout(resolve, 200)); const fileLine = `Line ${i + 1} from ${filePath} (simulated)`; context.stream.write({ payload: { line: fileLine, lineNumber: i + 1 } }); } context.stream.end(); console.log(`Finished streaming for 'fileStream' on ${filePath}`); } catch (error) { console.error('Error during fileStream:', error); context.stream.error({ code: 500, message: 'Streaming failed' }); } })(); // Note: For streaming resources, the main handler function itself doesn't return the value directly. // It initiates the stream operations. });Exposing Prompts
Section titled “Exposing Prompts”Prompts typically involve sending input to a model (like an LLM) and getting a generated response. A prompt handler receives a PromptContext and can return a single payload or stream responses (e.g., token by token).
Use server.addPrompt<ParamsType, PayloadType>(promptId, handler).
// In your addServerHandlers function or similar setup location:// server: MCPServer
// Example 1: A simple echo prompt (single response)interface EchoParams { message: string; prefix?: string; }interface EchoPayload { echoedMessage: string; timestamp: string; }
server.addPrompt<EchoParams, EchoPayload>('simpleEcho', async (context: PromptContext<EchoParams>) => { const { message, prefix = 'Echo' } = context.params; console.log(`Prompt 'simpleEcho' for client ${context.clientId} with message: "${message}"`); if (!message) { throw { code: 400, message: 'Parameter "message" is required.' } as MCPErrorData; } return { payload: { echoedMessage: `${prefix}: ${message}`, timestamp: new Date().toISOString() } }; });
// Example 2: A mock LLM prompt that streams words (streaming response)interface GenerateTextParams { topic: string; wordCount?: number; }interface TextTokenPayload { token: string; isFinal?: boolean; }
server.addPrompt<GenerateTextParams, TextTokenPayload>('textGenerator', async (context: PromptContext<GenerateTextParams>) => { const { topic, wordCount = 10 } = context.params; console.log(`Prompt 'textGenerator' on topic '${topic}' for client ${context.clientId}`);
(async () => { try { const words = `Generating a short text about ${topic}: `.split(' '); for (const word of words) { await new Promise(resolve => setTimeout(resolve, 100)); // Simulate generation delay context.stream.write({ payload: { token: word + ' ' } }); } for (let i = 0; i < wordCount; i++) { await new Promise(resolve => setTimeout(resolve, 150)); context.stream.write({ payload: { token: `word${i + 1} ` } }); } context.stream.write({ payload: { token: '.', isFinal: true } }); context.stream.end(); console.log('Finished streaming for textGenerator'); } catch (err) { context.stream.error({ code: 500, message: 'Text generation failed' }); } })(); });Exposing Tools
Section titled “Exposing Tools”Tools are server-side functions that clients can execute. A tool handler receives a ToolContext and should return a result. Tools typically do not stream responses in the same way resources or prompts might, but their execution can be asynchronous.
Use server.addTool<ParamsType, PayloadType>(toolId, handler).
// In your addServerHandlers function or similar setup location:// server: MCPServer
// Example: A string utility toolinterface StringUtilParams { operation: 'uppercase' | 'lowercase' | 'length'; text: string; }interface StringUtilPayload { result: string | number; originalText: string; }
server.addTool<StringUtilParams, StringUtilPayload>('stringUtil', async (context: ToolContext<StringUtilParams>) => { const { operation, text } = context.params; console.log(`Tool 'stringUtil' (op: ${operation}) for client ${context.clientId} on text: "${text}"`);
if (!text || typeof text !== 'string') { throw { code: 400, message: 'Parameter "text" must be a non-empty string.' } as MCPErrorData; }
let operationResult: string | number; switch (operation) { case 'uppercase': operationResult = text.toUpperCase(); break; case 'lowercase': operationResult = text.toLowerCase(); break; case 'length': operationResult = text.length; break; default: throw { code: 400, message: `Unsupported operation: ${operation}. Valid operations are 'uppercase', 'lowercase', 'length'.` } as MCPErrorData; }
return { payload: { result: operationResult, originalText: text } }; });Full Server Example
Section titled “Full Server Example”Let’s combine these concepts into a single runnable server file. This server will expose one resource, one prompt, and one tool as defined above.
import { createServer, MCPServer, ResourceContext, PromptContext, ToolContext, MCPErrorData } from '@modelcontext/mcp-sdk';
// Define interfaces used by handlersinterface ServerTimeParams {}interface ServerTimePayload { currentTime: string; timezone: string; }interface EchoParams { message: string; prefix?: string; }interface EchoPayload { echoedMessage: string; timestamp: string; }interface StringUtilParams { operation: 'uppercase' | 'lowercase' | 'length'; text: string; }interface StringUtilPayload { result: string | number; originalText: string; }
function addActualServerHandlers(server: MCPServer) { // Resource: serverTime server.addResource<ServerTimeParams, ServerTimePayload>('serverTime', async (context: ResourceContext<ServerTimeParams>) => { console.log(`Resource 'serverTime' requested by client: ${context.clientId}`); return { payload: { currentTime: new Date().toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone } }; } );
// Prompt: simpleEcho server.addPrompt<EchoParams, EchoPayload>('simpleEcho', async (context: PromptContext<EchoParams>) => { const { message, prefix = 'Echo' } = context.params; console.log(`Prompt 'simpleEcho' for client ${context.clientId} with message: "${message}"`); if (!message) throw { code: 400, message: 'Parameter "message" is required.' } as MCPErrorData; return { payload: { echoedMessage: `${prefix}: ${message}`, timestamp: new Date().toISOString() } }; } );
// Tool: stringUtil server.addTool<StringUtilParams, StringUtilPayload>('stringUtil', async (context: ToolContext<StringUtilParams>) => { const { operation, text } = context.params; console.log(`Tool 'stringUtil' (op: ${operation}) for client ${context.clientId} on text: "${text}"`); if (!text || typeof text !== 'string') throw { code: 400, message: 'Parameter "text" must be a non-empty string.' } as MCPErrorData; let opResult: string | number; if (operation === 'uppercase') opResult = text.toUpperCase(); else if (operation === 'lowercase') opResult = text.toLowerCase(); else if (operation === 'length') opResult = text.length; else throw { code: 400, message: `Unsupported operation: ${operation}.` } as MCPErrorData; return { payload: { result: opResult, originalText: text } }; } ); console.log('All handlers added.');}
async function main() { console.log('Initializing TypeScript MCP Server V2...'); const server: MCPServer = createServer({ serverId: 'ts-mcp-server-full-example', serverDescription: 'TypeScript MCP server with resource, prompt, and tool.', capabilities: { resources: [{ id: 'serverTime', description: 'Provides the current server time.' }], prompts: [{ id: 'simpleEcho', description: 'Echoes back the provided text.' }], tools: [{ id: 'stringUtil', description: 'Performs operations on strings (uppercase, lowercase, length).' }] } });
addActualServerHandlers(server);
// Optional: Listen to server events server.on('clientConnected', (clientId) => console.log(`EVENT: Client ${clientId} connected.`)); server.on('clientDisconnected', (clientId) => console.log(`EVENT: Client ${clientId} disconnected.`)); server.on('error', (error, clientId) => console.error(`EVENT: Server error (Client: ${clientId || 'N/A'}):`, error));
const port = 8080; const host = '0.0.0.0'; try { await server.listen({ port, host }); console.log(`TypeScript MCP Server is live on ws://${host}:${port}${server.path}`); console.log('Press Ctrl+C to shut down.'); } catch (error) { console.error('Failed to start MCP server:', error); process.exit(1); }
process.on('SIGINT', async () => { console.log('\nSIGINT received. Shutting down server...'); await server.close(); console.log('Server has been shut down.'); process.exit(0); });}
main();Best Practices for TypeScript MCP Servers
Section titled “Best Practices for TypeScript MCP Servers”- Clear Capability Definitions: Accurately define your server’s
capabilitiesduring initialization. This helps clients understand what your server offers. - Strong Typing: Use TypeScript interfaces for
ParamsTypeandPayloadTypein your handlers. This improves code clarity, maintainability, and catches errors at compile time. - Input Validation: Always validate parameters received in the
context.paramsobject within your handlers. Return meaningfulMCPErrorData(e.g., code 400 for bad requests) if validation fails. - Asynchronous Handlers: Ensure all I/O operations or long-running tasks within handlers are asynchronous (
async/await) to prevent blocking the server’s event loop. - Error Handling: Implement robust error handling within handlers. Throw
MCPErrorDataobjects for errors that should be communicated back to the client. For unexpected server errors, log them thoroughly. - Streaming for Large Data: For resources or prompts that deal with large data or continuous updates, use the
context.streamAPI (write(),end(),error()) to send data incrementally. - Contextual Information: Utilize the
contextobject (e.g.,context.clientId,context.requestId) in handlers for logging, auditing, or custom logic based on the client. - Graceful Shutdown: Implement signal handlers (e.g., for
SIGINT) to close the server gracefully, ensuring all client connections are terminated properly and resources are released. - Logging: Add comprehensive logging for requests, errors, and server lifecycle events to aid in debugging and monitoring.