Loading...
Loading...

Most AI agents are locked in a box. They can reason about the world. They can write code, draft documents, and answer questions. What they can't do, by default, is actually reach out and touch the world. Read your files. Query your database. Call your APIs. Trigger your workflows.
MCP (Model Context Protocol) is the bridge. It's how you give AI agents real tools to work with, and it's dramatically simpler to set up than the tutorials suggest.
I've set up MCP servers for file systems, databases, external APIs, and custom business logic. This is the tutorial I wish existed when I started.
MCP is a standardized protocol that lets AI models communicate with external services. Think of it like USB for AI: a standard connector that works regardless of what's on either end.
Without MCP, you'd need to build custom integrations for every tool your agent uses. With MCP, you build (or install) an MCP server for each service, and any MCP-compatible AI client can use it immediately.
The architecture has three parts:
That's it. Client asks server for capabilities. Server responds with available tools. Client tells the AI about those tools. AI uses them.
By the end of this tutorial, you'll have:
Your AI assistant will be able to query your database, read and write files, and call your internal APIs, all through natural language.
You need Node.js 18+ and npm. That's it.
# Check versions
node --version # Should be 18+
npm --version
# Create project
mkdir my-mcp-servers
cd my-mcp-servers
npm init -y
# Install MCP SDK
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node ts-node
# Initialize TypeScript
npx tsc --initYour tsconfig.json should have:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
}
}This server gives your AI the ability to query a SQLite database. Useful for local data analysis, logs, or any scenario where you want AI to reason about structured data without cloud dependencies.
// src/sqlite-server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import Database from "better-sqlite3";
// Initialize server
const server = new Server(
{
name: "sqlite-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Database connection (path from environment or argument)
const dbPath = process.argv[2] || "./data.db";
const db = new Database(dbPath);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "query",
description: "Execute a SQL SELECT query on the database",
inputSchema: {
type: "object",
properties: {
sql: {
type: "string",
description: "The SQL SELECT query to execute",
},
},
required: ["sql"],
},
},
{
name: "list_tables",
description: "List all tables in the database",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "describe_table",
description: "Get the schema for a specific table",
inputSchema: {
type: "object",
properties: {
table_name: {
type: "string",
description: "The name of the table to describe",
},
},
required: ["table_name"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "query": {
const { sql } = args as { sql: string };
// Safety: only allow SELECT queries
const trimmed = sql.trim().toUpperCase();
if (!trimmed.startsWith("SELECT")) {
return {
content: [{
type: "text",
text: "Error: Only SELECT queries are allowed for safety.",
}],
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 instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
}
case "list_tables": {
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).all() as { name: string }[];
return {
content: [{
type: "text",
text: tables.map(t => t.name).join("\n"),
}],
};
}
case "describe_table": {
const { table_name } = args as { table_name: string };
const schema = db.prepare(`PRAGMA table_info(${table_name})`).all();
return {
content: [{
type: "text",
text: JSON.stringify(schema, null, 2),
}],
};
}
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`SQLite MCP server running on ${dbPath}`);
}
main().catch(console.error);Install the SQLite dependency:
npm install better-sqlite3
npm install -D @types/better-sqlite3This server lets your AI read, write, and list files within a specific directory.
// src/filesystem-server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from "fs/promises";
import * as path from "path";
const server = new Server(
{ name: "filesystem-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Restrict operations to this directory
const ROOT_DIR = process.argv[2] || process.cwd();
// Safety: ensure path is within ROOT_DIR
function safePath(filePath: string): string {
const resolved = path.resolve(ROOT_DIR, filePath);
if (!resolved.startsWith(ROOT_DIR)) {
throw new Error("Path traversal detected: access denied");
}
return resolved;
}
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "read_file",
description: "Read the contents of a file",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
},
required: ["path"],
},
},
{
name: "write_file",
description: "Write content to a file (creates or overwrites)",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
content: { type: "string", description: "Content to write" },
},
required: ["path", "content"],
},
},
{
name: "list_directory",
description: "List files and directories in a path",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Directory path (default: root)" },
},
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "read_file": {
const { path: filePath } = args as { path: string };
const safe = safePath(filePath);
const content = await fs.readFile(safe, "utf-8");
return { content: [{ type: "text", text: content }] };
}
case "write_file": {
const { path: filePath, content } = args as { path: string; content: string };
const safe = safePath(filePath);
await fs.mkdir(path.dirname(safe), { recursive: true });
await fs.writeFile(safe, content, "utf-8");
return { content: [{ type: "text", text: `Written to ${filePath}` }] };
}
case "list_directory": {
const { path: dirPath = "." } = args as { path?: string };
const safe = safePath(dirPath);
const entries = await fs.readdir(safe, { withFileTypes: true });
const listing = entries.map(e => `${e.isDirectory() ? "[DIR]" : "[FILE]"} ${e.name}`);
return { content: [{ type: "text", text: listing.join("\n") }] };
}
default:
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`Filesystem MCP server rooted at ${ROOT_DIR}`);
}
main().catch(console.error);Claude Desktop reads MCP server configs from a JSON file. Location:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%/Claude/claude_desktop_config.jsonHere's a complete config that adds both servers:
{
"mcpServers": {
"sqlite": {
"command": "node",
"args": [
"/absolute/path/to/my-mcp-servers/dist/sqlite-server.js",
"/absolute/path/to/your/database.db"
]
},
"filesystem": {
"command": "node",
"args": [
"/absolute/path/to/my-mcp-servers/dist/filesystem-server.js",
"/absolute/path/to/allowed/directory"
]
}
}
}Build and restart:
npx tsc
# Restart Claude DesktopYou should see the tools appear in Claude's interface. Try: "List the tables in my database" or "What files are in my project directory?"
Here's the pattern for wrapping any HTTP API as an MCP server. This example wraps a hypothetical internal task management API.
// src/tasks-api-server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const API_BASE = process.env.TASKS_API_URL || "https://api.example.com";
const API_KEY = process.env.TASKS_API_KEY;
async function apiCall(method: string, path: string, body?: unknown) {
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(`API error ${response.status}: ${await response.text()}`);
}
return response.json();
}
const server = new Server(
{ name: "tasks-api", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_tasks",
description: "List tasks, optionally filtered by status or assignee",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["open", "in_progress", "done"] },
assignee: { type: "string" },
},
},
},
{
name: "create_task",
description: "Create a new task",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
assignee: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["title"],
},
},
{
name: "update_task_status",
description: "Update the status of a task",
inputSchema: {
type: "object",
properties: {
task_id: { type: "string" },
status: { type: "string", enum: ["open", "in_progress", "done"] },
},
required: ["task_id", "status"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "list_tasks": {
const params = new URLSearchParams();
if (args.status) params.set("status", args.status as string);
if (args.assignee) params.set("assignee", args.assignee as string);
const tasks = await apiCall("GET", `/tasks?${params}`);
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
}
case "create_task": {
const task = await apiCall("POST", "/tasks", args);
return { content: [{ type: "text", text: `Created task: ${JSON.stringify(task, null, 2)}` }] };
}
case "update_task_status": {
const { task_id, status } = args as { task_id: string; status: string };
const task = await apiCall("PATCH", `/tasks/${task_id}`, { status });
return { content: [{ type: "text", text: `Updated: ${JSON.stringify(task, null, 2)}` }] };
}
default:
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
}
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true,
};
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Tasks API MCP server running");
}
main().catch(console.error);Server won't start. Check that you're using absolute paths in the Claude Desktop config. Relative paths fail silently. Also verify the compiled JS exists at the path you specified.
Tools don't appear. Restart Claude Desktop completely after updating the config. It reads the config at startup, not live.
Errors on tool calls. Add console.error() logging to your server handlers. These appear in Claude Desktop's logs, not the chat interface.
Path traversal rejected. Your safePath function is working. Review the path you're passing and make sure it's relative to the root directory you specified.
Type errors in TypeScript. Make sure your tsconfig.json has "module": "commonjs" and that your imports end with .js (not .ts) for ESM compatibility.
Once you have this foundation, the possibilities expand quickly.
| MCP Server Idea | Use Case |
|---|---|
| GitHub API | Let AI read issues, PRs, and code |
| Slack API | Let AI post messages and read channels |
| Calendar API | Let AI schedule meetings |
| Notion API | Let AI read and write docs |
| Postgres | Direct database access for analysis |
| Email IMAP | Let AI read and categorize email |
| Browser Automation | Let AI control a browser |
The MCP ecosystem is growing fast. Many servers for popular services already exist as open source packages. Before building from scratch, check the official MCP servers repository for existing implementations.
The pattern you've learned here transfers directly to any of these. Client-server protocol. Tool definition. Handler implementation. That's the whole thing.

MCP is to AI agents what HTTP was to browsers. One standard interface that means build once, works everywhere. Here's the real technical breakdown.

Stop wrapping ChatGPT in a text box and calling it an agent. Here's how to build real agents with perception, reasoning, tools, and memory.

Stop watching tutorials about tutorials. Here's how to actually build an AI agent that does something useful, from zero, in one sitting.
Stop reading about AI and start building with it. Book a free discovery call and see how AI agents can accelerate your business.