Skip to content
Learn Agentic AI
Learn Agentic AI16 min read0 views

Building Your First MCP Server: Connect AI Agents to Any External Tool

Step-by-step tutorial on building an MCP server in TypeScript, registering tools and resources, handling requests, and connecting to Claude and other LLM clients.

What Is an MCP Server and Why Build One?

The Model Context Protocol (MCP) is an open standard that defines how AI models connect to external tools and data sources. Think of it as a USB-C port for AI — a universal interface that lets any compatible AI client (Claude, GPT-4, Gemini, or a custom agent) discover and use your tools without custom integration code.

Before MCP, every AI tool integration was bespoke. You would write a function calling schema for OpenAI, a different tool definition for Anthropic, and another adapter for LangChain. MCP eliminates this duplication: build one MCP server and every MCP-compatible client can use it.

This tutorial builds a production-ready MCP server from scratch. By the end, you will have a server that exposes a database query tool and a file system resource to any AI client.

Setting Up the Project

Initialize a new TypeScript project with the MCP SDK:

// Terminal commands (run these in order):
// 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

Update your tsconfig.json to target ES2022 with Node module resolution, and add a build script to package.json.

Building the MCP Server

The MCP SDK provides a McpServer class that handles protocol negotiation, message routing, and transport management. Your job is to register tools and resources.

// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

// Create the server instance
const server = new McpServer({
  name: "my-first-mcp-server",
  version: "1.0.0",
  description: "A demo MCP server with database and file tools",
});

// ─── Tool 1: Query a SQLite Database ───
server.tool(
  "query_database",
  "Execute a read-only SQL query against the application database. " +
    "Returns results as JSON. Only SELECT queries are allowed.",
  {
    query: z
      .string()
      .describe("SQL SELECT query to execute"),
    limit: z
      .number()
      .optional()
      .default(100)
      .describe("Maximum number of rows to return"),
  },
  async ({ query, limit }) => {
    // Validate: only allow SELECT queries
    const normalized = query.trim().toUpperCase();
    if (!normalized.startsWith("SELECT")) {
      return {
        content: [
          {
            type: "text",
            text: "Error: Only SELECT queries are allowed. " +
              "This tool provides read-only database access.",
          },
        ],
        isError: true,
      };
    }

    try {
      // Add LIMIT clause if not present
      const limitedQuery = query.includes("LIMIT")
        ? query
        : `${query} LIMIT ${limit}`;

      const results = await executeQuery(limitedQuery);

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(results, null, 2),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Database error: ${(error as Error).message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

// ─── Tool 2: Search Files by Content ───
server.tool(
  "search_files",
  "Search for files containing a specific text pattern. " +
    "Returns matching file paths and the lines that match.",
  {
    pattern: z
      .string()
      .describe("Text pattern or regex to search for"),
    directory: z
      .string()
      .optional()
      .default(".")
      .describe("Directory to search in (default: current directory)"),
    file_extension: z
      .string()
      .optional()
      .describe("Filter by file extension, e.g., '.ts', '.py'"),
  },
  async ({ pattern, directory, file_extension }) => {
    try {
      const results = await searchFiles(pattern, directory, file_extension);

      if (results.length === 0) {
        return {
          content: [
            { type: "text", text: "No files found matching the pattern." },
          ],
        };
      }

      const formatted = results
        .map(
          (r) =>
            `**${r.file}** (line ${r.line}):\n\`\`\`\n${r.content}\n\`\`\``
        )
        .join("\n\n");

      return {
        content: [{ type: "text", text: formatted }],
      };
    } catch (error) {
      return {
        content: [
          { type: "text", text: `Search error: ${(error as Error).message}` },
        ],
        isError: true,
      };
    }
  }
);

export { server };

Each tool registration includes: a unique name, a human-readable description (this is what the AI model sees when deciding which tool to use), a Zod schema for parameter validation, and an async handler function.

Adding Resources

MCP resources expose data that AI clients can read — configuration files, database schemas, documentation. Unlike tools (which perform actions), resources are passive data sources.

// src/resources.ts
import { server } from "./server.js";

// ─── Resource: Database Schema ───
server.resource(
  "database-schema",
  "db://schema",
  "The complete database schema including all tables, columns, types, and relationships",
  async () => {
    const schema = await getDatabaseSchema();
    return {
      contents: [
        {
          uri: "db://schema",
          mimeType: "application/json",
          text: JSON.stringify(schema, null, 2),
        },
      ],
    };
  }
);

// ─── Resource: Application Configuration ───
server.resource(
  "app-config",
  "config://app",
  "Current application configuration (sensitive values redacted)",
  async () => {
    const config = await getRedactedConfig();
    return {
      contents: [
        {
          uri: "config://app",
          mimeType: "application/json",
          text: JSON.stringify(config, null, 2),
        },
      ],
    };
  }
);

// ─── Resource Template: Table Details ───
// Dynamic resources with URI templates
server.resource(
  "table-details",
  "db://tables/{tableName}",
  "Detailed information about a specific database table including " +
    "columns, indexes, row count, and sample data",
  async (uri, params) => {
    const tableName = params.tableName as string;

    // Validate table name to prevent injection
    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
      throw new Error("Invalid table name");
    }

    const details = await getTableDetails(tableName);
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(details, null, 2),
        },
      ],
    };
  }
);

Resources use URI schemes to identify data. The db://schema and config://app URIs are custom schemes that your server defines. URI templates like db://tables/{tableName} allow dynamic resources — the AI client can request details for any table by name.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

Setting Up the Transport

MCP supports multiple transports. For local development (Claude Desktop, Cursor), use stdio. For remote deployments, use Streamable HTTP.

// src/index.ts — Entry point with transport selection
import { server } from "./server.js";
import "./resources.js";  // Register resources
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const transportMode = process.env.MCP_TRANSPORT || "stdio";

async function main() {
  if (transportMode === "stdio") {
    // For local clients (Claude Desktop, Cursor)
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("MCP server running on stdio");
  } else if (transportMode === "http") {
    // For remote clients
    const app = express();
    const port = parseInt(process.env.PORT || "3001");

    app.all("/mcp", async (req, res) => {
      const transport = new StreamableHTTPServerTransport("/mcp", res);
      await server.connect(transport);
      await transport.handleRequest(req, res);
    });

    // Health check endpoint
    app.get("/health", (_, res) => {
      res.json({ status: "ok", server: "my-first-mcp-server", version: "1.0.0" });
    });

    app.listen(port, () => {
      console.log(`MCP server listening on http://localhost:${port}/mcp`);
    });
  }
}

main().catch(console.error);

Connecting to Claude Desktop

To use your MCP server with Claude Desktop, add it to the configuration file:

// Claude Desktop config location:
// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
// Windows: %APPDATA%/Claude/claude_desktop_config.json

// claude_desktop_config.json
{
  "mcpServers": {
    "my-mcp-server": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"],
      "env": {
        "DATABASE_URL": "sqlite:///path/to/your/database.db",
        "MCP_TRANSPORT": "stdio"
      }
    }
  }
}

After restarting Claude Desktop, the model can discover and use your tools. When a user asks "show me all users who signed up this week," Claude will call your query_database tool with an appropriate SQL query.

Implementing the Database Layer

Here is the complete database implementation that backs the tools:

// src/db.ts
import Database from "better-sqlite3";
import path from "path";

const DB_PATH = process.env.DATABASE_URL?.replace("sqlite:///", "") ||
  path.join(process.cwd(), "data.db");

let db: Database.Database;

function getDb(): Database.Database {
  if (!db) {
    db = new Database(DB_PATH, { readonly: true });
    db.pragma("journal_mode = WAL");
    // Safety: Set a query timeout to prevent runaway queries
    db.pragma("busy_timeout = 5000");
  }
  return db;
}

export async function executeQuery(query: string): Promise<unknown[]> {
  const database = getDb();

  // Additional safety: check for write operations
  const forbidden = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE"];
  const upper = query.toUpperCase();
  for (const keyword of forbidden) {
    if (upper.includes(keyword)) {
      throw new Error(`Forbidden operation: ${keyword} not allowed`);
    }
  }

  try {
    const stmt = database.prepare(query);
    return stmt.all();
  } catch (error) {
    throw new Error(`Query failed: ${(error as Error).message}`);
  }
}

export async function getDatabaseSchema(): Promise<object> {
  const database = getDb();
  const tables = database
    .prepare(
      "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
    )
    .all() as { name: string }[];

  const schema: Record<string, unknown> = {};
  for (const { name } of tables) {
    const columns = database.prepare(`PRAGMA table_info(${name})`).all();
    const indexes = database.prepare(`PRAGMA index_list(${name})`).all();
    const count = database
      .prepare(`SELECT COUNT(*) as count FROM ${name}`)
      .get() as { count: number };

    schema[name] = {
      columns,
      indexes,
      row_count: count.count,
    };
  }

  return schema;
}

export async function getTableDetails(tableName: string): Promise<object> {
  const database = getDb();
  const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
  const indexes = database.prepare(`PRAGMA index_list(${tableName})`).all();
  const count = database
    .prepare(`SELECT COUNT(*) as count FROM ${tableName}`)
    .get() as { count: number };
  const sample = database
    .prepare(`SELECT * FROM ${tableName} LIMIT 5`)
    .all();

  return { table: tableName, columns, indexes, row_count: count.count, sample_data: sample };
}

Error Handling Best Practices

MCP tool handlers should never throw unhandled exceptions. Always return structured error responses:

// Pattern: Wrap all tool handlers with error boundary
function withErrorHandling(
  handler: (args: any) => Promise<any>
): (args: any) => Promise<any> {
  return async (args) => {
    try {
      return await handler(args);
    } catch (error) {
      const message =
        error instanceof Error ? error.message : "Unknown error occurred";

      console.error(`Tool error: ${message}`, error);

      return {
        content: [
          {
            type: "text",
            text: `Error: ${message}. Please try a different approach or check your input.`,
          },
        ],
        isError: true,
      };
    }
  };
}

The isError: true flag tells the AI client that the tool call failed, prompting it to retry with different parameters or explain the failure to the user.

Testing Your MCP Server

The MCP SDK includes a test client for validating your server without needing Claude Desktop:

// src/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";
import { server } from "./server.js";
import "./resources.js";

async function testServer() {
  // Create an in-memory transport pair
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

  // Connect server and client
  const client = new Client({ name: "test-client", version: "1.0.0" });
  await Promise.all([
    server.connect(serverTransport),
    client.connect(clientTransport),
  ]);

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

  // Test: Call a tool
  const result = await client.callTool({
    name: "query_database",
    arguments: { query: "SELECT * FROM users LIMIT 5" },
  });
  console.log("Query result:", result);

  // Test: Read a resource
  const schema = await client.readResource({ uri: "db://schema" });
  console.log("Database schema:", schema);

  // Test: Error handling
  const errorResult = await client.callTool({
    name: "query_database",
    arguments: { query: "DROP TABLE users" },
  });
  console.log("Error test:", errorResult);

  console.log("All tests passed!");
}

testServer().catch(console.error);

Deployment Considerations

For production deployments over HTTP:

  • Add authentication: Require an API key or OAuth token for all requests
  • Rate limiting: Limit tool calls per session to prevent abuse
  • Input sanitization: The Zod schemas validate types, but add domain-specific validation (SQL injection prevention, path traversal checks)
  • Logging: Log every tool call with parameters, execution time, and result size for observability
  • CORS: Configure CORS headers if browser-based clients will connect directly

FAQ

Can I build an MCP server in Python instead of TypeScript?

Yes. The official MCP SDK supports both TypeScript and Python. The Python SDK uses the same protocol and is fully compatible with TypeScript clients. Use pip install mcp and import from mcp.server. The API surface is nearly identical — server.tool() for registering tools, server.resource() for resources, and the same transport options (stdio, HTTP).

How does an AI model decide which MCP tool to use?

The model receives the tool name, description, and parameter schema as part of its context. When a user asks a question that could benefit from a tool, the model matches the intent to the tool description. Writing clear, specific descriptions is critical — a vague description like "queries data" will be used less effectively than "executes read-only SQL queries against the users, orders, and products tables."

Can one MCP server expose tools from multiple backends?

Absolutely. A single MCP server can register tools that talk to different backends — one tool queries PostgreSQL, another calls a REST API, another reads from S3. The MCP server acts as a unified interface. This is a common pattern for building organization-wide MCP servers that give AI agents access to multiple internal systems through one connection.

What is the difference between MCP tools and MCP resources?

Tools perform actions — they take input, do something, and return a result. They are invoked by the AI model when it decides an action is needed. Resources provide data — they expose information that the AI model can read to understand context. The model reads resources proactively (like reading documentation before answering a question) and calls tools reactively (like querying a database when it needs specific data).


#MCP #MCPServer #TypeScript #AITools #Claude #ModelContextProtocol #Tutorial #AgentTooling

Share
C

Written by

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.