MCP Servers: When curl Becomes a Trap and Typed Tools Solve It
In February 2024, I needed an agent to query transactions directly in the staging Postgres database. The REST API added three abstraction layers that made debugging impossible. The obvious solution: give it Bash and let it run psql.
It worked on the first call. By the tenth, the agent was building queries by hand, escaping parameters incorrectly, breaking on the third table with an unexpected naming convention. It was improvisation disguised as automation. It took me three days to understand that the problem wasn't the agent: it was that I hadn't given it the right tool for the job.
Native harness tools cover the case where the agent works with local code. But when you want the agent to update a Jira card, query logs in Grafana, run a prod database query, or send a Slack message? The first temptation is to give it Bash and let it call curl for each API. It works poorly: the agent has to remember the endpoint, build headers, parse responses, handle auth tokens. Each call becomes improvisation. Silent errors. No type safety.
MCP, the Model Context Protocol, solves this. It's an open protocol that defines how an external process exposes tools to the agent. The agent discovers the tools, their JSON Schema signatures, and calls them as if they were native. The harness becomes the bridge.
The piece in one sentence
An MCP server is a process that speaks a standardized protocol to: (1) describe the tools it offers, (2) execute those tools when the agent calls them. To the agent, it's all just tool calls. It doesn't matter whether they're native to the harness or came from an MCP server.
A "Jira" MCP server exposes jira_create_issue, jira_search, jira_update_status. The agent sees these tools alongside Read, Bash, etc. It calls them the same way. The harness routes them.
stdio vs HTTP
MCP servers connect to the harness via one of two transports:
stdio: the server is a subprocess. The harness launches the server and exchanges JSON-RPC messages via stdin/stdout. The process dies when the session ends. Use when the server runs locally, without network dependency, without needing to be shared across sessions.
HTTP: the server is a web service. The harness connects via HTTP/SSE. You manage the server lifecycle separately. Use when the server runs remotely, multiple agents share it, or the server needs persistent state.
90% of MCP servers you'll use start as stdio. HTTP only when you genuinely need it.
Configuration
In Claude Code, MCP servers are declared in ~/.mcp.json:
{
"mcpServers": {
"postgres-prod": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres",
"postgresql://readonly@db.internal/app"]
},
"github": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "ghp_xxxxxxxx" }
}
}
}
When the session starts, the harness reads the file, launches the stdio servers, and discovers the tools from each one. The agent sees everything prefixed with the server name: mcp__postgres-prod__query, mcp__github__create_issue.
What makes a tool good
A badly designed MCP server is worse than none: the agent trips over its own tools.
Verbal, specific names: create_issue, not issue or do_jira_thing. The model reads the verb and decides.
Descriptions that teach discrimination: "Search for issues by JQL query. Use this when you have specific filters. For simple keyword search, use search_text instead." The description teaches when to use it and when not to.
Strict schema: required always marked. Precise types with enum when options are closed. Descriptions per field.
Structured results: return structured JSON, not long text. The model extracts better from {"count": 5, "results": [...]} than from "I found 5 results, they are: ..."
Anti-pattern: MCP for everything
The inverse pattern of "Bash for everything": creating MCP servers for every API you use. Now you have 12 servers, each with 50 tools, and the agent's context window is saturated with tool descriptions before the first message.
The rule: MCP server for things you'll use frequently and where type safety matters. One-off operation against an obscure API? curl via Bash is cheaper. Recurring workflow against Postgres? MCP server is worth it.
Another variation: a catch-all tool like execute(action: string) that delegates everything to a string field. The model loses type safety. Break it into specific tools with specific schemas.
MCP in action
Scenario: operator reports "user X didn't receive the confirmation email." Agent has MCP servers available for postgres-prod, mailgun, sentry, slack.
agent -> mcp__postgres-prod__query
"SELECT id, email, created_at FROM users WHERE email = 'user@x.com'"
result -> {user_id: 4421, email: ..., created_at: "2026-05-20"}
agent -> mcp__mailgun__list_events
{recipient: "user@x.com", since: "2026-05-20"}
result -> {events: [{event: "bounce", reason: "550 user unknown"}]}
agent -> final text
"Email bouncing with 550 user unknown -- invalid address.
Sentry shows no errors on our side. The system worked correctly.
Contact the user to confirm their email address."
Four calls, three different MCP servers, structured diagnosis. No hand-building queries, no curl plus JSON parsing, no type surprises. That's the point.
Top comments (0)