Model Context Protocol (MCP): Building MCP Servers from Scratch
Model Context Protocol (MCP): Building MCP Servers from Scratch
The Model Context Protocol (MCP) standardizes how AI applications talk to external tools and data sources. Instead of every agent framework inventing its own tool-calling convention, MCP defines a client-server protocol: the AI host (an IDE, agent, or chat app) connects to MCP servers that expose tools (actions), resources (readable data), and prompts (reusable templates). This guide builds MCP servers from scratch and covers the decisions that matter in production.
The MCP Architecture
MCP has three roles:
- Host: the application the user interacts with (Orbital, an IDE, a chat app). It owns the LLM and the conversation.
- Client: one connection from the host to a server. A host can hold many clients.
- Server: exposes capabilities to the host. Servers are stateless by design; state lives in the host or in external systems.
┌─────────────┐ MCP Client ┌──────────────┐
│ Host │◄──────────────►│ MCP Server │
│ (Orbital) │ │ (your tool) │
└─────────────┘ └──────────────┘
The protocol is JSON-RPC 2.0 over one of two transports: stdio (local, spawned as a child process) or HTTP with Server-Sent Events (remote, over the network).
Transport Choice: stdio vs HTTP
| Transport | Use Case | Auth | Latency |
|---|---|---|---|
| stdio | Local tools, IDE integrations | None (local) | Lowest |
| HTTP/SSE | Remote services, multi-tenant | OAuth 2.0, keys | Network |
Start with stdio for anything that runs on the developer's machine. Move to HTTP when the server must be shared, deployed, or accessed by many hosts.
Building a Server with the TypeScript SDK
Install the SDK and scaffold a minimal server:
npm install @modelcontextprotocol/sdk zod
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "github-ops",
version: "1.0.0",
});
// A tool: an action the LLM can invoke
server.tool(
"get_issue",
"Fetch a GitHub issue by number",
{
owner: z.string(),
repo: z.string(),
issueNumber: z.number().int().positive(),
},
async ({ owner, repo, issueNumber }) => {
const issue = await fetchIssue(owner, repo, issueNumber);
return {
content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
};
},
);
// A resource: data the host can read (e.g. for context injection)
server.resource(
"repo-readme",
"README of the current repository",
async (uri) => ({
contents: [
{
uri: uri.href,
text: await readReadme(),
},
],
}),
);
// A prompt: a reusable template the host can insert into the conversation
server.prompt(
"review-pr",
"Generate a code review checklist for a PR",
{ prNumber: z.number() },
({ prNumber }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Review PR #${prNumber}. Focus on correctness, security, and test coverage.`,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
The zod schemas do double duty: they validate arguments and generate the JSON Schema the host sends to the LLM, which is what lets the model call your tool with correct arguments.
The Python Equivalent
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("github-ops")
@mcp.tool()
def get_issue(owner: str, repo: str, issue_number: int) -> str:
"""Fetch a GitHub issue by number."""
issue = fetch_issue(owner, repo, issue_number)
return json.dumps(issue, indent=2)
@mcp.resource("repo://readme")
def repo_readme(uri: str) -> str:
return read_readme()
if __name__ == "__main__":
mcp.run() # stdio by default; use mcp.run(transport="sse") for HTTP
Tools vs Resources vs Prompts
- Tools are for actions with side effects: create an issue, deploy a service, run a query. The LLM decides when to call them.
- Resources are read-only data the host can pull into context: a file, a schema, a config. They answer "what does the model need to know?"
- Prompts are templates that package a workflow: "review this PR", "explain this error". They give users a one-click way to start a task.
A common mistake is exposing everything as a tool. If the model needs a file's contents to answer, that is a resource, not a tool. Tools should be verbs; resources should be nouns.
Error Handling and Tool Design
Tools fail. Return structured errors instead of throwing, so the LLM can recover:
server.tool(
"deploy_service",
"Deploy a service to staging",
{ service: z.string() },
async ({ service }) => {
try {
const result = await deploy(service);
return { content: [{ type: "text", text: `Deployed: ${result.url}` }] };
} catch (err) {
return {
content: [
{ type: "text", text: `Deploy failed: ${(err as Error).message}` },
],
isError: true,
};
}
},
);
Design rules that keep tools safe and useful:
- Narrow scopes: one tool, one job.
get_issueandcreate_issueare separate tools. - Validate aggressively: zod schemas reject bad arguments before your code runs.
- Never trust the model's input: treat every argument as attacker-controlled. No shell interpolation, no raw SQL.
- Return enough context: include IDs, URLs, and status so the model can act on the result without guessing.
- Cap expensive operations: add timeouts and result limits to every tool that touches external systems.
Security Model
MCP servers are a new attack surface: the LLM is now a user of your APIs. The OWASP LLM guidance applies directly:
- Authorization: the server must enforce its own permissions. The host's user may not have rights to every tool. Check identity and scope per call.
- Confirmation for destructive actions: tools that delete, overwrite, or spend money should require explicit confirmation from the human, not just the model.
- Input validation: validate every argument against an allowlist where possible. A tool that accepts a URL should validate the scheme and host.
- Secrets: never return secrets in tool output. Redact tokens, keys, and internal URLs before they reach the model.
- Prompt injection: tool output flows back into the model's context. Treat tool results as untrusted data and instruct the model to ignore instructions found in them.
Connecting a Host
Register the server in your host's config. For Orbital and most IDE-based hosts, a local config file points at the command:
{
"mcpServers": {
"github-ops": {
"command": "node",
"args": ["dist/server.js"],
"env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
}
}
}
For remote servers, use the URL form:
{
"mcpServers": {
"prod-db": {
"url": "https://mcp.example.com/db",
"headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
}
}
}
Implementation Checklist
- Choose stdio for local tools, HTTP/SSE for shared services
- Define tools as narrow verbs with zod-validated arguments
- Expose read-only data as resources, not tools
- Return structured errors with
isErrorso the LLM can recover - Enforce authorization inside the server, per tool
- Require human confirmation for destructive actions
- Redact secrets from all tool output
- Treat tool results as untrusted input (prompt injection defense)
- Add timeouts and result caps to every external call
- Test with a real host before shipping
MatterAI builds frontier AI infrastructure for engineering teams — from inference-optimized models to autonomous coding agents and agentic code reviews.
Explore what we're building:
- Orbital IDE — Autonomous AI coding agent with background agents and deep codebase memory
- AI Code Reviews — Agentic pre-commit reviews across GitHub, GitLab, and Bitbucket
- Axon Models — Frontier-grade reasoning models at 70% lower inference cost
Share this Guide:
More Guides
RAG vs Fine-Tuning: When to Use Each for Your LLM Application
Decide between retrieval-augmented generation and fine-tuning with a practical decision framework. Compare cost, latency, freshness, and accuracy, and see working implementations of both approaches.
13 min readGrafana Stack vs SigNoz: Choosing an OpenTelemetry-Native Observability Platform
Compare the Grafana LGTM stack (Loki, Tempo, Mimir) with SigNoz for OpenTelemetry observability: architecture, storage engines, operational overhead, cost at scale, and migration paths.
10 min readLLM Fine-Tuning: LoRA vs QLoRA vs Full Fine-Tuning
Compare full fine-tuning, LoRA, and QLoRA for LLMs with memory requirements, training recipes, and code. Learn which method fits your GPU budget, dataset size, and quality requirements.
14 min readPrompt Injection Defense: Securing LLM Apps Against Jailbreaks, RAG Poisoning, and Tool-Use Exploits
Defend production LLM applications against direct jailbreaks, indirect injection via RAG pipelines, and tool-use exploits with layered defenses, input filtering, and least-privilege tool design.
10 min readRate Limiting and Backpressure: Token Bucket vs Leaky Bucket, Distributed Rate Limiters with Redis
Implement rate limiting and backpressure for high-traffic APIs with token bucket and leaky bucket algorithms, sliding window counters, and atomic distributed rate limiters using Redis and Lua.
9 min readContinue Reading
RAG vs Fine-Tuning: When to Use Each for Your LLM Application
Decide between retrieval-augmented generation and fine-tuning with a practical decision framework. Compare cost, latency, freshness, and accuracy, and see working implementations of both approaches.
13 min readGrafana Stack vs SigNoz: Choosing an OpenTelemetry-Native Observability Platform
Compare the Grafana LGTM stack (Loki, Tempo, Mimir) with SigNoz for OpenTelemetry observability: architecture, storage engines, operational overhead, cost at scale, and migration paths.
10 min readLLM Fine-Tuning: LoRA vs QLoRA vs Full Fine-Tuning
Compare full fine-tuning, LoRA, and QLoRA for LLMs with memory requirements, training recipes, and code. Learn which method fits your GPU budget, dataset size, and quality requirements.
14 min readShip Faster. Ship Safer.
Join thousands of engineering teams using MatterAI to autonomously build, review, and deploy code with enterprise-grade precision.
