By Sagar Shankaran, Founder of CallSphere
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.
Key takeaways
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.
Initialize a new TypeScript project with the MCP SDK:
flowchart LR
HOST(["MCP host<br/>Claude Desktop or IDE"])
CLIENT["MCP client"]
subgraph SERVERS["MCP Servers"]
S1["Filesystem server"]
S2["GitHub server"]
S3["Postgres server"]
SX["Custom tool server"]
end
LLM["LLM session"]
OUT(["Grounded action"])
HOST <--> CLIENT
CLIENT <-->|stdio or HTTP+SSE| S1
CLIENT <--> S2
CLIENT <--> S3
CLIENT <--> SX
CLIENT --> LLM --> OUT
style HOST fill:#f1f5f9,stroke:#64748b,color:#0f172a
style CLIENT fill:#4f46e5,stroke:#4338ca,color:#fff
style OUT fill:#059669,stroke:#047857,color:#fff
// 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.
The MCP SDK provides a McpServer class that handles protocol negotiation, message routing, and transport management. Your job is to register tools and resources.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
// 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.
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.
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);
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.
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 };
}
MCP tool handlers should never throw unhandled exceptions. Always return structured error responses:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
// 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.
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);
For production deployments over HTTP:
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).
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."
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.
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

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
A founder's guide to the personal AI assistant market: best AI assistant apps, business-grade options, and how CallSphere's voice agent fits in.
A founder's guide to free AI agents, low-code AI agent builders, and how to know when you should pay for a real platform like CallSphere.
Using multiple chat AIs at once is a real 2026 workflow. Here is when it makes sense, how to set it up, and how CallSphere handles multi-model routing.
The 2026 desktop AI agent landscape — ServiceNow Project Arc, Anthropic Claude offerings, OpenAI agents, and Google Mariner. A buyer's map.
Anthropic and Moody's announced a data partnership in May 2026 that grounds Claude in audited financial reference data. Why grounding reduces hallucination and what it unlocks.
Anthropic announced full Microsoft 365 integration in May 2026. What the integration covers, what it means for Outlook, Word, Excel, and Teams users, and where the boundaries are.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI