본문으로 건너뛰기
EngineeringMar 28, 2026

Building Your First MCP Server: A Hands-On Developer Guide

OS
Open Soft Team

Engineering Team

What Is the Model Context Protocol (MCP)?

The Model Context Protocol (MCP) is an open standard created by Anthropic that defines how AI applications communicate with external data sources and tools. Think of MCP as USB-C for AI integration: just as USB-C provides a single universal connector for charging, data transfer, and display output, MCP provides a single universal protocol for connecting AI models to databases, APIs, file systems, and any other service. MCP eliminates the need for custom integrations between every AI application and every tool.

In 2026, MCP has become the dominant standard for AI-tool communication. Gartner predicts that 40% of enterprise applications will include AI agents by end of 2026, and MCP is the protocol that makes this practical at scale. Before MCP, connecting an AI model to N tools required N custom integrations. With M AI applications and N tools, you needed M x N integration adapters. MCP reduces this to M + N: every AI app implements one MCP client, every tool implements one MCP server.

MCP Architecture: Hosts, Clients, Servers, and Transports

Understanding MCP architecture is essential before writing any code. The protocol defines four key roles:

ComponentRoleExample
HostThe AI application that end users interact withClaude Desktop, Cursor, VS Code Copilot
ClientThe MCP protocol handler inside the hostBuilt into the host application
ServerExposes tools, resources, and prompts via MCPYour custom server (what we will build)
TransportThe communication layer between client and serverstdio, HTTP+SSE, Streamable HTTP

The communication flow works like this:

  1. The host starts and initializes an MCP client for each configured server.
  2. The client connects to the server via a transport (stdio for local, HTTP for remote).
  3. The client sends an initialize request, negotiating protocol version and capabilities.
  4. The server responds with its available tools, resources, and prompts.
  5. When the AI model needs external data or actions, the client calls the appropriate server method.
  6. The server executes the operation and returns results via JSON-RPC 2.0 messages.

Tools vs Resources vs Prompts

MCP servers can expose three types of capabilities:

  • Tools are functions the AI model can call. They take structured input and return structured output. Example: query_database(sql: string) or send_email(to: string, subject: string, body: string).
  • Resources are data the AI model can read. They are identified by URIs and return content. Example: file:///path/to/document.md or postgres://localhost/mydb/users.
  • Prompts are reusable prompt templates that the server provides. They help standardize how the AI interacts with the server’s domain. Example: a summarize_ticket prompt template for a Jira MCP server.

Step-by-Step: Building an MCP Server in TypeScript

Let us build a practical MCP server that provides a tool for querying a SQLite database. This is a common use case: giving an AI model safe, read-only access to your application data.

Step 1: Initialize the Project

mkdir mcp-sqlite-server && cd mcp-sqlite-server
npm init -y
npm install @modelcontextprotocol/sdk better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3
npx tsc --init

Configure tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

Step 2: Implement the Server

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import Database from "better-sqlite3";
import { z } from "zod";

// Initialize the database
const db = new Database(process.env.DB_PATH || "./data.db", {
  readonly: true,
});

// Create the MCP server
const server = new McpServer({
  name: "sqlite-query",
  version: "1.0.0",
});

// Register a tool for querying the database
server.tool(
  "query",
  "Execute a read-only SQL query against the SQLite database",
  {
    sql: z.string().describe("The SQL SELECT query to execute"),
  },
  async ({ sql }) => {
    // 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.",
          },
        ],
        isError: true,
      };
    }

    try {
      const rows = db.prepare(sql).all();
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(rows, null, 2),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Query error: ${(error as Error).message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

// Register a tool to list all tables
server.tool(
  "list_tables",
  "List all tables in the database with their schemas",
  {},
  async () => {
    const tables = db
      .prepare(
        `SELECT name, sql FROM sqlite_master
         WHERE type='table' AND name NOT LIKE 'sqlite_%'
         ORDER BY name`
      )
      .all();

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(tables, null, 2),
        },
      ],
    };
  }
);

// Register a resource for database schema
server.resource(
  "schema",
  "sqlite://schema",
  async (uri) => {
    const tables = db
      .prepare(
        `SELECT name, sql FROM sqlite_master
         WHERE type='table' AND name NOT LIKE 'sqlite_%'`
      )
      .all();

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(tables, null, 2),
        },
      ],
    };
  }
);

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

main().catch(console.error);

Step 3: Build and Test Locally

npx tsc
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | node dist/index.js

You should see a JSON-RPC response with the server’s capabilities.

Step 4: Build the Same Server in Python

For Python developers, here is the equivalent using the official MCP Python SDK:

# server.py
import sqlite3
import json
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("sqlite-query")

DB_PATH = "data.db"

@mcp.tool()
def query(sql: str) -> str:
    """Execute a read-only SQL query against the SQLite database."""
    normalized = sql.strip().upper()
    if not normalized.startswith("SELECT"):
        raise ValueError("Only SELECT queries are allowed.")

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        cursor = conn.execute(sql)
        rows = [dict(row) for row in cursor.fetchall()]
        return json.dumps(rows, indent=2, default=str)
    finally:
        conn.close()

@mcp.tool()
def list_tables() -> str:
    """List all tables in the database with their schemas."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        cursor = conn.execute(
            "SELECT name, sql FROM sqlite_master "
            "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
        )
        tables = [dict(row) for row in cursor.fetchall()]
        return json.dumps(tables, indent=2)
    finally:
        conn.close()

@mcp.resource("sqlite://schema")
def get_schema() -> str:
    """Return the database schema as a resource."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        cursor = conn.execute(
            "SELECT name, sql FROM sqlite_master WHERE type='table'"
        )
        return json.dumps([dict(r) for r in cursor.fetchall()], indent=2)
    finally:
        conn.close()

if __name__ == "__main__":
    mcp.run(transport="stdio")

Install dependencies and run:

pip install mcp[cli]
python server.py

Connecting to Claude Desktop

Claude Desktop natively supports MCP servers. To connect your server, edit the Claude Desktop configuration file:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json

{
  "mcpServers": {
    "sqlite-query": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"],
      "env": {
        "DB_PATH": "/absolute/path/to/your/data.db"
      }
    }
  }
}

For the Python version:

{
  "mcpServers": {
    "sqlite-query": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Restart Claude Desktop. You should see a hammer icon in the chat interface indicating available MCP tools. Now you can ask Claude questions like “What tables are in the database?” or “Show me the top 10 users by signup date” and it will use your MCP server to query the database directly.

Connecting to Cursor

Cursor also supports MCP servers. Add configuration to .cursor/mcp.json in your project root:

{
  "mcpServers": {
    "sqlite-query": {
      "command": "node",
      "args": ["./dist/index.js"],
      "env": {
        "DB_PATH": "./data.db"
      }
    }
  }
}

After saving, restart Cursor. The MCP tools appear in the AI assistant panel and can be invoked during code generation and debugging sessions.

Testing and Debugging Your MCP Server

Using the MCP Inspector

The MCP Inspector is the official debugging tool. It provides a web UI for interacting with your server:

npx @modelcontextprotocol/inspector node dist/index.js

This opens a browser interface at http://localhost:5173 where you can:

  • View all registered tools, resources, and prompts
  • Call tools with custom inputs and inspect responses
  • Monitor the JSON-RPC message stream in real time
  • Test error handling by sending malformed requests

Unit Testing with the SDK

Write automated tests using the in-memory transport:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

describe("SQLite MCP Server", () => {
  let client: Client;

  beforeEach(async () => {
    const [clientTransport, serverTransport] =
      InMemoryTransport.createLinkedPair();
    const server = createServer(); // your server factory
    await server.connect(serverTransport);
    client = new Client({ name: "test", version: "1.0" });
    await client.connect(clientTransport);
  });

  it("should list tables", async () => {
    const result = await client.callTool({
      name: "list_tables",
      arguments: {},
    });
    expect(result.content[0].text).toContain("users");
  });

  it("should reject non-SELECT queries", async () => {
    const result = await client.callTool({
      name: "query",
      arguments: { sql: "DROP TABLE users" },
    });
    expect(result.isError).toBe(true);
  });
});

Common Debugging Issues

ProblemCauseSolution
Server not appearing in Claude DesktopConfig path or JSON syntax errorValidate JSON, check absolute paths
“Tool not found” errorsServer did not register tools before connectRegister tools before calling server.connect()
Timeout on tool callsLong-running operations without progressAdd progress notifications via server.sendProgress()
stderr output breaking protocolConsole.log writes to stdout (stdio transport)Use console.error() for logging with stdio
Connection drops after idleTransport timeoutImplement heartbeat or use HTTP transport

Best Practices for Production

  1. Input validation: Always validate and sanitize tool inputs. Use Zod schemas (TypeScript) or Pydantic models (Python) for strict type checking.

  2. Read-only by default: Start with read-only access. Only add write capabilities when explicitly needed, and always require confirmation for destructive operations.

  3. Error handling: Return structured error messages with isError: true. Never expose internal stack traces or database connection strings.

  4. Logging: Log all tool invocations with timestamps, inputs, and execution duration. Use stderr for logs (not stdout) when using stdio transport.

  5. Rate limiting: Implement per-tool rate limits to prevent runaway AI loops from overwhelming your backend services.

  6. Timeouts: Set execution timeouts on all tool handlers. An AI model might call a tool that triggers an expensive query — protect your infrastructure.

  7. Environment separation: Use environment variables for all configuration. Never hardcode database URLs, API keys, or file paths.

  8. Versioning: Follow semantic versioning for your MCP server. The initialize handshake includes version negotiation — breaking changes require a major version bump.

FAQ

Q: What is the difference between MCP and function calling? A: Function calling (used by OpenAI, Anthropic, and others) defines tools inline within each API request. MCP externalizes tool definitions into standalone servers that any MCP-compatible host can discover and use. Function calling is per-request; MCP is a persistent protocol with stateful sessions.

Q: Can I use MCP with models other than Claude? A: Yes. MCP is an open protocol. OpenAI, Google DeepMind, and Microsoft have adopted MCP support in their platforms as of early 2026. Any AI application that implements an MCP client can connect to any MCP server.

Q: Is MCP only for local tools? A: No. While stdio transport is designed for local servers, HTTP+SSE and Streamable HTTP transports support remote MCP servers. You can deploy MCP servers as cloud services accessible over the network.

Q: How does MCP handle authentication? A: The protocol supports OAuth 2.0 for remote servers. Local stdio servers inherit the security context of the host process. For enterprise deployments, MCP gateways can centralize authentication and authorization.

Q: What languages can I build MCP servers in? A: Official SDKs exist for TypeScript, Python, Java, Kotlin, C#, and Swift. Community SDKs cover Rust, Go, Ruby, and PHP. The protocol is language-agnostic — any language that can read/write JSON-RPC over stdio or HTTP can implement an MCP server.

Q: How do I update my MCP server without restarting the host? A: MCP supports capability change notifications. When your server’s tools change, it can send a notifications/tools/list_changed message, prompting the client to re-fetch the tool list. For stdio servers, the host typically needs a restart. HTTP servers can be updated without host restart.