Agent infrastructure
Platform integration
A practical guide to MCP servers: transport choice, scaffolding, tool design, auth, observability, and the production pitfalls that catch most teams.

MCP servers expose tools, resources, and prompts to AI agents over a defined protocol. By the end of this guide you'll have a working MCP server running locally, understand the architecture choices that matter in production, and know which trade-offs to make before you wire it into a real agent.
Prerequisites: Node.js 20+ or Python 3.10+, basic familiarity with JSON-RPC, and an API or codebase you want to expose. Time required: about 45 minutes for the local setup, longer if you're hardening for production.
This guide assumes you already know what Model Context Protocol servers are at a conceptual level. If you don't, start there and come back.
MCP servers speak JSON-RPC 2.0 over one of two transports: stdio or Streamable HTTP (which can optionally upgrade to Server-Sent Events for streaming responses). The older HTTP+SSE transport from the 2024-11-05 spec was deprecated in the March 2025 revision in favour of Streamable HTTP — if you read older guides referencing a separate SSE endpoint, that's why. The choice locks in deployment, auth, and how clients discover you.
Use stdio when the server runs as a subprocess of the agent host (Claude Desktop, a local IDE plugin, a single-user CLI). It's the simplest path. No network surface, no auth layer, the host manages lifecycle.
Use Streamable HTTP when the server runs as a service multiple clients connect to. You need this for any production deployment where agents run remotely, where you want central observability, or where the server holds credentials for a backend system.
Don't try to support both from day one. The auth and lifecycle models differ enough that you'll end up with two implementations sharing a name.
Install the official SDK from the Model Context Protocol project. For TypeScript:
npm install @modelcontextprotocol/sdk
For Python:
pip install "mcp[cli]"
Create a minimal server that registers one tool. TypeScript:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "example-server", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: "get_customer",
description: "Fetch a customer record by ID",
inputSchema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"]
}
}]
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === "get_customer") {
const args = req.params.arguments as { id: string };
return { content: [{ type: "text", text: JSON.stringify({ id: args.id, status: "active" }) }] };
}
throw new Error("Unknown tool");
});
const transport = new StdioServerTransport();
await server.connect(transport);
Note that the low-level Server API takes a Zod request schema — not a method-name string — as the first argument to setRequestHandler. If you prefer the higher-level ergonomics, the SDK also exposes McpServer with registerTool(...), which hides this plumbing.
Run it. The process should start, write nothing to stdout (stdout is the JSON-RPC channel — logs go to stderr), and wait for input.
Don't connect to a real agent yet. Use MCP Inspector to drive the server directly and watch the JSON-RPC traffic.
npx @modelcontextprotocol/inspector node ./build/server.js
Inspector opens a browser UI. Click through the tools list, invoke get_customer with a test ID, and confirm the response shape. If the call fails, the JSON-RPC log shows you exactly which message broke and where. We cover this workflow in more detail in our MCP Inspector debugging guide.
Expected output: a green tick on tools/list, your tool name visible in the panel, and a successful tools/call round-trip with the JSON payload you returned.
This is where most MCP server projects go wrong. The temptation is to wrap every API endpoint you have as a tool. Don't.
Agents reason about tools by name, description, and schema. A list of 200 thinly-described CRUD endpoints produces worse agent behaviour than 30 task-shaped tools with clear semantics. Group operations by the job the agent is trying to do.
Write descriptions as if to a new engineer who's never seen your product. "Fetch a customer record by ID" is fine; "Use this when you have a confirmed customer ID and need the current account status, plan tier, and contact details" is better.
For stdio servers, auth is whatever the host passes in via environment variables or config. Read the credential at startup, fail fast if it's missing.
For remote servers, the MCP authorization spec (2025-06-18 onward) defines the server as an OAuth 2.1 resource server. Clients discover the authorization server via RFC 9728 Protected Resource Metadata (served at /.well-known/oauth-protected-resource), complete an OAuth 2.1 + PKCE flow — PKCE is mandatory as of the 2025-11-25 revision — and present the resulting bearer token, scoped with an RFC 8707 resource indicator, on each request. Unauthenticated requests get a 401 with a WWW-Authenticate header pointing at the metadata document. The server's job is then to map that token to a user, then invoke the backend as that user — not as a shared service account.
This matters more than it looks. If every tool call hits your backend as mcp-service-account, you've broken the permission model of the underlying product. The agent can read and write things the authenticated user shouldn't be able to. Audit trails point at the service account, not the human. Multi-tenant data leaks become a single bug away.
The rule: tool calls execute as the authenticated user, always. If your backend doesn't have an identity-delegation pattern (OAuth on-behalf-of, signed user assertions, JWT exchange), build one before you ship the MCP server.
Log every tool invocation with: timestamp, tool name, authenticated user, input arguments (redact secrets), backend latency, and result status. Send these to your existing observability stack — not to a bespoke MCP log file.
At minimum, structure the log line so you can answer three questions in under a minute:
Without this, the first production incident becomes a forensic exercise. With it, you treat MCP traffic like any other API surface.
MCP servers aren't write-once. Every time the backend changes — a new field, a renamed endpoint, a tightened permission — the tool definition has to keep up. If it doesn't, the agent calls a tool that succeeds at the protocol level and returns wrong data. That's the worst failure mode in agent systems: silent, plausible, and only caught downstream.
Wire your MCP server into the same CI that protects your backend. Contract tests against the tools list. Schema validation on every release. A canary tool call as part of deploy verification. The maintenance burden of a hand-built MCP server scales linearly with the size of the surface it exposes — which is the same compounding problem we covered in the hidden cost of bespoke agent connectors.
The steps above describe building one MCP server by hand. That's the right place to start when you have a focused use case and one product surface to expose. It gets harder fast when you have several products, a large API surface, and a moving backend — the hand-built path stops compounding.
Pontil takes a different route for established SaaS companies in that position. We generate the tools layer from the codebases you already have, run it as a managed runtime where each call executes as the authenticated user, and keep tool definitions current as your products change. The protocol the agent speaks — MCP today, something else tomorrow — sits behind the runtime rather than driving the architecture.
If you're staring at a portfolio of products and the MCP server count is climbing faster than the team can maintain, that's the conversation to have. Book a walkthrough and we'll show you the generated tools against one of your own services.
console.log corrupts the JSON-RPC stream and the client disconnects with no useful error. Always log to stderr.Stay up to date on the ever changing agentic landscape.