Building Custom MCP Servers: Bridging the Gap Between LLMs and Your Local Stack
Large Language Models (LLMs) have become incredibly capable at writing code and reasoning through logic, but they suffer from a fundamental limitation: they are isolated. Out of the box, an AI agent knows nothing about your internal company APIs, your local database schema, or the specific business logic hidden in your private repositories.
To bridge this gap, we’ve historically relied on brittle copy-pasting or building complex, bespoke integrations for every new project. The Model Context Protocol (MCP), recently introduced by Anthropic, changes this. It provides an open-standard interface that allows AI models to interact with external data sources and tools securely and consistently.
As a senior engineer, your goal isn't just to use AI, but to build the infrastructure that makes AI effective for your team. In this guide, we will walk through the architecture of MCP and implement a custom MCP server from scratch using TypeScript.
Understanding the MCP Architecture
Before we dive into the code, we need to understand the relationship between the three main components of the protocol:
- The MCP Host: This is the client application where the user interacts with the AI. Examples include Claude Desktop, IDEs like VS Code (via extensions), or custom-built internal portals.
- The MCP Server: This is a lightweight process (usually running locally or in a container) that exposes specific capabilities—resources, tools, and prompts—to the host.
- The LLM: The model itself, which communicates with the host. The LLM doesn't talk to the server directly; the host acts as the orchestrator, passing messages back and forth.
The communication happens over a standardized transport layer, typically JSON-RPC 2.0 over stdio (standard input/output) for local servers or SSE (Server-Sent Events) for remote ones. This design is brilliant because it means your server doesn't need to handle complex authentication or public networking if it's running locally; it just needs to read from stdin and write to stdout.
The Three Pillars: Resources, Tools, and Prompts
An MCP server provides value through three primary primitives:
- Resources: These are like GET endpoints. They provide read-only data to the model. Think of them as files, database records, or API responses. They have URIs (e.g.,
db://main/users). - Tools: These are like POST endpoints. They allow the model to perform actions that have side effects, such as sending an email, triggering a CI/CD build, or executing a database query. Tools have a defined JSON Schema for their arguments.
- Prompts: These are reusable templates that help users interact with the model. They allow you to standardize how the AI should approach specific tasks within your domain.
Building a Custom MCP Server: The "Internal API Explorer"
Let’s build a practical example. Imagine your company has an internal REST API for managing project deployments. We want to give an AI agent the ability to check deployment status and trigger a rollback if necessary.
Step 1: Project Setup
We'll use the official TypeScript SDK provided by Anthropic. Start by initializing a new Node.js project:
mkdir mcp-deployment-server cd mcp-deployment-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node npx tsc --init
Step 2: Defining the Server
Create an index.ts file. We will initialize the Server class and define our capabilities. Note that we use zod for runtime type safety, which is essential when dealing with LLM-generated inputs.
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; const server = new Server( { name: "deployment-manager", version: "1.0.0", }, { capabilities: { tools: {}, }, } );
Step 3: Implementing a Tool
Now, let's implement a tool called get_deployment_status. This tool will mock an internal API call to return the status of a specific service.
server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get_deployment_status", description: "Get the current status of a production service deployment", inputSchema: { type: "object", properties: { serviceName: { type: "string", description: "The name of the service (e.g., 'auth-api')" }, }, required: ["serviceName"], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "get_deployment_status") { const { serviceName } = z.object({ serviceName: z.string(), }).parse(request.params.arguments); // In a real scenario, this would be a fetch() to your internal API const status = serviceName === "auth-api" ? "Healthy" : "Degraded"; return { content: [ { type: "text", text: `The status of ${serviceName} is currently: ${status}`, }, ], }; } throw new McpError(ErrorCode.MethodNotFound, "Tool not found"); });
Step 4: Starting the Transport
Finally, we connect the server to the standard input/output transport.
async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Deployment MCP Server running on stdio"); } main().catch((error) => { console.error("Server error:", error); process.exit(1); });
Note: We use console.error for logging because stdout is reserved for the JSON-RPC protocol communication. If you print to stdout, you will break the connection between the host and the server.
Security and Safety Considerations
When you build an MCP server, you are essentially giving an LLM a set of "hands" to interact with your environment. This requires a shift in how we think about security.
1. The Principle of Least Privilege
Your MCP server should only have the permissions it absolutely needs. If a tool is meant to read logs, don't give the underlying service account permission to delete them.
2. Human-in-the-Loop (HITL)
For high-impact actions (like trigger_rollback or delete_database), the MCP host (like Claude Desktop) will typically ask the user for confirmation before executing the tool. However, your server should also implement its own validation. Never trust that the input coming from the LLM is well-formatted or malicious-free.
3. Input Validation
Always use schema validation (like Zod) to ensure the arguments passed to your tools match your expectations. LLMs can occasionally "hallucinate" arguments or pass types that your backend doesn't expect.
Integrating with Claude Desktop
To test your server, you can integrate it with the Claude Desktop app. You’ll need to modify your claude_desktop_config.json file (typically found in ~/Library/Application Support/Claude/ on macOS or %APPDATA%\Claude\ on Windows).
{ "mcpServers": { "deployment-manager": { "command": "node", "args": ["/path/to/your/server/index.js"] } } }
Once configured, restart Claude. You will see a hammer icon indicating that the tools are available. You can now ask: "What is the status of the auth-api service?" and Claude will call your local script, receive the "Healthy" status, and report it back to you in natural language.
Beyond Localhost: Remote MCP Servers
While stdio is perfect for local development and personal productivity, enterprise use cases often require remote MCP servers. In these scenarios, you would use the SSE (Server-Sent Events) transport.
This allows you to host a centralized MCP server on your internal cloud (e.g., AWS ECS or Kubernetes). Your developers can then connect their local IDEs or AI clients to this centralized server, ensuring everyone is using the same version of the tools and that access is governed by your corporate Identity Provider (IdP).
Best Practices for Senior Engineers
As you begin implementing MCP across your organization, keep these architectural tips in mind:
- Modularize your Servers: Don't build one monolithic MCP server for the entire company. Create small, domain-specific servers (e.g.,
sentry-mcp-server,jira-mcp-server,db-schema-server). This makes them easier to maintain and allows users to pick only the tools they need. - Provide Rich Metadata: The description field in your tool definition is the most important part. The LLM uses this description to decide when and how to use the tool. Be explicit. Instead of "gets user data," use "retrieves user profile information, including subscription status and last login date, using a unique user ID."
- Handle Errors Gracefully: When a tool fails, return a descriptive error message. The LLM can often use that error message to self-correct. For example, if a database query fails because of a typo in a table name, the LLM might see the error, check the schema resource, and try again with the correct name.
Conclusion: The Actionable Path Forward
The Model Context Protocol is the missing link in the AI development stack. It moves us away from generic chatbots and toward specialized agents that understand our specific workflows.
To get started:
- Identify a repetitive read-only task in your workflow (e.g., checking PR status, looking up API documentation, or querying a read-replica database).
- Build a simple MCP server using the TypeScript SDK to expose that data as a Resource.
- Connect it to your local AI client to experience the immediate lift in productivity.
By building these bridges today, you are preparing your infrastructure for a future where AI agents aren't just tools we talk to, but active participants in our development lifecycle.