Skip to content

MCP Server Development with TypeScript SDK

MCP Server Development - with TypeScript SDK

Section titled “MCP Server Development - with TypeScript SDK”

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:

Terminal window
# Using npm
npm install @modelcontext/mcp-sdk
# Or using yarn
yarn add @modelcontext/mcp-sdk

Initializing 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();

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 expected
interface 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.
}
);

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' });
}
})();
}
);

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 tool
interface 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
}
};
}
);

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 handlers
interface 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();
  • Clear Capability Definitions: Accurately define your server’s capabilities during initialization. This helps clients understand what your server offers.
  • Strong Typing: Use TypeScript interfaces for ParamsType and PayloadType in your handlers. This improves code clarity, maintainability, and catches errors at compile time.
  • Input Validation: Always validate parameters received in the context.params object within your handlers. Return meaningful MCPErrorData (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 MCPErrorData objects 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.stream API (write(), end(), error()) to send data incrementally.
  • Contextual Information: Utilize the context object (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.