Agentic AI

    What Broke in My Production MCP Servers

    Lessons from running MCP servers on Esferas, UseFireApp, and Log Cannon: routing traps, auth tradeoffs, tool text that lies, and errors a model can act on.

    9 min read

    I run several MCP servers that customers and agents call every day. The largest is on Esferas, the LinkedIn outreach product I co-founded: nine tools plus a ping, served over Streamable HTTP at /mcp, used by customers who paste their own API key into Claude or Cursor. UseFireApp has an operator MCP server that my own agents drive, and Log Cannon exposes search and query tools so an agent can investigate an alert by reading the logs itself.

    Very little of what went wrong on these had anything to do with the model. The problems sat in the HTTP plumbing around the endpoint and in the sentences the tools sent back. If I were reviewing your server, that's where I'd start.

    Wrap code you already trust

    The servers I'm happiest with are thin layers over application code that already existed. Each Esferas tool calls a MediatR request the web app was already using. On a smaller app of mine, the first design for agent access grew a separate Worker, a service binding into another system, and a dedicated OAuth provider. I cut it down to one MCP route inside the existing app that calls the existing REST handlers in-process, authenticated with a per-user personal access token. That left one deployment and one set of business rules.

    UseFireApp shows the cost of the other approach. Its MCP server grew up beside the REST API and the in-app agent, and adding one agent tool there means five edits across four files (the tool registry, the argument schemas, the MCP contract registry, the descriptions and titles, and the registration call) and three test assertions that fail one after another as you fix each. When a REST project came through, I kept MCP out of it on purpose. One contract per piece of work; the second surface doesn't grow as a side effect.

    If you're starting fresh, put each tool directly on top of whatever your controllers or command handlers call. The tool layer should own argument parsing, the description, and the shape of the reply. Business logic that lives there is a second implementation, and it will drift from the first.

    The endpoint inherits your web app's routing

    Under the 2025 revisions of the spec (2025-03-26 through 2025-11-25), a Streamable HTTP client may send a GET to the MCP endpoint to open a stream for server-initiated messages. The server has to answer with text/event-stream or a 405. The .NET SDK's stateless mode maps exactly one endpoint, POST /mcp/. On Esferas, GET and DELETE fell through to the Blazor app's catch-all page route, which answered 200 with the HTML shell. The client got HTML where it expected an event stream, the stream ended immediately, and it reconnected with no backoff. One Claude Desktop client sustained about 450 requests a minute against production, and none of them reached the MCP server.

    The fix was mapping GET and DELETE to 405. I left those two routes anonymous on purpose, because a 401 would have told the client to start an OAuth flow for a stream that doesn't exist.

    The same catch-all answered /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server with HTML. The client SDK treats a 4xx on those paths as “no OAuth here” and moves on, but it parses any 2xx as real metadata. A customer with a wrong or rotated key hit a JSON parse error and never got as far as trying the key in their config. Those paths now return 404.

    The third problem came from the app's shared auth scheme, which accepts a cookie or an API key and redirects to the OIDC login page when it gets neither. An agent with a missing key received a 302 to a login form. The MCP route now has its own API-key-only policy that returns a plain 401.

    The current revision, 2026-07-28, removes the GET stream and protocol-level sessions entirely, and tells a server that only speaks that revision to answer GET and DELETE with 405. So the fix matches where the spec ended up. Clients on the older revisions will be around for a while. Before you hand out the URL, curl every HTTP method against the endpoint and every .well-known path, and read the status codes and content types that come back.

    Pick auth for whoever is actually connecting

    Authorization is optional in the spec. If you do it over HTTP, the spec's version is OAuth 2.1 with your MCP server as the resource server. It must publish Protected Resource Metadata (RFC 9728) naming its authorization server, answer an unauthenticated request with a 401 whose WWW-Authenticate header points at that metadata, and reject tokens that weren't issued for it. Stdio servers skip all of this and read credentials from the environment. Client registration has shifted as well: the 2026-07-28 revision prefers Client ID Metadata Documents and keeps Dynamic Client Registration only for backward compatibility.

    I've shipped three different answers. Esferas customers already had an API key, so the MCP endpoint takes that same key in a header. There's no new credential to explain, and the setup panel in the app is covered by a test so it can't drift from the real tool list. The smaller app uses per-user personal access tokens, stored hashed and shown once. The cost there is that the claude.ai “add custom connector” dialog expects OAuth and can't connect, so users go through Claude Desktop. I decided not to build an authorization server for one dialog box.

    The email system I run for support inboxes has the opposite setup: its MCP server is OAuth 2.1 with a passkey and a consent screen, scoped to the connecting member's inboxes. That works well for a person in Claude Code on a laptop. A headless support agent has no browser to click through consent, so it keeps its API key and a skill that holds the actual policy, including approval before anything sends. My rule of thumb: OAuth when a person connects interactively through a client that expects it, and a scoped key or token when the caller is a script, a scheduled agent, or a customer who already manages keys in your product.

    Whatever you pick, treat every tool result as data leaving your system, because it goes into the customer's model context and from there into their provider's logs. The first version of the Esferas ping tool echoed back an internal account identifier that had no business leaving the server. Nobody thinks of a health check as a data-exposure surface, which is exactly why it needs the same review as every other tool. Return the fields the model needs and nothing you wouldn't paste into a support ticket.

    Ship a config for each client

    When Esferas launched MCP, the setup instructions had one JSON block with a URL and an X-ApiKey header, which is the shape Claude Code and Cursor accept. Claude Desktop's claude_desktop_config.json took only stdio servers at the time. It skipped our entry with a generic “not valid MCP server configurations” message, so Desktop users got no server and no useful error. Usage sat at zero, which is easy to read as weak demand. The number was measuring our bug. The setup page now has two labeled configs: the remote one for Claude Code and Cursor, and an mcp-remote bridge for Desktop with the key passed through env. The launch email had linked to that page instead of pasting the snippet, so the fix was a page edit and nobody needed a second email.

    Tool text gets repeated with confidence

    The failure that worries me most produces no error. An agent calls a tool, reads the text that comes back, and tells a paying customer what happened, in a voice that sounds certain. If the tool's text is wrong, nobody notices. After the Esferas server built clean and passed its tests, a code review that checked each tool's claims against the handler it wraps found seven false statements.

    One tool confirmed sequence assignments by checking whether a prospect had any membership record for the sequence. The assignment code refuses to change anything if a membership exists in any status, and memberships are never deleted, only moved between statuses. So the check “confirmed” exactly the prospects that had been skipped. It now checks for an active membership.

    Another trap was in deduplication. Re-importing a list into a sequence is the normal fix for prospects that ended up without one, but the dedupe key ignores the sequence and lives for seven days. The re-import is dropped silently and the original item, still without a sequence, stands. A tool that reports the request it made will tell the customer the sequence was applied. It has to look for pending imports and describe the state the system is actually in.

    On UseFireApp I made descriptions testable. Each MCP tool has a contract entry listing phrases its description must mention and claims it must not make, and the test suite checks the description text against both lists. It's crude, but it means a description can fail a build. The spec's outputSchema and structuredContent help with the shape of results: a server that declares an output schema must return structured content that conforms to it. A schema can confirm a field is a boolean. Whether the boolean is true is still your problem.

    Return errors the model can act on

    The spec separates protocol errors (unknown tool, malformed request) from tool execution errors, which come back as a normal result with isError: true so the model can read them and retry. Input validation failures, like a date in the wrong format, belong in the second group. Your SDK may not agree.

    The .NET SDK deserializes enum arguments before your method runs. A caller that sent pending where the enum expected NotSent, Sent, or Connected got a JsonException that no try/catch inside the tool could see. The SDK logged it at Error, and the Esferas error-rate alert fired. One client calling with curl did that 132 times in two minutes. Enum parameters are now plain strings, parsed inside the tool, and a bad value gets a refusal that lists the allowed names. The SDK's duplicate Error log for the same refusal is filtered out. That client is also a decent argument for the rate limiting the spec requires servers to apply to tool calls.

    Tool annotations such as readOnlyHint and destructiveHint help a client decide what to confirm with the user, and the spec says clients must treat them as untrusted unless they come from a trusted server. They're hints. If a tool sends messages to real people, the gate belongs in the server. UseFireApp's operator tool for messaging a prospect, on books set up for human review, stages the message and returns pending_approval. A person approves it before it goes out. An approved message can also come back held because it's outside the send window, and the tool reports that as held, which is a different state from failed. The agent needs to know which one it got.

    Log the calls, and learn what the SDK logs for you

    Every tool call should leave a structured event behind: tool name, the identity behind the key, arguments with secrets stripped, duration, and outcome. Esferas sends its logs to Log Cannon, the open-source log platform I run on Cloudflare Workers and ClickHouse. It handles 4M+ events a day across the products that report to it, and the alert that caught the enum problem lives there.

    Two things caught me off guard. SDKs have their own logging opinions, and binding failures can land at Error whether or not you consider them errors, so one misbehaving client can trip an alert you tuned for real failures. Check what your SDK logs before you set thresholds. The other is that write tools create write paths. Log Cannon's own MCP server has a create_log tool that inserts straight into ClickHouse, which made it a second writer next to the queue consumer the repo docs had long described as the only one. Any change that assumes every row passed through the consumer, like a new column without a default, has to handle both.

    The Log Cannon tools my agents use for alert investigation are search, query, and an overview. None of them edits an alert. Alert changes go through the REST API, and the agent instructions say to run an alert's test endpoint against live data before patching it, because an admin key applies a bad edit immediately with no review step.

    If you have a server about to go out to customers, two checks would have caught a lot of what I've described here: curl every method and every .well-known path on the endpoint, and read each tool's description and result text next to the handler it wraps, line by line.

    Want help putting agentic AI to work in your business?

    I embed with AI product teams and SMBs through Red Leg Dev LLC.