Building Custom MCP Servers & Clients in TypeScript: Tools, Resources & Prompts Architecture

Table of Contents(25 sections)
The Model Context Protocol (MCP) defines a standardized interface for Large Language Models (LLMs) to interact with external tools, access dynamic resources, and receive structured context. This document details the construction of custom MCP servers and clients using TypeScript, focusing on @modelcontextprotocol/sdk, transport mechanisms, security, and prompt architecture.
Understanding MCP Transports: Stdio vs. SSE
MCP supports multiple transport layers. The primary mechanisms for custom implementations are Stdio and Server-Sent Events (SSE). Each presents distinct advantages and operational characteristics.
Stdio Transport
Stdio (Standard Input/Output) is a synchronous, request-response mechanism. It's ideal for local execution environments, CLI tools, or scenarios where a single, persistent process manages the LLM interaction. The client sends a JSON-RPC request over stdin, and the server responds over stdout.
Advantages:
- Simplicity for local development and debugging.
- Low overhead for single-shot interactions.
- Direct integration with process execution models.
Disadvantages:
- Limited scalability for concurrent requests.
- Not suitable for web-based or distributed systems without significant orchestration.
- Error handling can be more complex due to process lifecycle management.
SSE Transport
SSE (Server-Sent Events) provides a unidirectional, event-streaming mechanism over HTTP. It's well-suited for web-based clients, long-lived connections, and scenarios requiring continuous updates or asynchronous tool execution. The client establishes an HTTP connection, and the server pushes events.
Advantages:
- Scalable for multiple concurrent clients.
- Native support for web environments.
- Asynchronous event delivery, suitable for streaming tool outputs or resource updates.
- Robust error handling and reconnection mechanisms inherent to HTTP.
Disadvantages:
- Requires an HTTP server infrastructure.
- More complex setup than Stdio for basic local use cases.
- Unidirectional; client-to-server communication requires separate HTTP requests (e.g., POST).
Building a Custom MCP Server with @modelcontextprotocol/sdk
The @modelcontextprotocol/sdk provides the foundational components for implementing MCP servers and clients in TypeScript. We will focus on building a server that exposes tools and dynamic resources.
Core Server Architecture
An MCP server typically involves:
- Transport Layer: Handling incoming requests and outgoing responses (Stdio or SSE).
- Tool Handlers: Functions that execute specific operations requested by the LLM.
- Resource Providers: Mechanisms to expose dynamic data or schemas to the LLM.
- Security & Validation: Ensuring requests are authorized and inputs are sanitized.
Let's construct a basic SSE-based MCP server.
// src/server.ts
import {
createMCPSSEServer,
MCPTool,
MCPResource,
MCPContext,
MCPError,
MCPErrorCode,
} from '@modelcontextprotocol/sdk';
import express from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import { z } from 'zod'; // For schema validation
// --- 1. Define Tool Handlers ---
// Tools are functions the LLM can call. They must have a schema for arguments.
// Tool: `searchWeb`
const searchWebArgsSchema = z.object({
query: z.string().describe('The search query to execute.'),
numResults: z.number().int().min(1).max(10).optional().default(3).describe('Number of search results to return.'),
});
const searchWebTool: MCPTool<typeof searchWebArgsSchema> = {
name: 'searchWeb',
description: 'Performs a web search and returns relevant results.',
argsSchema: searchWebArgsSchema,
handler: async (args, context: MCPContext) => {
console.log(`[Tool] searchWeb called with query: "${args.query}", results: ${args.numResults}`);
// In a real scenario, this would call an external search API.
// For demonstration, we return mock data.
const mockResults = [
{ title: `Result 1 for "${args.query}"`, url: `https://example.com/search/${args.query}/1` },
{ title: `Result 2 for "${args.query}"`, url: `https://example.com/search/${args.query}/2` },
{ title: `Result 3 for "${args.query}"`, url: `https://example.com/search/${args.query}/3` },
];
return mockResults.slice(0, args.numResults);
},
};
// Tool: `createFile`
const createFileArgsSchema = z.object({
path: z.string().describe('The full path including filename where the file should be created.'),
content: z.string().describe('The content to write into the file.'),
overwrite: z.boolean().optional().default(false).describe('Whether to overwrite the file if it already exists.'),
});
const createFileTool: MCPTool<typeof createFileArgsSchema> = {
name: 'createFile',
description: 'Creates a new file with specified content at a given path.',
argsSchema: createFileArgsSchema,
handler: async (args, context: MCPContext) => {
console.log(`[Tool] createFile called: ${args.path}, overwrite: ${args.overwrite}`);
// Simulate file system interaction. In production, use `fs.promises`.
if (!args.overwrite && args.path.includes('existing-file.txt')) { // Mock existing file
throw new MCPError(MCPErrorCode.RESOURCE_CONFLICT, `File already exists at ${args.path}. Set overwrite to true.`);
}
return { success: true, message: `File '${args.path}' created/updated.` };
},
};
// --- 2. Define Dynamic Resources ---
// Resources provide structured data or schemas that the LLM can query.
// Resource: `projectFiles`
const projectFilesResource: MCPResource = {
name: 'projectFiles',
description: 'Lists files and directories within the current project context.',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name of the file or directory.' },
type: { type: 'string', enum: ['file', 'directory'], description: 'Type of the entry.' },
size: { type: 'number', description: 'Size in bytes (for files).' },
lastModified: { type: 'string', format: 'date-time', description: 'Last modification timestamp.' },
},
required: ['name', 'type'],
},
},
// The `get` method is called when the LLM requests this resource.
get: async (context: MCPContext) => {
console.log('[Resource] projectFiles requested.');
// In a real application, this would scan the project directory.
return [
{ name: 'src/', type: 'directory', lastModified: new Date().toISOString() },
{ name: 'src/server.ts', type: 'file', size: 4096, lastModified: new Date().toISOString() },
{ name: 'package.json', type: 'file', size: 1024, lastModified: new Date().toISOString() },
{ name: 'README.md', type: 'file', size: 2048, lastModified: new Date().toISOString() },
];
},
};
// --- 3. Initialize MCP SSE Server ---
const app = express();
app.use(cors()); // Enable CORS for web clients
app.use(bodyParser.json()); // Parse JSON request bodies
const mcpServer = createMCPSSEServer({
tools: [searchWebTool, createFileTool],
resources: [projectFilesResource],
// Optional: Implement an authentication/authorization middleware
// This example uses a simple token check.
authenticate: async (token: string | undefined) => {
if (token === 'my-secret-api-key') {
return { userId: 'dev-user', roles: ['admin'] }; // Return user context
}
throw new MCPError(MCPErrorCode.UNAUTHENTICATED, 'Invalid or missing authentication token.');
},
// Optional: Custom error handler for tool/resource execution
onError: (error: Error) => {
console.error('MCP Server Error:', error);
// You might want to log this to a monitoring system.
},
});
// Mount the MCP server routes
app.use('/mcp', mcpServer.router);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`MCP SSE Server listening on http://localhost:${PORT}/mcp`);
console.log(`Tools available: ${mcpServer.getToolNames().join(', ')}`);
console.log(`Resources available: ${mcpServer.getResourceNames().join(', ')}`);
});
// To run this:
// 1. npm init -y
// 2. npm install @modelcontextprotocol/sdk express body-parser cors zod
// 3. ts-node src/server.ts (or compile and run with node)
Exposing Tool Handlers
Tool handlers are asynchronous functions that execute logic based on arguments provided by the LLM. The argsSchema property, typically defined using zod, is crucial for input validation and for the LLM to understand the tool's parameters.
// Example tool handler structure
const myTool: MCPTool<typeof myArgsSchema> = {
name: 'myTool',
description: 'Does something useful.',
argsSchema: myArgsSchema,
handler: async (args, context) => {
// `args` is type-safe due to `myArgsSchema`
// `context` contains information about the current MCP session (e.g., authentication)
console.log('Tool called with:', args);
// Perform operations, e.g., API calls, file system access
return { status: 'success', data: 'some result' };
},
};
Defining Dynamic Resource Schemas
Resources allow the LLM to query dynamic information. The schema property describes the structure of the data returned by the get method. This schema is provided to the LLM, enabling it to understand and query the resource effectively.
// Example resource structure
const myResource: MCPResource = {
name: 'myResource',
description: 'Provides dynamic data.',
schema: {
type: 'object',
properties: {
id: { type: 'string' },
value: { type: 'number' },
},
required: ['id', 'value'],
},
get: async (context) => {
// Fetch or generate dynamic data
return [{ id: 'item1', value: 123 }, { id: 'item2', value: 456 }];
},
};
Integrating with LLM Clients: Claude Code, Cursor, and Custom Agents
MCP's strength lies in its interoperability.
Claude Code & Cursor Integration
Anthropic's Claude Code and Cursor IDEs are designed to consume MCP servers. When you start an MCP server (especially an SSE one), these clients can be configured to connect to its endpoint. They will automatically discover the exposed tools and resources, making them available for the LLM to use within the IDE context.
For example, in Cursor, you might configure an MCP endpoint in its settings, pointing to http://localhost:3000/mcp. The IDE then acts as an MCP client, forwarding LLM requests to your server and displaying the results.
Custom Agent Runtimes
For custom agent runtimes, you'll use the @modelcontextprotocol/sdk client.
// src/client.ts
import { createMCPClient, MCPClientOptions } from '@modelcontextprotocol/sdk';
async function runMCPClient() {
const clientOptions: MCPClientOptions = {
// For SSE, specify the base URL of your MCP server
// For Stdio, you'd pass `process.stdin` and `process.stdout`
transport: {
type: 'sse',
baseUrl: 'http://localhost:3000/mcp',
headers: {
'Authorization': 'Bearer my-secret-api-key', // Include authentication token
},
},
// Optional: Logger for debugging
logger: console,
};
const client = createMCPClient(clientOptions);
try {
// --- 1. Get available tools and resources ---
const tools = await client.getTools();
console.log('Available Tools:', tools.map(t => t.name));
const resources = await client.getResources();
console.log('Available Resources:', resources.map(r => r.name));
// --- 2. Call a tool ---
console.log('\nCalling searchWeb tool...');
const searchResults = await client.callTool('searchWeb', { query: 'MCP protocol best practices', numResults: 2 });
console.log('Search Results:', searchResults);
// --- 3. Get a resource ---
console.log('\nGetting projectFiles resource...');
const projectFiles = await client.getResource('projectFiles');
console.log('Project Files:', projectFiles);
// --- 4. Handle errors ---
console.log('\nCalling createFile tool with conflict...');
try {
await client.callTool('createFile', { path: 'existing-file.txt', content: 'new content' });
} catch (error: any) {
console.error('Error calling createFile:', error.message);
}
console.log('\nCalling createFile tool with overwrite...');
const createResult = await client.callTool('createFile', { path: 'new-file.txt', content: 'Hello, MCP!', overwrite: true });
console.log('Create File Result:', createResult);
} catch (error) {
console.error('MCP Client Error:', error);
}
}
runMCPClient();
// To run this:
// 1. Ensure the server (src/server.ts) is running.
// 2. npm install @modelcontextprotocol/sdk
// 3. ts-node src/client.ts
Context Prompt Templates
MCP doesn't directly define prompt templates, but it provides the structured information necessary for an LLM to construct effective prompts. The LLM receives:
- Tool Schemas: JSON Schema definitions for each tool, including
name,description, andargsSchema. - Resource Schemas: JSON Schema definitions for each resource, including
name,description, and the structure of its data. - Resource Data: The actual data returned when a resource is
getby the LLM.
A well-designed prompt template for an LLM using MCP might look like this (conceptual, LLM-specific syntax):
You are an AI assistant with access to the following tools and resources:
<tools>
{{#each tools}}
<tool_code>
{
"name": "{{this.name}}",
"description": "{{this.description}}",
"parameters": {{json this.argsSchema}}
}
</tool_code>
{{/each}}
</tools>
<resources>
{{#each resources}}
<resource_code>
{
"name": "{{this.name}}",
"description": "{{this.description}}",
"schema": {{json this.schema}}
}
</resource_code>
{{/each}}
</resources>
<context>
{{#if currentResourceData}}
<resource_data name="{{currentResourceData.name}}">
{{json currentResourceData.data}}
</resource_data>
{{/if}}
</context>
Your goal is to assist the user by leveraging these capabilities.
If you need to perform an action, use the `<call_tool>` tag.
If you need to retrieve information, use the `<get_resource>` tag.
If you have retrieved information from a resource, it will appear in the `<context>` block.
User: {{user_query}}
This template dynamically injects the MCP server's capabilities, allowing the LLM to reason about available actions and data structures.
Security Boundaries and Input Sanitization
Security is paramount when exposing tools to an LLM.
-
Authentication & Authorization:
- Authentication: Use API keys, OAuth tokens, or JWTs to verify the client's identity. The
authenticatefunction increateMCPSSEServeris the entry point for this. - Authorization: Based on the authenticated user's roles or permissions, restrict access to certain tools or resources. This logic belongs within the tool/resource
handleror a middleware.
typescript// Example: Authorization within a tool handler const restrictedTool: MCPTool<typeof someSchema> = { name: 'restrictedAction', description: 'Only for admins.', argsSchema: someSchema, handler: async (args, context: MCPContext) => { if (!context.user || !context.user.roles.includes('admin')) { throw new MCPError(MCPErrorCode.PERMISSION_DENIED, 'Only administrators can perform this action.'); } // ... execute admin action }, }; - Authentication: Use API keys, OAuth tokens, or JWTs to verify the client's identity. The
-
Input Validation (Schema Enforcement):
- The
argsSchemafor tools andschemafor resources are critical. The@modelcontextprotocol/sdkautomatically validates incoming arguments against these schemas. - Use robust schema validation libraries like
zodorJoito define strict types, ranges, and patterns.
- The
-
Output Sanitization:
- Ensure that any data returned by tools or resources does not contain sensitive information unless explicitly intended and authorized.
- Filter or redact data before returning it to the LLM if it might be exposed to unauthorized parties or used in unintended ways.
-
Least Privilege:
- Design tools to perform only the necessary actions. Avoid creating "god" tools that can do anything.
- If a tool interacts with external systems (databases, APIs, file systems), ensure the underlying credentials or permissions granted to the server process are as restrictive as possible.
-
Rate Limiting & Abuse Prevention:
- Implement rate limiting on your MCP server endpoints to prevent denial-of-service attacks or excessive resource consumption by misbehaving agents.
- Monitor tool usage for unusual patterns.
Production Gotchas & Troubleshooting
1. MCPError: UNAUTHENTICATED or PERMISSION_DENIED
Failure Mode: Client receives authentication or permission errors. Fix:
- Authentication: Verify the
Authorizationheader in your client request matches the expected token in yourauthenticatefunction. Ensure the token is correctly passed. - Authorization: Check the
context.userobject within your tool/resource handlers. Ensure the user has the necessary roles or permissions for the requested action. Debug the logic in yourauthenticateand handler functions.
2. MCPError: INVALID_ARGUMENTS
Failure Mode: Tool calls fail due to argument mismatch. Fix:
- The LLM provided arguments that do not conform to your
argsSchema. - Server-side: Double-check your
zodschema definitions. Are all required fields present? Are types correct (string, number, boolean)? Are there anymin/maxconstraints being violated? - Client-side (LLM): If you're building a custom agent, ensure your agent's tool-calling logic correctly extracts and formats arguments according to the schema. If using an IDE, the LLM might be hallucinating arguments; refine your prompt or tool descriptions.
3. MCPError: TOOL_EXECUTION_FAILED or RESOURCE_GET_FAILED
Failure Mode: Tool/resource handler logic throws an unhandled exception. Fix:
- Server-side: Implement robust
try...catchblocks within your tool and resource handlers. Catch specific errors (e.g., network errors from external APIs, file system errors) and return meaningfulMCPErrorcodes or messages. - Logging: Ensure your
onErrorcallback increateMCPSSEServeris logging these errors effectively. Use a structured logger.
4. SSE Connection Drops or No Events
Failure Mode: Client doesn't receive events, or the connection is unstable. Fix:
- CORS: Ensure your Express server has
cors()middleware enabled if your client is on a different origin. - Proxy/Firewall: Check if any proxies or firewalls are interfering with long-lived HTTP connections or SSE event streams.
- Server Heartbeat: The
@modelcontextprotocol/sdkhandles SSE heartbeats, but ensure your server isn't being aggressively terminated by a load balancer or container orchestrator due to perceived inactivity. - Client Reconnection: Ensure your client-side SSE implementation (or the SDK's client) has robust auto-reconnection logic.
5. Performance Degradation with Stdio
Failure Mode: Slow responses or blocking behavior with Stdio transport. Fix:
- Stdio is inherently synchronous per process. If you need concurrency, you must manage multiple server processes (e.g., using a process manager like PM2) or switch to SSE.
- Ensure your tool handlers are truly asynchronous and don't block the event loop.
Architecture & Transport Comparison
| Feature | Stdio Transport | SSE Transport |
|---|---|---|
| Communication | Bidirectional (request/response) | Unidirectional (server-to-client stream) |
| Protocol | JSON-RPC over stdin/stdout | HTTP/1.1 (event-stream) |
| Concurrency | Single request per process | Multiple concurrent clients |
| Scalability | Low (requires process management) | High (standard HTTP server scaling) |
| Use Case | Local CLI tools, single-process agents | Web clients, distributed agents, streaming updates |
| Setup Complexity | Low (basic process I/O) | Moderate (HTTP server, routing, CORS) |
| Error Handling | Process exit codes, JSON-RPC errors | HTTP status codes, SSE event errors, client reconnect |
| Authentication | Out-of-band (e.g., environment variables) | HTTP headers (Bearer tokens, cookies) |
| Network Overhead | Low (raw JSON) | Moderate (HTTP headers, framing) |
Frequently Asked Questions
1. Can I use MCP with other LLMs besides Anthropic's Claude?
Yes. While Anthropic pioneered MCP, the protocol is open and designed for interoperability. Any LLM or agent runtime capable of parsing JSON Schema for tool/resource definitions and making HTTP requests (for SSE) or managing process I/O (for Stdio) can integrate with an MCP server. You would need to adapt the LLM's prompt engineering to effectively utilize the MCP-provided schemas.
2. How do I handle long-running tool executions?
For long-running tools, the handler should ideally return quickly with a status indicating the operation has started, and then use a separate mechanism (e.g., webhooks, polling, or another SSE channel) to notify the client/LLM of completion or progress. The current MCP specification primarily supports synchronous tool execution where the handler's return value is the final result. For truly asynchronous, long-running tasks, consider:
- A tool that initiates the task and returns a
job_id. - Another tool
getJobStatus(job_id)that the LLM can poll. - Or, if your client supports it, push updates via a separate SSE stream.
3. Is it possible to define custom error codes beyond MCPErrorCode?
The MCPErrorCode enum provides a standard set of errors. While you can technically return custom error messages within an MCPError, it's generally best practice to map your internal application errors to the closest MCPErrorCode to maintain protocol consistency. If a truly unique error condition arises, you can use MCPErrorCode.INTERNAL_ERROR with a detailed message.
4. How do I manage state across multiple tool calls within an MCP session?
The MCPContext object passed to tool and resource handlers is designed for this. You can attach session-specific data to it. For SSE, the server maintains a connection per client, allowing you to associate state with that connection. For Stdio, state management is typically external or passed explicitly in subsequent requests if the process is short-lived. For example, you might store a session_id in the context and use it to retrieve data from a backend store.
5. What are the best practices for versioning my MCP server's API?
Treat your MCP server's tools and resources like any other API.
- Schema Evolution: Make additive changes to schemas (e.g., adding optional fields) to maintain backward compatibility.
- New Tools/Resources: Introduce new tools or resources without breaking existing clients.
- Breaking Changes: For breaking changes (e.g., renaming a tool, removing a required field), consider:
- Deploying a new version of the server on a different base path (e.g.,
/mcp/v1,/mcp/v2). - Using content negotiation (less common for MCP).
- Providing clear deprecation warnings.
- Deploying a new version of the server on a different base path (e.g.,
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Building a Custom MCP Client: Connecting Any LLM to Multiple Model Context Protocol Servers
Comprehensive guide covering building a custom mcp client: connecting any llm to multiple model context protocol servers with production-grade architecture and code examples.
Read more
Building Custom MCP Servers with TypeScript: Complete Architecture & Deployment Guide
Comprehensive guide covering building custom mcp servers with typescript: complete architecture & deployment guide with production-grade architecture and code examples.
Read more
Building Production MCP Servers on Cloudflare Workers, KV & Durable Objects
Comprehensive guide covering building production mcp servers on cloudflare workers, kv & durable objects with production-grade architecture and code examples.
Read more