advancedpracticalmcpserverdevelopmentprotocol

Chapter 2 of 8

Building MCP Servers from Scratch

MCP Server architecture and protocol, developing a custom tool server with TypeScript, and integrating with Claude Code, Cline, and Cursor.

The Model Context Protocol (MCP) is an open standard (spec at modelcontextprotocol.io) that lets LLM applications connect to external tools, data sources, and services through a unified interface. Think of it as USB-C for AI: a single protocol that any client (Claude Code, Cline, Cursor) can use to talk to any server (a database query tool, a file system bridge, a custom API wrapper). This tutorial covers MCP architecture end to end — from the JSON-RPC protocol layer through building a production-ready tool server in TypeScript to wiring it up with the three most popular MCP clients.

Why Build an MCP Server?

Before MCP, every AI coding tool invented its own plugin API. Claude Code had hooks, Cursor had extensions, Cline had its own tool format. An integration you built for one tool was useless in another. MCP solves this by standardizing three primitives:

PrimitivePurposeAnalogy
ToolsFunctions the LLM can invokeREST API endpoints
ResourcesData the LLM can readGET endpoints returning context
PromptsReusable prompt templatesStored procedures for LLM calls

Build one MCP server and it works with every MCP-compatible client. That is the architectural payoff.

MCP Protocol Architecture

MCP uses a client-server model with three roles: the host (LLM application like Claude Code), the client (connector within the host that manages individual server connections), and the server (your tool service). The protocol itself is JSON-RPC 2.0, originally published by Anthropic in November 2024 and now maintained as an open specification at modelcontextprotocol.io (latest version: 2025-06-18).

Transport Layer

The transport layer determines how client and server exchange messages. The 2025-06-18 specification defines two standardized transports plus a deprecated legacy transport:

Transport          Use Case                      Direction
─────────────────  ──────────────────────────    ─────────────────
stdio              Local CLI tools, Claude Code   Bidirectional over stdin/stdout
Streamable HTTP    Production servers, cloud       Single HTTP endpoint, optional
                   deployments, remote access    SSE for server-push, sessions
SSE (deprecated)   Legacy browser-based clients   Separate GET+POST endpoints

Stdio is the simplest and most common for development. The client spawns the server as a child process and communicates over stdin/stdout. This is what Claude Code, Claude Desktop, and Cline use for local tool servers.

SSE (from the original 2024-11-05 spec) uses a GET endpoint for server-to-client streaming and separate HTTP POST requests for client-to-server messages. It is deprecated in the 2025-06-18 specification. Existing SSE servers continue to work, but new implementations should use Streamable HTTP instead.

Streamable HTTP is the production-grade transport. A single HTTP endpoint handles all message types. Clients send JSON-RPC via POST; the server optionally uses SSE for streaming responses. It supports session management via Mcp-Session-Id headers, resumable streams via Last-Event-ID, and works behind standard load balancers. This is the recommended transport for any server exposed over the network.

Message Format

All MCP communication uses JSON-RPC 2.0. A tool invocation looks like this:

// Client sends a tools/call request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_files",
    "arguments": {
      "pattern": "*.ts",
      "directory": "/src"
    }
  }
}

// Server responds with the result
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Found 23 files matching *.ts in /src"
      }
    ]
  }
}

Lifecycle

Every MCP connection follows a three-phase lifecycle:

  1. Initialize — client sends initialize with its capabilities and protocol version; server responds with its own capabilities
  2. Operate — bidirectional exchange of tools/call, resources/read, prompts/get, and notification messages
  3. Shutdown — either side sends a termination notification and the transport closes

The server advertises its capabilities during initialization. This is how a client discovers what tools, resources, and prompts are available without prior configuration.

Client-Side Capabilities

The 2025-06-18 specification also defines three capabilities that clients expose to servers, enabling advanced bidirectional patterns:

CapabilityPurposeWhen to Use
SamplingServer requests LLM completions through the clientTools that need language understanding (summarization, explanation) without their own API key
RootsServer discovers the client's filesystem/workspace boundariesFile system tools that need to know which directories are accessible
ElicitationServer prompts the user for additional structured inputTools that need interactive clarification before proceeding

These capabilities enable recursive patterns — the LLM calls a tool, the tool calls back to the LLM via sampling, the LLM processes the result. This tutorial focuses on the server-side primitives (tools, resources, prompts), which are the foundation for most MCP servers.

Setting Up the TypeScript Project

Let's build a practical MCP server: a code analysis tool that provides file search, dependency inspection, and code metrics. This server will work with Claude Code, Cline, and Cursor identically.

mkdir mcp-code-tools && cd mcp-code-tools
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

The @modelcontextprotocol/sdk package (from github.com/modelcontextprotocol/typescript-sdk) provides the server framework. zod handles input validation schemas — MCP tool parameters must be defined as JSON Schema, and zod maps directly to it.

Initialize TypeScript:

npx tsc --init --target ES2022 --module Node16 \
  --moduleResolution Node16 --outDir dist --rootDir src

Create the source directory:

mkdir -p src

Building the Server: Core Structure

The foundation of any MCP server is a transport paired with an McpServer instance. Create src/index.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Create the server instance with metadata
const server = new McpServer({
  name: "code-tools",
  version: "1.0.0",
  description: "Code analysis tools for file search, dependency inspection, and code metrics"
});

// Register tools, resources, and prompts here (next sections)

// Start the stdio transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Code Tools MCP server running on stdio");
}

main().catch((error) => {
  console.error("Server failed to start:", error);
  process.exit(1);
});

Key details: StdioServerTransport reads JSON-RPC messages from stdin and writes responses to stdout. All logging must go to stderr (console.error) — stdout is reserved for the protocol. This is the single most common mistake new MCP server developers make.

Add the run script to package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"
  }
}

Registering Tools

Tools are the core primitive — they are callable functions that the LLM invokes when it needs to perform an action. Register them using server.tool(), which takes a name, description, a zod schema for parameters, and an implementation function.

Add these tool registrations to src/index.ts before the main() function:

server.tool(
  "search_files",
  "Search for files matching a glob pattern in a directory tree. Returns file paths and sizes.",
  {
    pattern: z.string().describe("Glob pattern to match (e.g., '*.ts', 'src/**/*.test.ts')"),
    directory: z.string().describe("Root directory to search in"),
    maxResults: z.number().optional().default(50).describe("Maximum number of results to return")
  },
  async ({ pattern, directory, maxResults }) => {
    const { execSync } = await import("child_process");
    const { readdirSync, statSync } = await import("fs");
    const { join } = await import("path");

    try {
      const results: Array<{ path: string; size: number }> = [];
      
      function walk(dir: string): void {
        if (results.length >= maxResults) return;
        const entries = readdirSync(dir, { withFileTypes: true });
        for (const entry of entries) {
          if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
          const fullPath = join(dir, entry.name);
          if (entry.isDirectory()) {
            walk(fullPath);
          } else if (entry.name.endsWith(pattern.replace("*", "")) || 
                     entry.name.includes(pattern.replace("*", ""))) {
            results.push({
              path: fullPath,
              size: statSync(fullPath).size
            });
            if (results.length >= maxResults) return;
          }
        }
      }

      walk(directory);
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            pattern,
            directory,
            count: results.length,
            files: results
          }, null, 2)
        }]
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error searching files: ${error instanceof Error ? error.message : String(error)}`
        }],
        isError: true
      };
    }
  }
);

Tool 2: Dependency Inspector

server.tool(
  "inspect_dependencies",
  "Analyze package.json dependencies and return version information, update availability, and dependency counts.",
  {
    directory: z.string().describe("Path to the project directory containing package.json")
  },
  async ({ directory }) => {
    const { readFileSync } = await import("fs");
    const { join } = await import("path");

    try {
      const pkgPath = join(directory, "package.json");
      const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
      const deps = pkg.dependencies || {};
      const devDeps = pkg.devDependencies || {};

      const analysis = {
        name: pkg.name,
        version: pkg.version,
        dependencyCount: Object.keys(deps).length,
        devDependencyCount: Object.keys(devDeps).length,
        totalDependencies: Object.keys(deps).length + Object.keys(devDeps).length,
        dependencies: deps,
        devDependencies: devDeps
      };

      return {
        content: [{
          type: "text",
          text: JSON.stringify(analysis, null, 2)
        }]
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error reading package.json: ${error instanceof Error ? error.message : String(error)}`
        }],
        isError: true
      };
    }
  }
);

Tool 3: Code Metrics

server.tool(
  "code_metrics",
  "Calculate code metrics for TypeScript/JavaScript files: line count, function count, and complexity estimate.",
  {
    filePath: z.string().describe("Path to the source file to analyze")
  },
  async ({ filePath }) => {
    const { readFileSync } = await import("fs");

    try {
      const source = readFileSync(filePath, "utf-8");
      const lines = source.split("\n");
      const codeLines = lines.filter(line => {
        const trimmed = line.trim();
        return trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("*");
      });

      const functionMatches = source.match(
        /(?:function\s+\w+|(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>)/g
      );
      const functionCount = functionMatches ? functionMatches.length : 0;

      const complexityKeywords = (source.match(/\b(if|else|for|while|case|catch|\?\?|&&|\|\|)\b/g) || []).length;

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            file: filePath,
            totalLines: lines.length,
            codeLines: codeLines.length,
            commentLines: lines.length - codeLines.length,
            functionCount,
            estimatedComplexity: complexityKeywords,
            linesPerFunction: functionCount > 0 ? Math.round(codeLines.length / functionCount) : codeLines.length
          }, null, 2)
        }]
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error analyzing file: ${error instanceof Error ? error.message : String(error)}`
        }],
        isError: true
      };
    }
  }
);

The isError: true flag in the return value is critical — it tells the LLM client that the tool invocation failed, so the model can handle the error gracefully (retry, fall back, or report to the user) rather than treating garbage output as a successful result.

Registering Resources

Resources provide read-only data that the LLM can access on demand. They are similar to GET endpoints — the client requests a resource URI and the server returns its content. Resources are ideal for exposing configuration files, environment metadata, or computed context.

import { z } from "zod";

server.resource(
  "project-info",
  "code-tools://project-info",
  "Returns metadata about the currently configured project workspace",
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify({
        server: "code-tools",
        version: "1.0.0",
        capabilities: ["file_search", "dependency_inspection", "code_metrics"],
        supportedPatterns: ["*.ts", "*.js", "*.tsx", "*.jsx", "*.json"]
      }, null, 2)
    }]
  })
);

server.resource(
  "file-content",
  "code-tools://file/{path}",
  "Read the content of a file from the workspace",
  async (uri) => {
    const { readFileSync } = await import("fs");
    const filePath = uri.pathname.replace(/^\//, "");
    try {
      const content = readFileSync(filePath, "utf-8");
      return {
        contents: [{
          uri: uri.href,
          mimeType: "text/plain",
          text: content
        }]
      };
    } catch (error) {
      return {
        contents: [{
          uri: uri.href,
          mimeType: "text/plain",
          text: `Error reading file: ${error instanceof Error ? error.message : String(error)}`
        }]
      };
    }
  }
);

The resource URI template code-tools://file/{path} uses URI template syntax. The MCP client can list available resources and their URI templates, then request specific ones by filling in the template variables.

Registering Prompts

Prompts are reusable templates that the LLM client can load on demand. They are useful for encoding common workflows — for example, a standardized code review prompt that uses the tools we just registered.

server.prompt(
  "code-review",
  "Generate a comprehensive code review for a file using code metrics and dependency data.",
  {
    filePath: z.string().describe("Path to the file to review"),
    focusArea: z.enum(["performance", "readability", "security", "all"]).optional()
      .default("all").describe("Specific area to focus the review on")
  },
  async ({ filePath, focusArea }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: [
            `Review the file at ${filePath}.`,
            focusArea !== "all" ? `Focus specifically on ${focusArea}.` : "Cover all aspects.",
            "",
            "Steps:",
            "1. Use code_metrics to get the file's metrics",
            "2. Read the file content",
            "3. Analyze the code for issues related to the focus area",
            "4. If the focus is 'security', check for common vulnerabilities",
            "5. If the focus is 'performance', identify potential bottlenecks",
            "6. Provide specific, actionable recommendations"
          ].join("\n")
        }
      }
    ]
  })
);

When a user selects this prompt in their MCP client, the pre-formatted instructions are injected into the conversation, guiding the LLM to use the server's own tools in the right sequence.

Error Handling and Validation

Robust error handling separates production MCP servers from toy examples. The TypeScript SDK provides structured error types:

import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";

// In any tool handler:
server.tool(
  "safe_operation",
  "An operation with structured error handling.",
  { input: z.string() },
  async ({ input }) => {
    // Validation errors
    if (input.length > 10000) {
      throw new McpError(
        ErrorCode.InvalidParams,
        "Input exceeds maximum length of 10000 characters"
      );
    }

    try {
      // Business logic here
      return { content: [{ type: "text", text: "Success" }] };
    } catch (error) {
      // Wrap unexpected errors with context
      throw new McpError(
        ErrorCode.InternalError,
        `Operation failed: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

Use McpError with proper error codes for recoverable errors (invalid params, resource not found). Use isError: true in the return value for expected failure modes where the LLM should see the error message rather than an exception trace.

Adding Streamable HTTP Transport

For production deployments where the server runs remotely, add the Streamable HTTP transport alongside stdio:

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

// Add after the main() function
async function startHttpServer() {
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: crypto.randomUUID });
  await server.connect(transport);
  
  // Express.js integration example
  const express = await import("express");
  const app = (0, express.default)();
  app.post("/mcp", (req, res) => {
    // Express passes the raw request to the transport
  });
  
  app.listen(3000, () => {
    console.error("Code Tools MCP server running on HTTP at port 3000");
  });
}

The Streamable HTTP transport lets you deploy the same server behind a load balancer with TLS termination, health checks, and all the infrastructure that production services require. Clients discover the server via its HTTP endpoint URL instead of a local command.

Testing the Server

Before connecting to a client, verify the server works in isolation using the MCP Inspector tool included in the SDK:

npx @modelcontextprotocol/inspector node dist/index.js

This launches a web UI at http://localhost:6274 that connects to your server over stdio and lets you manually invoke tools, read resources, and load prompts. It is the fastest way to debug tool definitions and parameter handling.

For automated tests:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

async function testServer() {
  const transport = new StdioClientTransport({
    command: "node",
    args: ["dist/index.js"]
  });
  
  const client = new Client({ name: "test-client", version: "1.0.0" });
  await client.connect(transport);

  // List available tools
  const tools = await client.listTools();
  console.log("Available tools:", tools.tools.map(t => t.name));

  // Call a tool
  const result = await client.callTool({
    name: "code_metrics",
    arguments: { filePath: "./src/index.ts" }
  });
  console.log("Metrics result:", result.content[0].text);
  
  // Disconnect
  await client.close();
}

testServer();

Integration: Claude Code

Claude Code configures MCP servers in .mcp.json at the project root (committed to git for team sharing), or via claude mcp add commands for user-scoped servers stored in ~/.claude.json:

{
  "mcpServers": {
    "code-tools": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-code-tools/dist/index.js"],
      "env": {
        "NODE_ENV": "production"
      }
    }
  }
}

For a remote server using Streamable HTTP:

{
  "mcpServers": {
    "code-tools-remote": {
      "type": "http",
      "url": "https://your-server.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_API_KEY}"
      }
    }
  }
}

After adding the configuration, restart Claude Code or run /mcp to verify the server is connected. The tools appear in Claude Code's tool list and the model can invoke them automatically during loop engineering tasks — for example, using code_metrics inside a test-driven loop to verify that a refactoring actually reduced complexity.

You can also manage servers from the CLI:

# Add a stdio server
claude mcp add code-tools -- node /path/to/dist/index.js

# Add a remote HTTP server
claude mcp add --transport http code-tools-remote https://your-server.example.com/mcp

# List all configured servers
claude mcp list

# Remove a server
claude mcp remove code-tools

Integration: Cline

Cline (github.com/cline/cline, formerly Claude Dev) configures MCP servers through its MCP settings panel. Open the Cline panel in VS Code, click the MCP Servers icon, navigate to Configure, and add:

{
  "mcpServers": {
    "code-tools": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-code-tools/dist/index.js"],
      "env": {
        "NODE_ENV": "production"
      },
      "disabled": false,
      "autoApprove": []
    }
  }
}

This configuration is stored in ~/.cline/mcp.json for CLI usage or through the extension's settings for the IDE. Cline spawns the server process when a session starts and connects automatically. The tools are available to Cline's agent just like its built-in file editing and terminal tools. The autoApprove array lets you specify tool names that skip the user confirmation dialog.

For remote servers, Cline uses the Streamable HTTP transport:

{
  "mcpServers": {
    "code-tools-remote": {
      "type": "streamableHttp",
      "url": "https://your-server.example.com/mcp",
      "headers": {
        "Authorization": "Bearer your-token"
      }
    }
  }
}

Integration: Cursor

Cursor configures MCP servers in .cursor/mcp.json at the project root (committed to git for team sharing), or in ~/.cursor/mcp.json for global access:

{
  "mcpServers": {
    "code-tools": {
      "type": "stdio",
      "command": "node",
      "args": ["/absolute/path/to/mcp-code-tools/dist/index.js"],
      "env": {
        "NODE_ENV": "production",
        "API_KEY": "${env:MCP_API_KEY}"
      }
    }
  }
}

Place this file in your project's root directory and commit it to version control — team members get the MCP server configuration automatically. Cursor detects the file and connects to the server on workspace load. The tools become available in Cursor's Agent mode and Cmd+K inline editing.

Cursor supports variable interpolation in config values: ${env:NAME} for environment variables, ${workspaceFolder} for the project root, ${userHome} for the home directory, and ${pathSeparator} for the OS path separator.

For remote servers, Cursor infers the transport type from the URL:

{
  "mcpServers": {
    "code-tools-remote": {
      "url": "https://your-server.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${env:MCP_API_KEY}"
      }
    }
  }
}

MCP Ecosystem Overview

The MCP ecosystem has grown rapidly since the protocol's public release. Here is the current landscape as of mid-2026:

Official Servers (from Anthropic)

ServerPurposeTransport
filesystemFile and directory operationsstdio
githubGitHub API: issues, PRs, reposstdio
postgresPostgreSQL database queriesstdio
sqliteSQLite database queriesstdio
puppeteerBrowser automation and screenshotsstdio
brave-searchWeb search via Brave APIstdio
google-mapsLocation and directions via Google Mapsstdio
memoryPersistent knowledge graph storagestdio

Community Servers

The community has built hundreds of MCP servers. Notable categories:

  • Database connectors — MySQL, MongoDB, Redis, Supabase, Neon, PlanetScale
  • Cloud platforms — AWS, GCP, Azure, Vercel, Cloudflare, Railway
  • Dev tools — Docker, Kubernetes, Terraform, Jenkins, GitHub Actions
  • Communication — Slack, Discord, Notion, Linear, Jira
  • AI/ML — Hugging Face, LangChain, LlamaIndex, Weaviate, Pinecone

The full registry is at github.com/modelcontextprotocol/servers.

MCP-Compatible Clients

ClientTypeTransport SupportBest For
Claude CodeCLIstdio, HTTP, SSE, WebSocketLoop engineering, autonomous coding
Claude DesktopDesktop appstdio, SSEGeneral-purpose AI assistant
ClineVS Code extensionstdio, Streamable HTTPIDE-integrated AI coding
CursorIDEstdio, SSE, Streamable HTTPAI-powered editing with agent mode
WindsurfIDEstdioCascade multi-step execution
ContinueVS Code extensionstdioOpen-source AI coding assistant
ZedEditorstdioHigh-performance AI editing
GooseCLI/Desktop (Block)stdioOpen-source AI coding agent

Advanced Patterns

Progressive Tool Discovery

Rather than registering all tools at startup, implement lazy loading based on the client's declared capabilities:

// Only register advanced tools if the client supports them
server.tool(
  "advanced_refactor",
  "Perform complex AST-based refactoring (requires sampling support).",
  { /* schema */ },
  async (params) => { /* implementation */ },
  { /* optional annotations */ }
);

Sampling for LLM-Augmented Tools

MCP's sampling capability lets the server itself call back to the LLM during tool execution. This enables tools that need language understanding — for example, a code explanation tool that asks the LLM to summarize a function:

// Inside a tool handler:
const summary = await server.request(
  { method: "sampling/createMessage" },
  {
    messages: [{ role: "user", content: `Explain this function in one sentence:\n${code}` }],
    maxTokens: 100
  }
);

This creates a recursive loop — the LLM calls a tool, the tool calls the LLM, the LLM processes the result. It is a powerful pattern for tools that bridge raw data and human-readable analysis.

Batching and Concurrency

For tools that process multiple files, implement batching to avoid overwhelming the client:

server.tool(
  "batch_metrics",
  "Calculate code metrics for multiple files at once.",
  {
    files: z.array(z.string()).max(20).describe("Array of file paths (max 20)")
  },
  async ({ files }) => {
    const results = await Promise.allSettled(
      files.map(f => analyzeFile(f))
    );
    // Return aggregated results
  }
);

Limit batch sizes (hard cap at 20 files in this example) and use Promise.allSettled so one file failure does not abort the entire batch.

Security Considerations

MCP servers run with the same permissions as the process that spawns them. A stdio server has the full filesystem and network access of the user's shell. Apply these principles:

  1. Validate all inputs — use zod schemas with strict types; never pass raw user input to execSync, eval, or file paths
  2. Sandbox file access — restrict tools to a configurable root directory; reject paths containing .. or absolute paths outside the workspace
  3. Limit resource consumption — set maximum file sizes, result lengths, and batch counts
  4. Log and audit — write structured logs (to stderr) with timestamps and request IDs
  5. Network hygiene — if your server makes outbound HTTP requests, validate URLs and set timeouts
  6. Secrets management — pass API keys through environment variables, never hardcode them
// Example: path validation utility
function safePath(baseDir: string, targetPath: string): string {
  const { resolve, relative } = require("path");
  const resolved = resolve(baseDir, targetPath);
  const rel = relative(baseDir, resolved);
  if (rel.startsWith("..") || resolve(rel) !== resolved) {
    throw new McpError(ErrorCode.InvalidParams, "Path escapes workspace root");
  }
  return resolved;
}

Complete Project Structure

After completing this tutorial, your server project should look like this:

mcp-code-tools/
  package.json
  tsconfig.json
  src/
    index.ts          # Server setup, transport, main entry point
    tools/
      search.ts       # search_files tool
      dependencies.ts # inspect_dependencies tool
      metrics.ts      # code_metrics tool
    resources/
      project.ts      # project-info resource
      files.ts        # file-content resource
    prompts/
      review.ts       # code-review prompt
    utils/
      validation.ts   # Path validation, input sanitization
  dist/               # Compiled output

Splitting tools, resources, and prompts into separate modules keeps the codebase maintainable as the server grows. Each module exports a registration function that takes the McpServer instance:

// src/tools/metrics.ts
export function registerCodeMetricsTool(server: McpServer): void {
  server.tool("code_metrics", /* ... */);
}

// src/index.ts
import { registerCodeMetricsTool } from "./tools/metrics.js";
import { registerSearchTool } from "./tools/search.js";
registerCodeMetricsTool(server);
registerSearchTool(server);

Key Takeaways

  • MCP standardizes AI-tool communication — one protocol, two standardized transports (stdio, Streamable HTTP), works with every compatible client
  • Three server-side primitives — tools (callable functions), resources (read-only data), prompts (reusable templates) cover the full range of LLM interactions
  • Three client-side capabilities — sampling (LLM callbacks), roots (workspace discovery), elicitation (interactive user input) enable advanced bidirectional patterns
  • The TypeScript SDK (@modelcontextprotocol/sdk v1.x) provides McpServer, transport classes, and zod integration for type-safe tool definitions; a v2 SDK with split packages (@modelcontextprotocol/server, @modelcontextprotocol/client) is in development for Q3 2026
  • Stdio is for local development, Streamable HTTP is for production — build once, deploy either way
  • One server, every client — the same code-tools server works identically in Claude Code, Cline, and Cursor with minimal configuration changes
  • Test with MCP Inspectornpx @modelcontextprotocol/inspector provides an interactive web UI for debugging tool calls
  • Security is your responsibility — validate inputs, sandbox paths, limit resources, and never trust client-provided data blindly
  • The ecosystem is large and growing — official servers cover common needs, community servers extend into every domain, and the client list spans CLI tools, IDEs, and desktop apps