Blog/AI Automation/Model Context Protocol: Building MCP Servers
POST
September 01, 2025
LAST UPDATEDSeptember 01, 2025

Model Context Protocol: Building MCP Servers

Build MCP servers with TypeScript to extend AI assistants. Learn Anthropic's Model Context Protocol for standardized tool integration and resource access.

Tags

AIMCPAnthropicTool IntegrationTypeScript
Model Context Protocol: Building MCP Servers
6 min read

Model Context Protocol: Building MCP Servers

This is Part 6 of the AI Automation Engineer Roadmap series.

TL;DR

The Model Context Protocol standardizes how AI models connect to external tools and data sources, making integrations portable across any MCP-compatible client. You define tools, resources, and prompts once, and they work everywhere -- Claude Desktop, IDEs, custom apps, and beyond.

Why This Matters

In Part 5: Building AI Agents with Tool Calling, we built agents that could interact with the outside world through function calls. But every integration was tightly coupled to a specific model provider's function-calling format. Change providers, and you rewrite your tool definitions. Add a new client, and you duplicate integration code.

The Model Context Protocol (MCP) solves this by creating a universal standard -- think of it as the USB-C of AI integrations. Instead of building custom connectors for every combination of AI client and external system, you build one MCP server that any compatible client can use.

This matters because the AI ecosystem is fragmenting rapidly. Teams are using Claude for some tasks, GPT for others, running local models for sensitive data, and wiring everything through different interfaces. MCP gives you a single integration point that works across all of them.

Core Concepts

The Client-Server Architecture

MCP follows a straightforward client-server model. The MCP client is the AI application (Claude Desktop, an IDE plugin, your custom app). The MCP server is your integration code that exposes capabilities to the client.

Communication happens over two transport mechanisms:

  • stdio -- The client spawns the server as a subprocess and communicates via standard input/output. Ideal for local tools and development.
  • SSE (Server-Sent Events) -- The server runs as an HTTP service. Better for remote servers, shared infrastructure, and production deployments.

The client discovers the server's capabilities through an initialization handshake, then makes requests as the AI model decides to use tools or read resources.

The Three Primitives

MCP organizes everything into three primitives:

Tools are actions the AI can execute. They accept parameters, do something (call an API, write a file, query a database), and return results. Tools are model-controlled -- the AI decides when and how to use them based on the user's request.

Resources are data the AI can read. They provide context without side effects -- file contents, database records, API responses. Resources are application-controlled, meaning the client application decides when to fetch them (often to populate the context window).

Prompts are reusable templates that guide the AI for specific tasks. They can include arguments and reference resources. Think of them as pre-built interaction patterns -- a "code review" prompt, a "summarize document" prompt, etc.

Capability Negotiation

When a client connects to an MCP server, they exchange capability declarations. The server announces which primitives it supports (tools, resources, prompts) and the client announces what it can handle (sampling, roots, etc.). This negotiation ensures both sides understand what's available before any requests flow.

Hands-On Implementation

Setting Up the MCP SDK

First, initialize a new TypeScript project and install the MCP SDK:

bash
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Building a File System MCP Server

Let's build a practical MCP server that gives AI assistants controlled access to a project directory. This server exposes both tools (for actions) and resources (for reading data).

typescript
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
 
const PROJECT_ROOT = process.env.PROJECT_ROOT || process.cwd();
 
const server = new McpServer({
  name: "project-filesystem",
  version: "1.0.0",
});
 
// Tool: List files in a directory
server.tool(
  "list_files",
  "List files and directories at the given path",
  {
    directoryPath: z
      .string()
      .describe("Relative path from project root")
      .default("."),
  },
  async ({ directoryPath }) => {
    const fullPath = path.resolve(PROJECT_ROOT, directoryPath);
 
    // Security: prevent directory traversal
    if (!fullPath.startsWith(PROJECT_ROOT)) {
      return {
        content: [
          {
            type: "text",
            text: "Error: Access denied. Path is outside project root.",
          },
        ],
      };
    }
 
    const entries = await fs.readdir(fullPath, { withFileTypes: true });
    const listing = entries
      .map((e) => `${e.isDirectory() ? "[DIR]" : "[FILE]"} ${e.name}`)
      .join("\n");
 
    return {
      content: [{ type: "text", text: listing }],
    };
  }
);
 
// Tool: Search for text across files
server.tool(
  "search_files",
  "Search for a text pattern across project files",
  {
    pattern: z.string().describe("Text or regex pattern to search for"),
    fileExtension: z
      .string()
      .describe("File extension filter, e.g. '.ts'")
      .optional(),
  },
  async ({ pattern, fileExtension }) => {
    const results: string[] = [];
    const regex = new RegExp(pattern, "gi");
 
    async function searchDir(dir: string) {
      const entries = await fs.readdir(dir, { withFileTypes: true });
      for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);
        if (entry.name === "node_modules" || entry.name === ".git") continue;
        if (entry.isDirectory()) {
          await searchDir(fullPath);
        } else if (!fileExtension || entry.name.endsWith(fileExtension)) {
          try {
            const content = await fs.readFile(fullPath, "utf-8");
            const lines = content.split("\n");
            lines.forEach((line, idx) => {
              if (regex.test(line)) {
                const relPath = path.relative(PROJECT_ROOT, fullPath);
                results.push(`${relPath}:${idx + 1}: ${line.trim()}`);
              }
            });
          } catch {
            // Skip binary files
          }
        }
      }
    }
 
    await searchDir(PROJECT_ROOT);
    return {
      content: [
        {
          type: "text",
          text:
            results.length > 0
              ? results.slice(0, 50).join("\n")
              : "No matches found.",
        },
      ],
    };
  }
);
 
// Resource: Read a specific file
server.resource(
  "file",
  "file:///{filePath}",
  async (uri) => {
    const filePath = decodeURIComponent(uri.pathname.slice(1));
    const fullPath = path.resolve(PROJECT_ROOT, filePath);
 
    if (!fullPath.startsWith(PROJECT_ROOT)) {
      throw new Error("Access denied: path outside project root");
    }
 
    const content = await fs.readFile(fullPath, "utf-8");
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/plain",
          text: content,
        },
      ],
    };
  }
);
 
// Start the server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Project filesystem MCP server running on stdio");

Adding a Database Query Tool

Extend the server with database capabilities. This example uses a SQLite database, but the pattern works with any data source:

typescript
// src/database-tools.ts
import Database from "better-sqlite3";
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 
const ALLOWED_TABLES = ["users", "orders", "products", "analytics"];
 
export function registerDatabaseTools(
  server: McpServer,
  dbPath: string
) {
  const db = new Database(dbPath, { readonly: true });
 
  server.tool(
    "query_database",
    "Execute a read-only SQL query against the project database",
    {
      sql: z.string().describe("SQL SELECT query to execute"),
      params: z
        .array(z.union([z.string(), z.number()]))
        .describe("Query parameters for prepared statements")
        .optional(),
    },
    async ({ sql, params }) => {
      // Security: only allow SELECT statements
      const normalized = sql.trim().toUpperCase();
      if (!normalized.startsWith("SELECT")) {
        return {
          content: [
            {
              type: "text",
              text: "Error: Only SELECT queries are allowed.",
            },
          ],
        };
      }
 
      // Security: check for disallowed operations
      const forbidden = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER"];
      if (forbidden.some((op) => normalized.includes(op))) {
        return {
          content: [
            {
              type: "text",
              text: "Error: Query contains forbidden operations.",
            },
          ],
        };
      }
 
      try {
        const stmt = db.prepare(sql);
        const rows = params ? stmt.all(...params) : stmt.all();
 
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(rows.slice(0, 100), null, 2),
            },
          ],
        };
      } catch (error) {
        return {
          content: [
            {
              type: "text",
              text: `Query error: ${(error as Error).message}`,
            },
          ],
        };
      }
    }
  );
 
  // Resource: database schema
  server.resource(
    "schema",
    "db://schema",
    async () => {
      const tables = db
        .prepare(
          `SELECT name, sql FROM sqlite_master
           WHERE type='table' AND name IN (${ALLOWED_TABLES.map(() => "?").join(",")})
          `
        )
        .all(...ALLOWED_TABLES);
 
      return {
        contents: [
          {
            uri: "db://schema",
            mimeType: "application/json",
            text: JSON.stringify(tables, null, 2),
          },
        ],
      };
    }
  );
}

Connecting to Claude Desktop

To make your MCP server available in Claude Desktop, add it to the configuration file:

json
// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
// %APPDATA%\Claude\claude_desktop_config.json (Windows)
{
  "mcpServers": {
    "project-filesystem": {
      "command": "npx",
      "args": ["tsx", "/path/to/my-mcp-server/src/index.ts"],
      "env": {
        "PROJECT_ROOT": "/path/to/your/project"
      }
    }
  }
}

Restart Claude Desktop, and your tools and resources appear in the interface. The AI can now list files, search code, read file contents, and query your database -- all through the standardized MCP protocol.

Testing MCP Servers

Use the MCP Inspector for interactive testing during development:

bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts

For automated testing, write tests against the server directly:

typescript
// src/__tests__/server.test.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
describe("Project Filesystem MCP Server", () => {
  let client: Client;
 
  beforeAll(async () => {
    const server = new McpServer({ name: "test", version: "1.0.0" });
    // ... register tools ...
 
    const [clientTransport, serverTransport] =
      InMemoryTransport.createLinkedPair();
    await server.connect(serverTransport);
 
    client = new Client({ name: "test-client", version: "1.0.0" });
    await client.connect(clientTransport);
  });
 
  test("list_files returns directory contents", async () => {
    const result = await client.callTool({
      name: "list_files",
      arguments: { directoryPath: "." },
    });
 
    expect(result.content).toBeDefined();
    expect(result.content[0].text).toContain("[FILE]");
  });
 
  test("blocks directory traversal attacks", async () => {
    const result = await client.callTool({
      name: "list_files",
      arguments: { directoryPath: "../../../etc" },
    });
 
    expect(result.content[0].text).toContain("Access denied");
  });
});

Best Practices

  • Scope capabilities tightly. Only expose the tools and resources your use case actually needs. A file system server for a project should not have access to the entire disk.
  • Validate all inputs with Zod schemas. The MCP SDK uses Zod for parameter validation. Define strict schemas that reject unexpected input before your tool logic runs.
  • Use read-only database connections. When exposing database access, always connect in read-only mode and whitelist allowed tables. Never let an AI construct arbitrary write queries.
  • Return structured errors. Tools should catch exceptions and return descriptive error messages rather than crashing the server. The AI uses error messages to adjust its approach.
  • Log to stderr, not stdout. When using stdio transport, stdout is reserved for MCP protocol messages. Use console.error for logging.
  • Version your servers. Clients may cache capability information. Bump the version when you add, remove, or modify tools and resources.

Common Pitfalls

  • Over-permissioning tools. Giving a "write_file" tool unrestricted access invites accidental (or adversarial) overwrites. Always constrain tools to specific directories and validate paths.
  • Ignoring directory traversal. Paths like ../../etc/passwd can escape your project root. Always resolve paths and verify they remain within the allowed directory.
  • Returning too much data. If a resource returns a 10MB file, it floods the context window. Implement reasonable limits and pagination for large datasets.
  • Skipping the Inspector. Debugging MCP servers without the Inspector is painful. Use it during development to test tools interactively before connecting to a real client.
  • Mixing transport concerns. If you print debug info to stdout in stdio mode, you corrupt the protocol stream. This causes cryptic connection failures that are hard to diagnose.

What's Next

With MCP giving us standardized tool integration, a single agent can now plug into any external system. But what happens when a task is too complex for one agent? In Part 7: Multi-Agent Orchestration Patterns, we will explore how to coordinate multiple specialized agents -- supervisors, pipelines, and swarms -- to tackle workflows that no single agent can handle alone.

FAQ

What is the Model Context Protocol (MCP)?

MCP is an open protocol by Anthropic that standardizes how AI applications connect to external data sources and tools. It provides a universal interface so integrations work across Claude, IDEs, and other MCP-compatible clients.

How do you build an MCP server in TypeScript?

Use the @modelcontextprotocol/sdk package to define tools, resources, and prompts. The SDK handles transport, serialization, and protocol compliance while you focus on implementing your integration logic.

What is the difference between MCP tools and MCP resources?

Tools are actions the AI can execute with parameters (like API calls), while resources are data the AI can read (like files or database records). Tools change state; resources provide context.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

AI Evaluation for Production Workflows
Mar 21, 20266 min read
AI
Evaluation
LLMOps

AI Evaluation for Production Workflows

Learn how to evaluate AI workflows in production using task-based metrics, human review, regression checks, and business-aligned quality thresholds.

How to Build an AI Workflow in a Production SaaS App
Mar 21, 20267 min read
AI
SaaS
Workflows

How to Build an AI Workflow in a Production SaaS App

A practical guide to designing and shipping AI workflows inside a production SaaS app, with orchestration, fallback logic, evaluation, and user trust considerations.

Building AI Features Safely: Guardrails, Fallbacks, and Human Review
Mar 21, 20266 min read
AI
LLM
Guardrails

Building AI Features Safely: Guardrails, Fallbacks, and Human Review

A production guide to shipping AI features safely with guardrails, confidence thresholds, fallback paths, auditability, and human-in-the-loop review.