Building Your First MCP Server: A Hands-On Developer Guide
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:
| Component | Role | Example |
|---|---|---|
| Host | The AI application that end users interact with | Claude Desktop, Cursor, VS Code Copilot |
| Client | The MCP protocol handler inside the host | Built into the host application |
| Server | Exposes tools, resources, and prompts via MCP | Your custom server (what we will build) |
| Transport | The communication layer between client and server | stdio, HTTP+SSE, Streamable HTTP |
The communication flow works like this:
- The host starts and initializes an MCP client for each configured server.
- The client connects to the server via a transport (stdio for local, HTTP for remote).
- The client sends an
initializerequest, negotiating protocol version and capabilities. - The server responds with its available tools, resources, and prompts.
- When the AI model needs external data or actions, the client calls the appropriate server method.
- 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)orsend_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.mdorpostgres://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_ticketprompt 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
| Problem | Cause | Solution |
|---|---|---|
| Server not appearing in Claude Desktop | Config path or JSON syntax error | Validate JSON, check absolute paths |
| “Tool not found” errors | Server did not register tools before connect | Register tools before calling server.connect() |
| Timeout on tool calls | Long-running operations without progress | Add progress notifications via server.sendProgress() |
| stderr output breaking protocol | Console.log writes to stdout (stdio transport) | Use console.error() for logging with stdio |
| Connection drops after idle | Transport timeout | Implement heartbeat or use HTTP transport |
Best Practices for Production
-
Input validation: Always validate and sanitize tool inputs. Use Zod schemas (TypeScript) or Pydantic models (Python) for strict type checking.
-
Read-only by default: Start with read-only access. Only add write capabilities when explicitly needed, and always require confirmation for destructive operations.
-
Error handling: Return structured error messages with
isError: true. Never expose internal stack traces or database connection strings. -
Logging: Log all tool invocations with timestamps, inputs, and execution duration. Use stderr for logs (not stdout) when using stdio transport.
-
Rate limiting: Implement per-tool rate limits to prevent runaway AI loops from overwhelming your backend services.
-
Timeouts: Set execution timeouts on all tool handlers. An AI model might call a tool that triggers an expensive query — protect your infrastructure.
-
Environment separation: Use environment variables for all configuration. Never hardcode database URLs, API keys, or file paths.
-
Versioning: Follow semantic versioning for your MCP server. The
initializehandshake 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.