Connecting AI Agents to Your APIs Using the Model Context Protocol
Over the past six years building AI automation systems for international clients, I've seen countless teams hit the same wall: they have powerful APIs sitting behind their infrastructure, but connecting those APIs to an AI agent feels like performing open-heart surgery on a running server. You end up writing brittle prompt chains, hardcoded function calling schemas, and glue code that breaks every time the LLM provider ships an update.
Then Anthropic introduced the Model Context Protocol (MCP), and honestly, it changed how I architect AI integrations. Instead of fighting prompt engineering to extract structured tool calls, you expose your backend as a standardized server that any MCP-compatible agent — Claude, Cursor, custom agents — can discover and consume natively.
In this guide, I'll walk you through exactly how to build an MCP server that bridges your existing APIs to AI agents. We'll go from protocol fundamentals to a working TypeScript implementation you can ship to production by Friday.
What Is the Model Context Protocol?
The Model Context Protocol is an open standard that defines how AI agents communicate with external tools, data sources, and services. Think of it as a contract layer between your LLM and your backend, replacing the messy web of custom function-calling schemas, webhook handlers, and prompt parsers that most teams cobble together.
MCP uses a client-server architecture:
- MCP Host: The AI application running the model (Claude Desktop, Cursor, an IDE plugin, or your custom agent runtime).
- MCP Client: A protocol-speaking connector inside the host that talks to servers.
- MCP Server: A lightweight service you build that exposes your APIs, databases, or files as tools, resources, and prompts.
The protocol speaks JSON-RPC over stdio, HTTP, or Server-Sent Events. Because it's standardized, your server works with any compliant client — no vendor lock-in.
Why MCP Beats Custom Function Calling
Before MCP, integrating an LLM with an API meant manually writing JSON schemas, parsing messy model output, handling retries, and praying the model returned valid arguments. I built that kind of system for a client in 2023, and the maintenance burden was brutal.
With MCP, the agent discovers tools dynamically, validates inputs against typed schemas, and gets structured responses back. The model no longer hallucinates function names because it queries the server for the actual tool manifest. This makes MCP server development dramatically more reliable than prompt engineering alone.
Other wins I noticed in production deployments:
- Reusability: One server works across Claude, Cursor, Continue.dev, and any future MCP-compliant client.
- Type safety: Tool schemas are validated at the protocol level.
- State management: MCP supports resources (read-only data) and prompts (templated workflows), not just stateless tool calls.
- Security: Authentication and authorization live on your server, not in the prompt.
Setting Up Your First MCP Server
Let's build something practical — an MCP server that exposes a task management API. I'll use TypeScript and the official @modelcontextprotocol/sdk, but the protocol is language-agnostic. Official SDKs exist for Python, Go, Rust, Java, and C#.
First, scaffold the project:
mkdir task-mcp-server && cd task-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node
Add a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Defining Tools That Expose Your API
The heart of any MCP server is the tool manifest. Each tool describes a callable function with a name, description, and JSON Schema for inputs. Here's a server that exposes three tools backed by a task API:
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 { z } from "zod";
const API_BASE = process.env.TASK_API_URL || "https://api.example.com";
const API_KEY = process.env.TASK_API_KEY || "";
const server = new Server(
{
name: "task-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_tasks",
description: "List all tasks for the authenticated user, optionally filtered by status.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["open", "in_progress", "done"],
description: "Filter tasks by their current status",
},
},
},
},
{
name: "create_task",
description: "Create a new task with a title and optional description.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Short task title" },
description: { type: "string", description: "Longer details" },
},
required: ["title"],
},
},
{
name: "update_task_status",
description: "Move a task to a new status.",
inputSchema: {
type: "object",
properties: {
task_id: { type: "string", description: "UUID of the task" },
status: {
type: "string",
enum: ["open", "in_progress", "done"],
},
},
required: ["task_id", "status"],
},
},
],
}));
Notice how the descriptions read like natural language. That's intentional — the LLM uses them to decide when to call the tool. Write descriptions the way you'd explain the tool to a junior developer.
Implementing the Tool Handlers
Now we wire each tool to a real API call. Always validate inputs with Zod, and surface meaningful errors back to the agent:
const ListTasksSchema = z.object({
status: z.enum(["open", "in_progress", "done"]).optional(),
});
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
});
const UpdateTaskSchema = z.object({
task_id: z.string().uuid(),
status: z.enum(["open", "in_progress", "done"]),
});
async function callApi(path: string, init: RequestInit = {}) {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
...(init.headers || {}),
},
});
if (!res.ok) {
throw new Error(`API ${res.status}: ${await res.text()}`);
}
return res.json();
}
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "list_tasks") {
const { status } = ListTasksSchema.parse(args);
const query = status ? `?status=${status}` : "";
const tasks = await callApi(`/tasks${query}`);
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
}
if (name === "create_task") {
const { title, description } = CreateTaskSchema.parse(args);
const task = await callApi("/tasks", {
method: "POST",
body: JSON.stringify({ title, description }),
});
return { content: [{ type: "text", text: `Created task ${task.id}` }] };
}
if (name === "update_task_status") {
const { task_id, status } = UpdateTaskSchema.parse(args);
const task = await callApi(`/tasks/${task_id}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
return { content: [{ type: "text", text: `Updated ${task_id} to ${status}` }] };
}
throw new Error(`Unknown tool: ${name}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
The isError: true flag is critical — it tells the agent the tool failed, and most MCP clients (including Claude) will let the model retry or ask the user for clarification. Don't silently swallow errors.
Adding Resources and Prompts
Tools cover the "actions" your agent can take, but Model Context Protocol also defines two other primitives worth knowing:
- Resources: Read-only data the agent can pull into context (e.g., a user's recent orders, a config file, a database schema). Clients typically render these as
@-mentions. - Prompts: Reusable prompt templates that the user can invoke with slash commands, often pre-filled with server-specific context.
Here's a resource handler that exposes the user's profile:
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "task://user/profile",
name: "User Profile",
mimeType: "application/json",
description: "Current user account information",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === "task://user/profile") {
const profile = await callApi("/me");
return {
contents: [
{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(profile, null, 2),
},
],
};
}
throw new Error("Resource not found");
});
Wiring It Up to Claude Desktop
Now the fun part. To make Claude API integration feel native, register the server in Claude Desktop's config:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"tasks": {
"command": "node",
"args": ["/absolute/path/to/task-mcp-server/dist/index.js"],
"env": {
"TASK_API_URL": "https://api.example.com",
"TASK_API_KEY": "sk_live_..."
}
}
}
}
Restart Claude Desktop, and you'll see a small plug icon indicating connected servers. Try asking: "Show me my open tasks and create a new one called 'Review Q4 report'" — the model will autonomously call list_tasks then create_task without you writing a single line of orchestration code.
Best Practices I Learned the Hard Way
After deploying MCP servers for several clients, here are the patterns that actually matter in production:
- Keep tool descriptions short and behavior-focused. The LLM sees the full schema; the description is for choosing when to call it. Avoid implementation details.
- Batch related operations. Instead of
get_user,get_user_posts,get_user_comments, expose a singleget_user_summarythat returns everything the agent usually needs. Fewer round-trips, lower latency, lower cost. - Use enums aggressively. When a field accepts a fixed set of values, define them as an enum in the JSON Schema. The model will almost never pick an invalid value.
- Paginate explicitly. Add
limitandcursorparameters. Agents will otherwise dump your entire database into context and blow the token budget. - Log everything. Wrap calls with a logger that records tool name, arguments (redacted), duration, and outcome. You'll thank yourself the first time the agent loops.
- Return human-readable summaries as
textand machine data asjson. Mix content types in the response array so the model can both reason over and programmatically consume results.
Common Pitfalls in MCP Server Development
A few things that bit me early on:
- Forgetting to set
isErroron failures. The agent assumes success and gives the user a confident wrong answer. - Putting secrets in tool responses. Never echo API keys or tokens back. Sanitize before returning.
- Blocking the event loop. MCP runs in a single Node.js process. Synchronous heavy work will freeze the agent. Use
asynceverywhere and offload CPU work to a worker thread. - Skipping input validation. Even though the schema is enforced, never trust the model. Validate at the handler boundary with Zod or similar — it catches edge cases and gives better error messages.
When to Build a Custom MCP Server
Not every API needs MCP. If your integration is one-shot or your users never touch an MCP client, the standard function-calling path is fine. But MCP shines when:
- You want one integration to power multiple AI clients (Claude, Cursor, custom agents).
- Your team is tired of maintaining prompt-engineering glue code.
- You need strong typing, schema validation, and observability around AI tool use.
- You're building a developer-facing product where power users want the model to access real systems safely.
Wrapping Up
The Model Context Protocol is one of those rare standards that genuinely simplifies a messy problem. By turning your APIs into discoverable, typed tools, you let AI agents interact with your backend the same way a well-trained human would — through a clean, documented interface.
If you're investing in AI automation and find yourself writing yet another brittle prompt parser, stop. Build an MCP server. The first one takes a day; the second one takes an hour, and you'll have a reusable foundation that works across every MCP-compatible client released this year and next.
I've shipped MCP integrations for SaaS dashboards, internal DevOps tools, and client CRMs. If you want help architecting one for your stack — or you need a developer to build the full server, authentication, and deployment pipeline — I'm available for hire on Fiverr and always happy to chat about the architecture on LinkedIn. Let's make your APIs AI-native.
Frequently Asked Questions
What is the Model Context Protocol (MCP)? The Model Context Protocol is an open standard introduced by Anthropic that defines how AI agents discover and call external tools, fetch resources, and use prompt templates. It uses JSON-RPC and replaces brittle custom function-calling integrations with a portable, typed contract between the model and your backend.
Do I need to use Anthropic's Claude to use MCP? No. MCP is an open protocol. Any client that implements the spec can talk to any server. Today that includes Claude Desktop, Cursor, Continue.dev, and several open-source agent frameworks. Building an MCP server makes your tools available across the entire ecosystem, not just one vendor.
How are MCP servers different from regular REST APIs? REST APIs are designed for human developers and other services. MCP servers are designed for LLM agents — they expose tools with natural-language descriptions, enforce input schemas at the protocol level, and return structured responses the model can reason over. The agent dynamically discovers what your server offers, instead of relying on hardcoded OpenAPI specs.
Can I run an MCP server remotely over HTTP? Yes. While the original spec focused on stdio for local development, MCP now supports HTTP with Server-Sent Events and Streamable HTTP transports. For production multi-tenant deployments, you typically run the server behind a secure gateway with proper authentication, rate limiting, and observability.
Is MCP secure enough for production APIs? MCP itself is a transport protocol — security depends on how you implement it. In production, run servers with strict input validation, scoped API tokens, audit logging, and network isolation. Never expose an MCP server directly to the public internet without authentication, and treat tool arguments as untrusted input even though they pass through schema validation.
How long does it take to build an MCP server? A simple MCP server with a handful of tools takes a few hours to a day. The first one requires learning the SDK, but the SDKs are well-documented and the patterns are consistent. Once you have a template, spinning up a new server for a different API is usually under an hour.
What languages can I use to build MCP servers? Official SDKs exist for TypeScript/Node.js, Python, Go, Rust, Java/Kotlin, and C#/.NET. Community implementations cover Ruby, PHP, Elixir, and more. Pick the language that matches your existing backend so you can share types and authentication libraries.