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:

  1. Request Reception: An MCP client sends a GET /context/{contextType} or POST /context/{contextType} request to the server, often including query parameters or a request body with specific arguments (e.g., path=src/index.ts).
  2. Authentication/Authorization (Optional but Recommended): The server verifies the client’s identity and permissions to access the requested context.
  3. Context Type Identification: The server identifies the contextType requested (e.g., project_structure, dependency_graph, api_spec).
  4. Argument Extraction: Any arguments provided by the client are parsed and validated.
  5. Resolver Invocation: The server dispatches the request to a registered “resolver” function specifically designed for that contextType.
  6. 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.
  7. Context Object Construction: The processed data is packaged into a ModelContext object.
  8. Response Generation: The ModelContext object is serialized and sent back to the client.

Here’s a simplified visual representation of this flow:

flowchart TD A[MCP Client] -->|Request Context| B(MCP Server) B --> C{Authenticate} C -->|No| D[Unauthorized Error] C -->|Yes| E{Identify Context Type} E --> J[Generate Context Object] J -->|Send Context| A D -.-> A

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:

  1. Initialize a new Node.js project: npm init -y
  2. Install the SDK: npm install @modelcontextprotocol/typescript-sdk
  3. Install TypeScript and ts-node for easy execution: npm install -D typescript ts-node @types/node
  4. Create tsconfig.json (minimal):
    {
      "compilerOptions": {
        "target": "es2020",
        "module": "commonjs",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "outDir": "./dist"
      },
      "include": ["server.ts"]
    }
    
  5. 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

  1. Define ProjectFilesContext interface and schema.
  2. Implement a resolver that reads directory contents.
  3. 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 a src directory)

โšก 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 path argument, 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

  1. Define DependencyGraphContext interface and schema.
  2. Implement a resolver that reads package.json.
  3. Add robust error handling for file not found and JSON parsing errors.
  4. 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 ModelContextServer is 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_info but not database_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_details context and create_issue actions.

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.

  1. How would you secure this context type to ensure only authorized clients can access it?
  2. What kind of args might a user_profile resolver expect from a client?

MCQs

  1. What is the primary purpose of a resolver function 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.

  2. Which of the following is NOT a good practice for handling errors within an MCP server resolver? a) Using try...catch blocks for asynchronous operations. b) Throwing specific Error objects 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.

  3. How does the ModelContextServer typically handle an unknown contextType requested 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:

  1. Define a new api_spec context type.
  2. 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.
  3. The resolver should accept an optional serviceName argument. If serviceName is provided, it should return the spec for that specific service; otherwise, it should return a consolidated spec for all services.
  4. Store your hypothetical API specs as simple JSON files (e.g., api-gateway.json, user-service.json) in a specs subdirectory.
  5. Implement robust error handling:
    • If serviceName is 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.
  6. 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 ModelContextServer simplifies server setup and context registration.
  • Context resolvers are 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

  1. Initialize ModelContextServer with port and optional base path.
  2. Define context interface and JSON schema for each context type.
  3. Implement resolver functions for each context type, handling data retrieval and processing.
  4. Register context types with the server using server.registerContextType().
  5. 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.