Mastering the Model Context Protocol (MCP): A Guide for Engineers
For the past two years, the primary challenge for AI engineers hasn't been the quality of the models—it's been the context. We have incredibly capable Large Language Models (LLMs) that are effectively locked in a room without windows. They can reason, write code, and solve complex logic puzzles, but they lack access to the one thing that makes them truly useful in a corporate environment: your private data and internal systems.
Until recently, connecting an LLM to a local database or a proprietary API meant building bespoke, brittle integration layers. You’d write custom function-calling logic for OpenAI, different logic for Claude, and yet another wrapper for local execution. Anthropic’s Model Context Protocol (MCP) aims to end this fragmentation. It provides an open-standard, secure way to connect AI models to data sources and tools, regardless of where they live.
In this guide, we’ll dive into the architecture of MCP, explore why it’s a shift from traditional RAG (Retrieval-Augmented Generation), and walk through a production-grade implementation using TypeScript.
Understanding the MCP Architecture
MCP operates on a client-server architecture, but with a twist that favors security and local-first development. To understand it, we need to define three primary roles:
- The MCP Host: This is the application that wants to use the data. Examples include the Claude Desktop app, a specialized IDE like Cursor, or your own custom-built AI agent.
- The MCP Server: This is a lightweight process (written in TypeScript, Python, etc.) that exposes specific capabilities (tools, resources, and prompts) to the host.
- The MCP Client: This is the bridge within the Host that communicates with the Server via the protocol.
The genius of MCP lies in the transport layer. Most local MCP servers communicate over standard input/output (stdio), while remote servers can use Server-Sent Events (SSE). This means your MCP server doesn't need to expose a public port or handle complex OAuth flows just to give an LLM access to a local SQLite database or a Jira instance. If the process can run on your machine, the LLM can use it.
Why MCP Over Traditional Tool-Calling?
Before MCP, we relied on "Function Calling." You’d define a JSON schema for a function, send it to the LLM, receive a call request, execute it locally, and send the result back. While effective, it suffers from several drawbacks:
- Scalability: Every new tool requires manual wiring into the LLM's prompt context.
- Security: There is no standardized way to sandbox these calls or verify what data is being accessed.
- Portability: A tool built for one platform rarely works on another without significant refactoring.
MCP standardizes the "handshake." When an MCP client connects to a server, it automatically discovers what tools and resources are available. It’s the "USB-C for AI models."
Building a Practical MCP Server with TypeScript
Let’s build a real-world example. Imagine you have an internal "Customer Success" database (SQLite) and you want your AI agent to be able to look up customer health scores and recent tickets without uploading that sensitive data to a third-party cloud.
1. Project Initialization
First, set up a new TypeScript project and install the MCP SDK.
mkdir customer-mcp-server cd customer-mcp-server npm init -y npm install @modelcontextprotocol/sdk npm install sqlite3 npm install --save-dev typescript @types/node @types/sqlite3
2. Defining the Server Logic
Create a file named index.ts. Our server will expose one "Resource" (static data) and one "Tool" (an executable function).
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import sqlite3 from "sqlite3"; import { promisify } from "util"; const db = new sqlite3.Database("./customer_data.db"); const dbGet = promisify(db.get).bind(db); const server = new Server( { name: "customer-insights-server", version: "1.0.0", }, { capabilities: { resources: {}, tools: {}, }, } ); /** * Tools: Executable functions the LLM can call. */ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get_customer_health", description: "Get the health score and recent tickets for a customer by ID", inputSchema: { type: "object", properties: { customerId: { type: "string" }, }, required: ["customerId"], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "get_customer_health") { const { customerId } = request.params.arguments as { customerId: string }; // In a real app, you'd perform validation and auth here const data = await dbGet( "SELECT health_score, ticket_count FROM customers WHERE id = ?", [customerId] ); return { content: [{ type: "text", text: JSON.stringify(data) }], }; } throw new Error("Tool not found"); }); // Start the server using stdio transport const transport = new StdioServerTransport(); await server.connect(transport);
3. Resources vs. Tools
In the example above, we used a Tool. Tools are dynamic; the LLM decides when to call them based on the user's intent. Resources, on the other hand, are like files or database tables that the LLM can "read."
If you have a set of documentation or a log file that doesn't change during the conversation, expose it as a Resource. If you need to perform an action (like querying a database with a specific ID or sending an email), use a Tool.
Securely Connecting Internal APIs
one of the most powerful use cases for MCP is acting as a secure proxy for internal APIs. Instead of giving an LLM your master API key for Stripe or AWS, you write an MCP server that exposes only the specific endpoints needed.
Implementing an API Proxy
When connecting to an internal API, your MCP server handles the authentication. The LLM never sees the API key; it only interacts with the schema you define.
server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "query_internal_inventory") { const response = await fetch('https://api.internal.corp/v1/inventory', { headers: { 'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}` } }); const data = await response.json(); return { content: [{ type: "text", text: JSON.stringify(data) }], }; } });
By keeping the INTERNAL_API_KEY in the environment variables of the MCP server (running on your local machine or a secure container), you create a hard boundary. The LLM can ask for inventory data, but it cannot craft arbitrary requests to your internal API.
Advanced Pattern: Agentic Workflows
MCP is the engine behind "Agentic Tool-Use." This is where the model doesn't just answer a question but executes a multi-step plan. For example:
- User: "Check if our top customer has any open high-priority tickets."
- Agent (via MCP): Calls
get_top_customerstool. - Agent (via MCP): Receives "Acme Corp" (ID: 123).
- Agent (via MCP): Calls
get_customer_healthwithcustomerId: 123. - Agent (via MCP): Sees 5 open tickets.
- Agent: Responds to user with the summary.
This "loop" is handled by the Host (like Claude Desktop) using the capabilities exposed by your MCP Server. As a developer, your job is simply to provide the model with the most high-fidelity tools possible.
Security Best Practices for MCP
As a senior engineer, security should be your first concern when giving an LLM access to data. MCP provides several layers of protection, but implementation details matter:
1. Principle of Least Privilege
Do not create a "God Tool" that accepts raw SQL. Instead, create specific tools like get_user_by_email. This prevents the LLM from accidentally (or via prompt injection) deleting tables or accessing unauthorized data.
2. Local-First Execution
Whenever possible, run MCP servers locally. If the server is running on the same machine as the Host, the data transfer happens over stdio. This ensures that the raw data from your database never travels over the internet to the model provider—only the specific snippets the model needs for its response are sent.
3. Input Validation
Treat every argument from the LLM as untrusted user input. Use libraries like Zod to validate the schema of the arguments before passing them to a database query or an API call.
import { z } from "zod"; const GetCustomerSchema = z.object({ customerId: z.string().uuid(), }); // Inside callTool handler: const result = GetCustomerSchema.safeParse(request.params.arguments); if (!result.success) throw new Error("Invalid arguments");
Deploying and Using MCP Servers
Once your server is built, you can use it in several ways:
- Claude Desktop: You can add your server to the
claude_desktop_config.jsonfile. This allows you to use your internal tools directly within the Claude interface. - Custom Clients: You can build a custom TypeScript application using the
@modelcontextprotocol/sdkto act as a host. This is ideal for building internal company bots. - Enterprise Hubs: For larger organizations, you can host MCP servers in Docker containers and connect to them via SSE, allowing a centralized repository of tools for all employees.
Conclusion: The Path Forward
The Model Context Protocol is more than just another API standard; it’s a fundamental shift in how we think about AI integration. By decoupling the model from the data source, we gain security, portability, and maintainability.
To get started with MCP today, I recommend the following steps:
- Identify a Data Silo: Find a local database or internal API that currently requires manual export-and-upload to use with an LLM.
- Build a Prototype: Use the TypeScript SDK to create a simple stdio server that exposes that data as a Resource.
- Iterate with Tools: Add specific, narrow tools to allow the LLM to interact with that data.
- Standardize: As you build more servers, create a shared library of schemas to ensure consistency across your organization.
The gap between "AI that talks" and "AI that works" is closing. MCP is the bridge that gets us there.