Building a closed vertical agent is still the reflex of many teams. You open a new repo, pick a framework, code the database integration, plug in an LLM and package it all into a CLI or web app. It works for one case. On the second one, the team discovers it rewrote half the code to run in another client.
The alternative: implement it once as an MCP server and consume it in Claude Desktop, Cursor, Windsurf and in your own agent without touching the server. This post has two goals. First, to explain when an MCP server beats a vertical agent (and when it does not, let's be honest). Second, to show how to build one from scratch in TypeScript, with the official SDK and code that runs.
MCP server vs vertical agent: the honest thesis
Before the clickbait, a calibration. An MCP server does not replace an agent. The agent is the loop (LLM + planner + execution), the MCP server is the layer that exposes capabilities (tools, data, prompts) to be consumed by any compatible client. The correct comparison is: MCP server vs building tools and vertical integrations inside each agent.
Four reasons to prefer an MCP server when the capability will be used by more than one client:
- Multi-client reuse: the same binary runs in Claude Desktop, Cursor, AI-enabled IDEs and in your custom agent. Without rewriting the integration.
- Clean separation: the server is a thin layer over your system. The agent handles reasoning, the server handles deterministic execution.
- Independent evolution: switching models (Claude to GPT) does not touch the server. Adding a tool to the server does not force a redeploy of the agent.
- Standardized discoverability: the client asks
tools/listand the server responds with a schema. No out-of-band documentation.
When it is NOT worth it: a one-shot application, an internal integration that nobody else will consume, or a loop with dense business logic that needs to live inside the agent. In those cases, a vertical agent is the way. Use the ruler: if more than one consumer will use this capability, build an MCP server.
Anatomy of an MCP server
An MCP server exposes three primitives. Tools: functions the LLM calls (create a ticket, query an API, run code). Resources: data the client reads (a file, a record, a database snapshot). Prompts: reusable templates the client can inject.
The transport defines how the client talks to the server. The two main ones: stdio (the server runs as a child process, communication over standard input and output, great for running locally in an IDE) and HTTP/SSE (the server runs as a network service, great for remote agents). Start with stdio. It is simpler and covers 80% of real cases.
Project setup
mkdir mcp-tickets && cd mcp-tickets
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
Adjust the package.json for ESM and add scripts.
{
"name": "mcp-tickets",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc"
}
}
Implementing the server with the first tool
Create src/server.ts. We will expose a create_ticket tool that creates a fictitious ticket. In your real version, this hits Linear, Jira or an internal endpoint.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
const server = new Server(
{ name: "mcp-tickets", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
const CreateTicketInput = z.object({
title: z.string().min(3),
priority: z.enum(["low", "medium", "high"]).default("medium"),
});
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "create_ticket",
description: "Cria um ticket de suporte com título e prioridade.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", minLength: 3 },
priority: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["title"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name !== "create_ticket") {
throw new Error(`tool desconhecida: ${req.params.name}`);
}
const args = CreateTicketInput.parse(req.params.arguments);
// aqui você faria a chamada real ao seu backend
const ticket = { id: `TKT-${Date.now()}`, ...args, status: "open" };
return {
content: [{ type: "text", text: JSON.stringify(ticket, null, 2) }],
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
Done. This file is a working MCP server. npm run dev brings up the process. It waits for the client to connect via stdio.
Connecting in Claude Desktop
To test it, add the server to the Claude Desktop config. On macOS, edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"tickets": {
"command": "npx",
"args": ["tsx", "/caminho/absoluto/mcp-tickets/src/server.ts"]
}
}
}
Restart Claude Desktop. Open a chat and ask: create a ticket with the title "stuck deploy" and priority high. Claude detects the create_ticket tool, asks for permission and runs it. The response comes back with the JSON of the created ticket, formatted by the agent.
Adding resources and serious validation
Tools take action. Resources expose data. Use resources when the LLM needs to read context, not act. Example: expose the list of open tickets as a resource, so any MCP client can read it.
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "tickets://open",
name: "Tickets abertos",
mimeType: "application/json",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
if (req.params.uri !== "tickets://open") {
throw new Error(`resource desconhecido: ${req.params.uri}`);
}
const open = [{ id: "TKT-1", title: "exemplo", status: "open" }];
return {
contents: [
{ uri: req.params.uri, mimeType: "application/json", text: JSON.stringify(open) },
],
};
});
A warning for production: the schema is a contract. Use Zod (or an equivalent) both on the input and on the output. If the LLM passes a wrong argument, return a structured error, not a raw exception. The MCP client passes it to the LLM as an observation, and it learns to correct itself.
Mistakes that kill a real MCP server
- Non-idempotent tools without warning: creating a ticket twice because the LLM retried. Use an idempotency key in the input or in the backend.
- Vague tool description: the LLM picks the wrong tool when two descriptions compete. Be specific and give an example in the
descriptionfield. - No timeout: a tool that calls a slow external API freezes the client. Set a timeout per tool and return a structured error.
- Auth in the wrong place: a stdio server assumes a local context; an HTTP server needs real auth (OAuth, mTLS). Do not expose HTTP without at least a bearer token and a rate limit.
- No structured logging: you need to correlate the client call, parameters, timing and result. Without logging, debugging becomes guessing.
The reframe: pattern before framework
Mature teams adopted MCP because they understood something simple: an open protocol beats a closed framework when you want to scale to multiple clients. The agent you build today will be replaced or complemented in two years. The MCP server you expose survives because it is just a standardized facade over your system, and the system rarely changes at the same speed as AI frameworks.
Start small. A server with one useful tool already opens the door for any MCP client on the market to consume your capability. It is the most underrated piece of today's AI stack.