Loading...
Loading...
Weekly AI insights —
Real strategies, no fluff. Unsubscribe anytime.
Written by Gareth Simono, Founder and CEO of Agentik {OS}. Full-stack developer and AI architect with years of experience shipping production applications across SaaS, mobile, and enterprise platforms. Gareth orchestrates 267 specialized AI agents to deliver production software 10x faster than traditional development teams.
Founder & CEO, Agentik{OS}
MCP is how AI agents talk to external services. Here's how to set up MCP servers from scratch with working code, not hand-waving explanations.

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.
Q: How do you set up an MCP server?
Setting up an MCP server involves creating a TypeScript or Python file that defines tools (functions the AI can call), resources (data the AI can read), and prompts (templates the AI can use). The server communicates with AI clients over stdio or HTTP using the MCP protocol. The Anthropic MCP SDK provides helpers for server creation and tool definition.
Q: What can you build with MCP servers?
MCP servers enable AI agents to interact with any external system: databases (query and write data), APIs (call external services), file systems (read and write files), browsers (automate web interactions), code execution (run scripts), and custom business tools. Any capability you want to give an AI agent can be wrapped as an MCP server.
Q: Do I need MCP servers for Claude Code?
Claude Code already includes built-in tools for file operations, code execution, and web search. You need custom MCP servers when you want Claude to interact with specific external services — your database, internal APIs, third-party services, or custom business tools not covered by built-in capabilities.
Full-stack developer and AI architect with years of experience shipping production applications across SaaS, mobile, and enterprise. Gareth built Agentik {OS} to prove that one person with the right AI system can outperform an entire traditional development team. He has personally architected and shipped 7+ production applications using AI-first workflows.

MCP Protocol Deep Dive: Why It Changes Everything
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.

Building Custom AI Agents from Scratch: What Works
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.

Build Your First AI Agent in One Afternoon
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.