Build a Production-Ready Model Context Protocol Server for Private Developer Tools
Introduction: What Is MCP and Why Use It?
The Model Context Protocol, usually shortened to MCP, is becoming one of the most important standards in AI-assisted software development. In simple terms, MCP gives AI applications a consistent way to call external tools, read contextual resources, and interact with real systems without every vendor inventing a different plugin format.
Think of it as a USB-C style interface for AI tools.
Instead of hard-coding custom integrations for your issue tracker, internal API docs, deployment logs, feature flag service, database schema, or CI system, you expose those capabilities through an MCP server. Any compatible AI client can then discover and use them.
For developers, the most useful pattern is this: build a private MCP server that gives your coding assistant controlled access to your engineering environment.
That does not mean giving the model free rein over production. Quite the opposite. A good MCP server defines narrow, auditable tools with strict input validation. The model can ask for information or trigger approved actions, but your server remains the policy boundary.
In this tutorial, we will build a practical MCP server in TypeScript that exposes two internal developer tools:
search_service_docs: searches local service documentationget_recent_deploys: returns recent deployment records for a service
You can adapt the same structure for GitHub, Jira, Linear, Datadog, Sentry, Kubernetes, internal catalogs, or incident tooling.
This is not a toy chatbot tutorial. The goal is a small but production-shaped MCP implementation: typed inputs, clear errors, local development flow, and security guardrails.
Prerequisites
You should be comfortable with basic TypeScript and Node.js. You do not need deep MCP experience.
Install these first:
- Node.js 20 or newer
- npm, pnpm, or yarn
- A code editor
- An MCP-compatible client such as Claude Desktop, Cursor, Windsurf, or another developer AI client that supports MCP server configuration
We will use npm in the examples.
You also need a project folder:
mkdir private-devtools-mcp
cd private-devtools-mcp
npm init -yInstall dependencies:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/nodeCreate a TypeScript config:
npx tsc --init --rootDir src --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022Update package.json so Node treats files as ES modules and gives us a dev command:
{
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}Your exact package.json can include other fields. The important pieces are type, dev, build, and start.
Step-by-Step Guide
Step 1: Create Local Data Sources
In real life, your MCP server would call internal APIs. For this tutorial, we will use local JSON files so the architecture is easy to test.
Create this structure:
mkdir -p src dataCreate data/docs.json:
[
{
"service": "billing-api",
"title": "Refund workflow",
"body": "Refunds are created through POST /refunds. The request must include payment_id, amount, reason, and idempotency_key. Refunds over 5000 require manual approval."
},
{
"service": "billing-api",
"title": "Invoice retry policy",
"body": "Failed invoice payments are retried after 6 hours, 24 hours, and 72 hours. After the third failure, the account enters payment_required state."
},
{
"service": "identity-service",
"title": "Session invalidation",
"body": "Sessions are invalidated by publishing a user.session.revoked event. Edge caches may take up to 60 seconds to reflect revocation."
}
]Create data/deploys.json:
[
{
"service": "billing-api",
"version": "2025.02.18.4",
"environment": "production",
"status": "success",
"deployed_at": "2025-02-18T16:24:00Z"
},
{
"service": "billing-api",
"version": "2025.02.17.2",
"environment": "production",
"status": "rolled_back",
"deployed_at": "2025-02-17T21:10:00Z"
},
{
"service": "identity-service",
"version": "2025.02.18.1",
"environment": "staging",
"status": "success",
"deployed_at": "2025-02-18T11:03:00Z"
}
]This gives us enough data to prove the pattern.
Step 2: Implement Data Access Helpers
Create src/data.ts:
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export type ServiceDoc = {
service: string;
title: string;
body: string;
};
export type DeployRecord = {
service: string;
version: string;
environment: "staging" | "production";
status: "success" | "failed" | "rolled_back";
deployed_at: string;
};
async function readJson<T>(path: string): Promise<T> {
const file = await readFile(resolve(process.cwd(), path), "utf8");
return JSON.parse(file) as T;
}
export async function searchDocs(service: string, query: string): Promise<ServiceDoc[]> {
const docs = await readJson<ServiceDoc[]>("data/docs.json");
const normalizedQuery = query.toLowerCase();
return docs.filter((doc) => {
return (
doc.service === service &&
`${doc.title} ${doc.body}`.toLowerCase().includes(normalizedQuery)
);
});
}
export async function getRecentDeploys(service: string, limit: number): Promise<DeployRecord[]> {
const deploys = await readJson<DeployRecord[]>("data/deploys.json");
return deploys
.filter((deploy) => deploy.service === service)
.sort((a, b) => Date.parse(b.deployed_at) - Date.parse(a.deployed_at))
.slice(0, limit);
}This keeps business logic separate from the MCP protocol layer. That matters later when you add tests, real API clients, caching, and authorization.
Step 3: Build the MCP Server
Create src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { getRecentDeploys, searchDocs } from "./data.js";
const server = new McpServer({
name: "private-devtools-mcp",
version: "1.0.0"
});
server.tool(
"search_service_docs",
"Search internal documentation for a specific service.",
{
service: z.string().min(1).describe("Exact service name, such as billing-api"),
query: z.string().min(2).max(120).describe("Search query for the docs")
},
async ({ service, query }) => {
const results = await searchDocs(service, query);
if (results.length === 0) {
return {
content: [
{
type: "text",
text: `No documentation matches found for service '${service}' and query '${query}'.`
}
]
};
}
return {
content: [
{
type: "text",
text: JSON.stringify({ results }, null, 2)
}
]
};
}
);
server.tool(
"get_recent_deploys",
"Return recent deploy records for a service.",
{
service: z.string().min(1).describe("Exact service name, such as billing-api"),
limit: z.number().int().min(1).max(10).default(5).describe("Maximum records to return")
},
async ({ service, limit }) => {
const deploys = await getRecentDeploys(service, limit);
return {
content: [
{
type: "text",
text: JSON.stringify({ deploys }, null, 2)
}
]
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);This is the core of the MCP server.
A few details are worth calling out:
McpServerdefines the server identity and capability registry.server.tool()exposes callable tools to the AI client.zodschemas validate model-provided arguments before your logic runs.StdioServerTransportlets local AI clients launch and communicate with your server over standard input and output.
That last point is important. For many local developer workflows, stdio is simpler and safer than exposing an HTTP service.
Step 4: Run the Server Locally
Build the project:
npm run buildThen run it:
npm run devYou may not see much output. That is normal for stdio-based MCP servers. The server is meant to be launched by an MCP client, not used directly as a web app.
If TypeScript complains about module resolution, re-check that tsconfig.json uses NodeNext for both module and moduleResolution.
Step 5: Connect the Server to an MCP Client
Each MCP-compatible client has its own configuration format, but the local stdio pattern is usually similar.
A typical configuration looks like this:
{
"mcpServers": {
"private-devtools": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/private-devtools-mcp/src/server.ts"]
}
}
}For a built version, use Node directly:
{
"mcpServers": {
"private-devtools": {
"command": "node",
"args": ["/absolute/path/to/private-devtools-mcp/dist/server.js"]
}
}
}Restart your AI client after adding the configuration.
Then ask a prompt like:
Use the private-devtools MCP server to find billing-api documentation about refund approvals. Then check whether billing-api had any recent production deploys that rolled back.A capable MCP client should discover the tools, call search_service_docs, then call get_recent_deploys, and synthesize an answer.
That is where MCP becomes powerful. The model does not need your entire documentation corpus in the prompt. It can request specific context at the moment it needs it.
Step 6: Add Safer Error Handling
Production developer tools fail. APIs time out. Data is missing. Tokens expire. Your MCP server should return helpful structured errors instead of dumping stack traces.
Update src/server.ts with a helper:
function errorText(message: string) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ error: message }, null, 2)
}
],
isError: true
};
}Then wrap tool handlers:
async ({ service, query }) => {
try {
const results = await searchDocs(service, query);
return {
content: [
{
type: "text",
text: JSON.stringify({ results }, null, 2)
}
]
};
} catch {
return errorText("Unable to search service documentation.");
}
}Do not return raw exception messages from internal systems. They can expose file paths, hostnames, SQL fragments, vendor names, or security-sensitive implementation details.
Step 7: Add Authorization Boundaries
Local MCP servers often run with the developer's permissions. That is convenient, but risky.
At minimum, add an allowlist for services:
const ALLOWED_SERVICES = new Set(["billing-api", "identity-service"]);
function assertAllowedService(service: string) {
if (!ALLOWED_SERVICES.has(service)) {
throw new Error("Service is not allowed");
}
}Use it inside every tool:
async ({ service, limit }) => {
try {
assertAllowedService(service);
const deploys = await getRecentDeploys(service, limit);
return {
content: [
{
type: "text",
text: JSON.stringify({ deploys }, null, 2)
}
]
};
} catch {
return errorText("Unable to retrieve deploy records for the requested service.");
}
}In a real enterprise MCP server, authorization should usually be based on the human user, not the model. Good patterns include:
- Read the user's SSO identity from a broker process.
- Exchange short-lived tokens instead of storing long-lived API keys.
- Scope tools by team, repository, service, environment, and action.
- Log tool calls with user identity, input parameters, and outcome.
- Require human approval for destructive operations.
MCP should make AI agents more useful, not less accountable.
Step 8: Add a Prompt Template Resource
MCP can expose more than tools. It can also provide prompts that guide repeatable workflows.
Add this to src/server.ts:
server.prompt(
"investigate_recent_service_change",
"Investigate recent docs and deploy activity for a service.",
{
service: z.string().min(1),
symptom: z.string().min(3).max(300)
},
({ service, symptom }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Investigate this service issue: ${symptom}\n\nService: ${service}\n\nFirst search relevant service docs. Then inspect recent deploys. Explain the most likely connection and list what a senior engineer should check next.`
}
}
]
})
);Now your MCP server does not just expose raw tools. It also provides a reusable investigation workflow.
This is especially useful for platform teams. You can encode operational habits into prompt templates without stuffing every rule into a global system prompt.
Common Pitfalls
Pitfall 1: Making Tools Too Broad
A tool named run_shell_command is tempting. It is also dangerous.
Prefer narrow tools with explicit intent, such as get_recent_deploys, query_error_rate, or open_incident_summary. Narrow tools are easier to validate, easier to audit, and easier for models to use correctly.
Pitfall 2: Skipping Input Validation
Never trust model-generated arguments. Even when the model is not malicious, it can pass malformed data, overly large limits, ambiguous service names, or unexpected strings.
Use schemas. Set length limits. Prefer enums when possible.
Pitfall 3: Returning Too Much Context
Dumping 200 logs into the response feels helpful, but it often makes the model worse. Return concise, relevant, structured data. Add pagination or filtering for large result sets.
A strong MCP server retrieves context surgically.
Pitfall 4: Treating Local Means Safe
Local developer MCP servers can still access credentials, source code, SSH keys, browser sessions, and internal systems. If the AI client can trigger the server, the server must enforce policy.
Keep secrets out of prompts and tool responses. Use short-lived credentials where possible.
Pitfall 5: No Observability
You need logs. At minimum, capture:
- Tool name
- User identity if available
- Timestamp
- Sanitized arguments
- Success or failure
- Latency
For enterprise AI developer tools, MCP audit logging is not optional. It is how you debug bad outputs, investigate misuse, and prove compliance.
FAQ
Is MCP only for Claude?
No. MCP was popularized through Anthropic's ecosystem, but the protocol is designed as an open integration pattern. Support is spreading across AI coding assistants, agent frameworks, and developer tools.
Should I use MCP instead of a normal REST API?
Use both when appropriate. MCP is the interface between AI clients and tools. Your MCP server can call existing REST, GraphQL, gRPC, database, or message queue systems behind the scenes.
Can an MCP server modify production systems?
Technically, yes. Practically, be careful. Start with read-only tools. For write actions, require human approval, strict authorization, idempotency keys, dry-run modes, and detailed audit logs.
What is the best language for building an MCP server?
TypeScript and Python are the most common choices right now because the SDK support and developer workflows are mature. Use the language your platform team can maintain.
How is this different from function calling?
Function calling is usually model-provider-specific. MCP is a client-to-tool protocol that lets multiple AI applications discover and call tools through a standard interface. In practice, MCP is better suited for reusable private integrations across teams.
Should every team build its own MCP server?
Usually no. A better model is to let platform or developer experience teams provide shared MCP servers for common systems, while product teams contribute narrowly scoped tools for domain-specific workflows.
Final Thoughts
A private MCP server is one of the most practical ways to make AI coding assistants useful inside real engineering environments. It gives the model fresh context, but keeps control in your hands.
The key is discipline. Start with read-only tools. Validate every input. Return structured data. Add authorization early. Log everything that matters.
Once that foundation is in place, you can expand into richer workflows: incident triage, pull request context gathering, service ownership lookup, test failure analysis, release readiness checks, and internal API discovery. That is where MCP starts to feel less like another integration layer and more like a serious operating interface for AI-native software teams.