Loading…
Loading…
Build MCP servers with TypeScript to extend AI assistants. Learn Anthropic's Model Context Protocol for standardized tool integration and resource access.
Tags
This is Part 6 of the AI Automation Engineer Roadmap series.
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.
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.
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:
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.
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.
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.
First, initialize a new TypeScript project and install the MCP SDK:
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 --initLet'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).
// 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");Extend the server with database capabilities. This example uses a SQLite database, but the pattern works with any data source:
// 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),
},
],
};
}
);
}To make your MCP server available in Claude Desktop, add it to the configuration file:
// ~/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.
Use the MCP Inspector for interactive testing during development:
npx @modelcontextprotocol/inspector npx tsx src/index.tsFor automated testing, write tests against the server directly:
// 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");
});
});console.error for logging.../../etc/passwd can escape your project root. Always resolve paths and verify they remain within the allowed directory.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.
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.
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.
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
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.
Learn how to evaluate AI workflows in production using task-based metrics, human review, regression checks, and business-aligned quality thresholds.
A practical guide to designing and shipping AI workflows inside a production SaaS app, with orchestration, fallback logic, evaluation, and user trust considerations.
A production guide to shipping AI features safely with guardrails, confidence thresholds, fallback paths, auditability, and human-in-the-loop review.