Why This Chapter Matters
In the evolving landscape of intelligent tools and AI agents, the ability to provide dynamic, structured, and relevant context is paramount. Without it, these tools operate in a vacuum, leading to generic, often unhelpful, outputs. This chapter is your guide to building the backbone of such a system: a Model Context Protocol (MCP) server.
An MCP server acts as the intelligent interface between your data sources and the consuming tools. It’s where you define what “context” means for your applications, how that context is retrieved and processed, and how it’s presented in a standardized way. Mastering MCP server development means you can empower intelligent agents with real-time, domain-specific understanding, moving from static, pre-trained models to dynamic, context-aware systems that genuinely understand your project, your team, or your user’s specific needs. This is about building the future of intelligent automation, not just consuming it.
Learning Objectives
By the end of this chapter, you will be able to:
- Understand the fundamental architecture and lifecycle of an MCP server.
- Initialize and configure an MCP server using the TypeScript SDK.
- Define and register custom context types and their schemas.
- Implement context resolvers to fetch and process data from various sources.
- Incorporate robust error handling and logging into your MCP server.
- Apply best practices for building performant and maintainable MCP server applications.
The Role of an MCP Server: A Data Bridge for Intelligence
At its core, an MCP server is a specialized API endpoint designed to serve structured context to MCP clients (which could be IDE extensions, AI agents, or other intelligent tools). It doesn’t just return raw data; it understands what context is being requested and how to transform underlying data into a format that’s immediately useful for intelligent processing.
Think of it as a smart data proxy. Instead of an AI agent needing to know how to query your database, parse your project files, or understand your API documentation, it simply asks the MCP server for project_structure or api_spec, and the server handles all the complexities, returning a standardized, well-defined context object.
MCP Server Request Lifecycle
The lifecycle of a typical MCP server request involves several key steps:
- Request Reception: An MCP client sends a
GET /context/{contextType}orPOST /context/{contextType}request to the server, often including query parameters or a request body with specific arguments (e.g.,path=src/index.ts). - Authentication/Authorization (Optional but Recommended): The server verifies the client’s identity and permissions to access the requested context.
- Context Type Identification: The server identifies the
contextTyperequested (e.g.,project_structure,dependency_graph,api_spec). - Argument Extraction: Any arguments provided by the client are parsed and validated.
- Resolver Invocation: The server dispatches the request to a registered “resolver” function specifically designed for that
contextType. - Data Retrieval & Processing: The resolver interacts with internal or external data sources (file system, database, external APIs) to fetch raw data. It then processes, transforms, and structures this data according to the context type’s schema.
- Context Object Construction: The processed data is packaged into a
ModelContextobject. - Response Generation: The
ModelContextobject is serialized and sent back to the client.
Here’s a simplified visual representation of this flow:
Core Components of an MCP Server
ModelContextServer: The central orchestrator. It listens for requests, routes them, and manages registered context types and resolvers. (Provided by the TypeScript SDK).- Context Types & Schemas: Definitions of the different kinds of context your server can provide, including their expected data structure (e.g., using JSON Schema).
- Context Resolvers: Functions that contain the actual logic for fetching, processing, and returning specific context types. These are where you connect to your data sources.
- Context Store (Optional): A mechanism for caching or persisting context, especially for frequently requested or expensive-to-generate contexts.
Initializing an MCP Server with the TypeScript SDK
The TypeScript SDK provides the ModelContextServer class, which simplifies the process of setting up an MCP server.
๐ Key Idea: An MCP server is a specialized HTTP server that serves structured context.
Basic Server Setup
Let’s start by creating a minimal server.
// server.ts
import { ModelContextServer } from "@modelcontextprotocol/typescript-sdk";
import { resolve } from "path";
// Define a simple context type for project information
interface ProjectInfoContext {
projectName: string;
rootDir: string;
description: string;
version: string;
}
// Create an instance of the MCP server
const server = new ModelContextServer({
port: 3000, // The port the server will listen on
// You can optionally add a base path for context endpoints
// basePath: "/mcp/v1",
});
// Register a context type and its resolver
server.registerContextType({
type: "project_info", // The unique identifier for this context
schema: { // A simple JSON Schema for validation and documentation
type: "object",
properties: {
projectName: { type: "string", description: "Name of the project" },
rootDir: { type: "string", description: "Root directory path" },
description: { type: "string", description: "Project description" },
version: { type: "string", description: "Project version" },
},
required: ["projectName", "rootDir", "description", "version"],
},
resolver: async (args: Record<string, any>): Promise<ProjectInfoContext> => {
// In a real application, you'd fetch this from package.json, a config file, etc.
console.log("Resolving project_info context with args:", args);
return {
projectName: "MyAwesomeProject",
rootDir: resolve("./"), // Current directory
description: "A sample project for MCP server demonstration.",
version: "1.0.0",
};
},
});
// Start the server
server.start()
.then(() => {
console.log(`MCP server started on port ${server.port}`);
console.log(`Access project_info context at: http://localhost:${server.port}/context/project_info`);
})
.catch((error) => {
console.error("Failed to start MCP server:", error);
process.exit(1);
});
// Handle graceful shutdown
process.on("SIGTERM", async () => {
console.log("SIGTERM received. Shutting down MCP server...");
await server.stop();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("SIGINT received. Shutting down MCP server...");
await server.stop();
process.exit(0);
});
To run this:
- Initialize a new Node.js project:
npm init -y - Install the SDK:
npm install @modelcontextprotocol/typescript-sdk - Install TypeScript and
ts-nodefor easy execution:npm install -D typescript ts-node @types/node - Create
tsconfig.json(minimal):{ "compilerOptions": { "target": "es2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist" }, "include": ["server.ts"] } - Run the server:
npx ts-node server.ts
Once running, you can access http://localhost:3000/context/project_info in your browser or with curl.
โก Quick Note: The resolver function receives args which are typically derived from query parameters for GET requests, or the request body for POST requests. This allows clients to ask for specific subsets or filtered context.
Worked Example: Dynamic Project File Context
Let’s build a more practical server that can provide a list of .ts files in a given directory, demonstrating how resolvers can interact with the file system and handle arguments.
Scenario
An intelligent agent needs to understand the TypeScript files within a specific sub-directory of a project to suggest refactorings or generate documentation.
Implementation Steps
- Define
ProjectFilesContextinterface and schema. - Implement a resolver that reads directory contents.
- Register the new context type with the server.
// server.ts (continued or new file)
import { ModelContextServer } from "@modelcontextprotocol/typescript-sdk";
import { readdir, stat } from "fs/promises"; // For async file system operations
import { join, resolve } from "path";
// --- Previous server setup and project_info context (omitted for brevity) ---
// Assume `server` instance is already created and `project_info` is registered.
// Define the interface for our new context type
interface ProjectFilesContext {
directory: string;
files: Array<{
name: string;
path: string;
size: number; // in bytes
isDir: boolean;
}>;
}
server.registerContextType({
type: "project_files",
schema: {
type: "object",
properties: {
directory: { type: "string", description: "The directory path scanned" },
files: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string", description: "File/directory name" },
path: { type: "string", description: "Absolute path" },
size: { type: "number", description: "Size in bytes (0 for directories)" },
isDir: { type: "boolean", description: "True if it's a directory" },
},
required: ["name", "path", "size", "isDir"],
},
},
},
required: ["directory", "files"],
},
resolver: async (args: { path?: string }): Promise<ProjectFilesContext> => {
const targetPath = args.path ? resolve(args.path) : resolve("./");
console.log(`Resolving project_files context for path: ${targetPath}`);
try {
const entries = await readdir(targetPath, { withFileTypes: true });
const filesInfo = await Promise.all(
entries
.filter(entry => entry.name.endsWith(".ts") || entry.isDirectory()) // Only .ts files or directories
.map(async (entry) => {
const entryPath = join(targetPath, entry.name);
let size = 0;
try {
if (entry.isFile()) {
const stats = await stat(entryPath);
size = stats.size;
}
} catch (statError) {
console.warn(`Could not get stats for ${entryPath}:`, statError);
// Gracefully handle stat errors, e.g., permission denied
}
return {
name: entry.name,
path: entryPath,
size: size,
isDir: entry.isDirectory(),
};
})
);
return {
directory: targetPath,
files: filesInfo,
};
} catch (error) {
console.error(`Error reading directory ${targetPath}:`, error);
// Re-throw or return an error object that the server can handle
throw new Error(`Failed to read directory: ${targetPath}. Error: ${error.message}`);
}
},
});
// ... (server start and shutdown logic)
Now, when you run the server, you can query:
http://localhost:3000/context/project_files(for the current directory)http://localhost:3000/context/project_files?path=src(if you have asrcdirectory)
โก Real-world insight: Resolvers often involve asynchronous operations like database queries, API calls, or file system access. Always use async/await and handle potential errors.
Error Handling and Robustness
A robust MCP server must gracefully handle errors, whether they originate from invalid client requests, internal resolver logic, or external data source failures.
โ ๏ธ What can go wrong:
- Invalid
contextType: Client requests a context that doesn’t exist. - Missing/Invalid Arguments: A resolver expects a
pathargument, but it’s missing or malformed. - External System Failure: A database is down, or an external API returns an error.
- Permissions Issues: The server cannot access a file or resource.
- Resolver Logic Bugs: Uncaught exceptions within your resolver code.
The ModelContextServer automatically handles unknown context types and basic HTTP errors. However, you are responsible for error handling within your resolvers.
Implementing Error Handling in Resolvers
In the project_files resolver above, we added a try...catch block. When an error occurs, we throw new Error(...). The ModelContextServer will catch this thrown error and convert it into an appropriate HTTP response (e.g., a 500 Internal Server Error) with an error message.
You can also return more specific error information if your client expects it:
// Example of a more detailed error response (if your schema allows)
// This requires your context schema to define an error structure, or you might throw
// a custom error class that the server can identify and process differently.
// For simplicity, the SDK will typically convert a thrown Error into a 500.
// If you want custom HTTP status codes or error bodies, you might need to
// implement custom error handling middleware if the SDK supports it, or
// return a specific error format from your resolver (if the context schema defines it).
// For now, throwing a standard Error is the most direct way to signal failure.
throw new Error(`Directory not found or inaccessible: ${targetPath}`);
Logging
Comprehensive logging is essential for debugging and monitoring your server. Integrate a logging library like winston or pino into your server and resolvers.
// server.ts (logging example)
import { ModelContextServer } from "@modelcontextprotocol/typescript-sdk";
import { createLogger, format, transports } from 'winston';
const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
format.splat(),
format.json()
),
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.simple()
)
}),
// new transports.File({ filename: 'mcp-server-error.log', level: 'error' }),
// new transports.File({ filename: 'mcp-server-combined.log' }),
],
});
// ... (server instance creation)
server.registerContextType({
type: "project_info",
// ... schema
resolver: async (args: Record<string, any>): Promise<ProjectInfoContext> => {
logger.info("Resolving project_info context", { args });
// ... resolver logic
return { /* ... */ };
},
});
// ... (other context types)
server.start()
.then(() => {
logger.info(`MCP server started on port ${server.port}`);
})
.catch((error) => {
logger.error("Failed to start MCP server:", error);
process.exit(1);
});
๐ฅ Optimization / Pro tip: Use structured logging (e.g., JSON format) for easier parsing by log aggregation tools like ELK stack or Splunk.
Code Lab: Expanding Context with External Data and Error Handling
Let’s enhance our MCP server to provide a more complex context: a simplified dependency_graph for a Node.js project, which involves reading package.json and handling potential errors if the file is missing or malformed.
Goal
Create a dependency_graph context that lists direct dependencies from package.json and their versions. The resolver should gracefully handle cases where package.json is not found or is invalid.
Steps
- Define
DependencyGraphContextinterface and schema. - Implement a resolver that reads
package.json. - Add robust error handling for file not found and JSON parsing errors.
- Register this new context type.
// server.ts (continued)
import { ModelContextServer } from "@modelcontextprotocol/typescript-sdk";
import { readFile } from "fs/promises";
import { join, resolve } from "path";
// Assume logger is initialized as shown previously
// --- Existing server instance and project_info, project_files contexts ---
// Define the interface for our new context type
interface DependencyGraphContext {
filePath: string;
dependencies: Record<string, string>; // packageName: version
devDependencies: Record<string, string>;
peerDependencies: Record<string, string>;
}
server.registerContextType({
type: "dependency_graph",
schema: {
type: "object",
properties: {
filePath: { type: "string", description: "Path to the package.json file" },
dependencies: {
type: "object",
additionalProperties: { type: "string" },
description: "Production dependencies",
},
devDependencies: {
type: "object",
additionalProperties: { type: "string" },
description: "Development dependencies",
},
peerDependencies: {
type: "object",
additionalProperties: { type: "string" },
description: "Peer dependencies",
},
},
required: ["filePath", "dependencies", "devDependencies", "peerDependencies"],
},
resolver: async (args: { path?: string }): Promise<DependencyGraphContext> => {
const targetDir = args.path ? resolve(args.path) : resolve("./");
const packageJsonPath = join(targetDir, "package.json");
logger.info(`Resolving dependency_graph context for: ${packageJsonPath}`);
try {
const fileContent = await readFile(packageJsonPath, "utf-8");
const packageJson = JSON.parse(fileContent);
return {
filePath: packageJsonPath,
dependencies: packageJson.dependencies || {},
devDependencies: packageJson.devDependencies || {},
peerDependencies: packageJson.peerDependencies || {},
};
} catch (error) {
if (error.code === 'ENOENT') {
logger.warn(`package.json not found at ${packageJsonPath}`);
throw new Error(`No package.json found in ${targetDir}.`);
} else if (error instanceof SyntaxError) {
logger.error(`Invalid package.json at ${packageJsonPath}:`, error);
throw new Error(`Invalid package.json format in ${targetDir}.`);
} else {
logger.error(`Failed to read or parse package.json at ${packageJsonPath}:`, error);
throw new Error(`Failed to get dependency graph for ${targetDir}: ${error.message}`);
}
}
},
});
// ... (server start and shutdown logic)
Now, restart your server and try accessing:
http://localhost:3000/context/dependency_graph(from your project root)http://localhost:3000/context/dependency_graph?path=./non-existent-dir(to test error handling)
๐ง Important: Carefully design your context schemas. They are the contract between your server and clients. Good schemas enable robust validation and clearer communication.
Security Considerations
While the MCP protocol itself doesn’t mandate specific security mechanisms, a production-grade MCP server must implement them.
- Authentication: Verify the identity of the client making the request. This could be API keys, OAuth tokens, or JWTs. The
ModelContextServeris built on a standard HTTP server (like Express), allowing you to integrate middleware for authentication. - Authorization: After authentication, determine if the authenticated client has permission to access the specific context type or specific arguments (e.g., a client might be allowed
project_infobut notdatabase_schema). This logic would typically live in middleware before your resolver or at the very beginning of your resolver. - Input Validation: Beyond basic schema validation, sanitize and validate all client-provided arguments (e.g., paths, IDs) to prevent injection attacks or path traversal vulnerabilities.
- Rate Limiting: Protect your server from abuse by limiting the number of requests a client can make within a certain timeframe.
Differentiating Core MCP and Extensions (MCP Apps)
It’s crucial to understand the distinction between the core Model Context Protocol and its extensions, such as the MCP Apps Extension (2026-01-26).
- Core MCP: Defines the fundamental mechanisms for requesting and providing structured context (e.g.,
/context/{contextType}). It’s about the what and how of context exchange. Your server implements this core. - MCP Apps Extension: Builds upon the core protocol to enable intelligent tools to discover and interact with “apps” that can perform actions or provide specific context related to a defined application. For example, an “issue tracker app” might expose
issue_detailscontext andcreate_issueactions.
Your MCP server can be designed to serve both core MCP contexts and contexts defined by extensions. The SDK’s registerContextType method is flexible enough to define any context type, regardless of whether it’s part of the core spec or an extension. If an extension specifies additional endpoints or interaction patterns, you might need to add custom route handlers alongside the SDK’s default /context routes.
Checkpoint
Consider a scenario where you need to provide a user_profile context. This context might include sensitive information like email addresses or internal IDs.
- How would you secure this context type to ensure only authorized clients can access it?
- What kind of
argsmight auser_profileresolver expect from a client?
MCQs
What is the primary purpose of a
resolverfunction in an MCP server? a) To define the schema for a context type. b) To handle HTTP request routing for all endpoints. c) To implement the logic for fetching, processing, and returning a specific context type. d) To store cached context objects.Answer: c) Resolvers are the core business logic units responsible for generating the actual context data.
Which of the following is NOT a good practice for handling errors within an MCP server resolver? a) Using
try...catchblocks for asynchronous operations. b) Throwing specificErrorobjects with descriptive messages. c) Silently failing and returning an empty context object, even if data retrieval failed. d) Logging errors with sufficient detail (e.g., stack traces).Answer: c) Silently failing makes debugging difficult and can lead to intelligent tools operating on incomplete or incorrect assumptions. It’s better to signal an error.
How does the
ModelContextServertypically handle an unknowncontextTyperequested by a client? a) It attempts to guess the correct context type based on the request. b) It logs a warning and returns an empty object. c) It returns an HTTP error response, usually 404 Not Found. d) It forwards the request to a default resolver.Answer: c) The server should respond with an appropriate HTTP error (like 404) if a requested resource (context type) does not exist.
Challenge: Implementing a Dynamic API Specification Context
Task: Extend your MCP server to provide an api_spec context.
Requirements:
- Define a new
api_speccontext type. - The context should return a simplified OpenAPI/Swagger-like specification (e.g., just a list of endpoints with their HTTP methods and basic descriptions) for a hypothetical API.
- The resolver should accept an optional
serviceNameargument. IfserviceNameis provided, it should return the spec for that specific service; otherwise, it should return a consolidated spec for all services. - Store your hypothetical API specs as simple JSON files (e.g.,
api-gateway.json,user-service.json) in aspecssubdirectory. - Implement robust error handling:
- If
serviceNameis provided but no matching spec file is found, return an error indicating the service was not found. - If a spec file is found but its JSON content is invalid, return an error.
- If
- Ensure proper logging for both success and failure cases.
This challenge will require you to combine file system interaction, argument parsing, conditional logic, and comprehensive error handling.
Summary
This chapter has equipped you with the knowledge and practical skills to build robust Model Context Protocol servers using the TypeScript SDK. You’ve learned how to define context types, implement resolvers to bridge data sources, and incorporate critical aspects like error handling and logging. By serving dynamic, structured context, your MCP server becomes a powerful enabler for next-generation intelligent tools, allowing them to operate with a deeper, more relevant understanding of their operational environment.
๐ TL;DR
- MCP servers provide dynamic, structured context to intelligent tools via a standardized protocol.
- The TypeScript SDK’s
ModelContextServersimplifies server setup and context registration. - Context
resolversare functions that encapsulate the logic to fetch, process, and return specific context types. - Robust error handling within resolvers (e.g.,
try...catch) is crucial for production systems. - Security (auth, authz, input validation) and comprehensive logging are non-negotiable for real-world deployments.
๐ง Core Flow
- Initialize
ModelContextServerwith port and optional base path. - Define context
interfaceand JSONschemafor each context type. - Implement
resolverfunctions for each context type, handling data retrieval and processing. - Register context types with the server using
server.registerContextType(). - Start the server and handle graceful shutdown signals.
๐ Key Takeaway
An MCP server is more than just a data API; it’s a semantic layer that translates raw data into actionable, structured context, enabling intelligent systems to operate with unprecedented domain-specific awareness and reducing the cognitive load on client-side AI logic.