Shipping a Remote MCP Server That Works in Both ChatGPT and Claude: A Field Guide
The first MCP server I wrote took an afternoon. It ran over stdio, it exposed three tools, and Claude Code picked it up on the first try. I felt like I understood the protocol.
Then I shipped a remote MCP server: an admin connector for a production web app, which real operators would add as a connector in ChatGPT and in Claude and use from their phones. That took a lot longer than an afternoon. None of the extra time went into the tools themselves. It went into the edges: a host that silently showed "no tools available", a request my server rejected because it carried a field I didn't expect, an error that vanished because I answered it with an empty response, and a model that told an operator a count that was off by one, with total confidence.
This post covers what I learned. It stays at the protocol and design level, so it carries over to whatever backend you're wrapping.
Stdio demos vs. a remote connector people actually use
A local stdio server lives a sheltered life: one client, one user, no network, no auth. A remote server is a public HTTP endpoint that different hosts reach through the Streamable HTTP transport. Each host has its own client implementation, OAuth flow, tool-list caching and way of showing (or hiding) your errors. And the person on the other end isn't a developer. They typed "how many open requests were assigned this week?" and they'll trust whatever number comes back.
That turns the tool surface into product design, protocol strictness into a compatibility problem, and auth into a hard requirement.
If you want a sandbox to see these moving parts before you touch production, my public lab mcp-deep-dive walks through servers, transports and clients step by step.
Design tools around jobs, not tables
My first instinct was to mirror the data model: list_records, get_record, update_record, one per entity. It's tidy, and the model hates it. It gets back a page of rows and has to filter, join and count in its head. That's slow, it burns context, and it's where hallucinated numbers come from. The fix is to design tools around the jobs an operator actually asks for:
- "What needs my attention today?" becomes one tool that returns a short, prioritized summary.
- "How did this week compare to last week?" becomes one tool that returns the comparison, already computed.
- "Reassign this item to someone else" becomes one write tool with a narrow input and a clear confirmation.
Every tool should be a verb with a small input schema and an output that's an answer, not raw material. Here's the shape I aim for (illustrative, not from any real server):
name: summarize_pending_work
description: >
Returns open items that need an operator's attention, grouped by owner,
with exact counts. Use for "what's pending", "what needs attention",
"who is overloaded". Read-only.
input:
owner: optional string # filter to one person
since: optional date # defaults to start of current week
output:
totals: { open: int, overdue: int }
by_owner: [{ owner, open, overdue, oldest_item_age_days }]
generated_at: timestamp
The description does a lot of the work: it says when to use the tool, in the phrasing a person would type, and that it's read-only. A vague description gets your tool skipped or misused.
Tool-design checklist
- Name is a verb phrase describing a job (
summarize_…,reassign_…), not a table name. - Description says when to use it, in the user's words, and what it won't do.
- Input schema is small, with a handful of fields, sensible defaults and enums instead of free text where possible.
- Output is an answer: computed totals, resolved names, human-readable dates.
- Read/write flag is explicit. Read-only tools carry the read-only hint annotation, and write tools say what they change.
- Idempotency: write tools are safe to retry, or they require an idempotency key.
- Errors tell the model what to do next ("owner not found, call
list_teamto get valid names").
Do the work server-side
Once tools are answers, the next question is where the work happens. My rule: if code can compute it exactly, code computes it. The model narrates.
Enrichment. Return names instead of IDs, resolve free-text references to canonical values on the server, and give timestamps in the operator's timezone. Every derived field you compute is one fewer chance for the model to guess.
Exact aggregates. This one burned me. Models repeat numbers verbatim. If your tool returns 40 rows and the model counts them, you get "about 40" on a good day and "41" on a bad one. If your tool returns open: 40, the model says 40. So:
- Compute counts and stats in the database, not in the model.
- Make sure the counters mean what the description says. "Assigned this week" has to use the same definition as the dashboard the operator compares it against, or you'll spend a day chasing a "wrong number" that's actually a definition mismatch.
- Return the definition with the number when it's ambiguous (
window: "Mon 00:00 to now, operator timezone").
Fallbacks for missing data. Real data has holes: a missing attachment, an empty field, an orphaned reference. Decide on the fallback in code (a placeholder, null with a reason, an explicit "unknown"), or the model will improvise something plausible.
Testing that the model uses these tools correctly is its own discipline. If you're building that harness, tool-call evals are where I'd start.
Transport and JSON-RPC gotchas
MCP is JSON-RPC 2.0 over a transport. Most of my debugging time went into the gap between "my server follows the spec" and "every host's client follows the spec the same way I read it."
The rule that fixed most of it is old, and it's Postel's: be strict in what you send, lenient in what you accept.
Accept extra top-level fields
My first server used a strict validator that rejected messages with unknown keys. It worked with one host and failed with another, because that client sent an extra top-level field. The fix: validate the fields you need (jsonrpc, id, method, params) and ignore the rest.
Return errors synchronously, never as an empty "accepted"
Under Streamable HTTP, a JSON-RPC request (anything with an id) gets a response, either a JSON body or an SSE stream. 202 Accepted with no body is for notifications and responses the client sends you, not for requests.
I had a code path where a request failed validation and fell through to the "nothing to return" branch, which answered 202. The host showed no error, just a stalled call, or a connector that "had no tools" when the failing request was tools/list. If a request fails, answer it with a JSON-RPC error object carrying the same id.
Use consistent error shapes the model can act on
There are two kinds of failure, and they belong in different places:
- Protocol errors (unknown method, malformed params) go back as JSON-RPC
errorobjects. - Tool execution errors (record not found, permission denied for this item) go back as a normal tool result flagged as an error, with a message the model can read and act on.
A model that gets "Internal error" retries blindly or apologizes vaguely. One that gets "No item with that reference. Call search_items with a name to find it." recovers on its own.
Symptom → cause → fix
| Symptom in the host | Likely protocol cause | Fix |
|---|---|---|
| Connector added, but "no tools available" | tools/list failed and the error was swallowed, or the response wasn't valid JSON-RPC | Return a proper JSON-RPC result or error for every request, and log tools/list calls |
| Tool call spins forever | Request answered with 202 and no body, or the SSE stream never closes | Requests always get a response, and you close the stream after the final message |
| Works in one host, fails in the other | Server rejects extra or unexpected fields | Validate the fields you need and ignore unknown ones |
| Auth loop: login succeeds, connector still unauthenticated | Missing or wrong WWW-Authenticate / protected resource metadata, or wrong audience on the token | Serve RFC 9728 metadata, include resource_metadata in the 401, and validate the audience |
| Model states a wrong count with confidence | Model aggregated rows itself, or the counter's definition differs from the dashboard's | Compute aggregates server-side and return definitions with numbers |
| Old tool names keep appearing | Host cached the tool list | Send notifications/tools/list_changed when tools change, and reconnect while testing |
| Vague "something went wrong" | Generic error message with nothing actionable | Return tool errors with a next step the model can take |
Auth for agents: OAuth 2.1 and scoped permissions
The MCP authorization spec builds on OAuth 2.1. Your MCP server is an OAuth resource server, usually not the authorization server. The host is the OAuth client.
The pieces, with the specs that define them:
- Protected resource metadata (RFC 9728) says which authorization server you trust, and your
401points to it. Most auth loops start here. - Authorization server metadata (RFC 8414) lets the host discover endpoints.
- Client registration: hosts are clients you don't know ahead of time. RFC 7591 dynamic registration is one way in. Check what the current spec revision and your hosts support.
- PKCE (RFC 7636) is mandatory.
- Audience binding: reject tokens not issued for your server, and never pass the host's token through to your backend.
- Revocation (RFC 7009) means disconnecting a connector actually kills it.
Scopes per tool, and two connectors instead of one
Map scopes to tools, not to the whole server: read:summary, write:assignment, and so on. Then check scopes inside each tool handler, not only at the edge. A token with read scope that calls a write tool should get a clear tool error, not a 500.
I also found it cleaner to ship two connectors: a read-only one most people can add safely, and an admin one with write tools behind stricter scopes. More setup, much smaller blast radius. It's the same principle I describe in separating talking from acting.
What to log
For every tool call, log the user identity, client ID, tool name, a redacted summary of the arguments, the outcome and latency. Never log tokens. When an operator says "the AI did something weird yesterday," you need to reconstruct exactly what it called.
Security hygiene before you publish
- Lock down existing server actions and endpoints. The MCP layer may check scopes carefully while an older endpoint it calls is wide open. Audit the whole path.
- Keep secrets out of the repo, and check history too. If a secret was ever committed, rotate it.
- Rate limit per token and per tool. A model in a loop can hammer a tool. Write tools get tighter limits.
- Separate read from write in code paths, not just in scopes. Read tools should use a read-only database role where your stack allows it.
- Treat tool inputs as untrusted. Anything the model passes may have come from a prompt-injected document. Validate, parameterize and constrain. The OWASP Top 10 for LLM Applications is a good checklist here, and I wrote about red-teaming agents for prompt injection separately.
- Confirm destructive writes. Return a preview and require a second call with a confirmation token for anything you can't undo.
Testing a connector in two hosts
I test in three layers.
1. The MCP Inspector. The official MCP Inspector checks initialize, tools/list, schemas, error responses and the OAuth flow before any model gets involved.
2. A two-host manual matrix. Hosts differ in discovery, OAuth, tool-list caching and error display, and they change over time, so I re-run this after every server change and host update.
| Check | ChatGPT | Claude | Notes |
|---|---|---|---|
| Add connector by URL, discovery succeeds | ☐ | ☐ | Watch for tools/list errors in your logs |
| OAuth: login, consent, token issued with correct audience | ☐ | ☐ | Test with a user who lacks write scope |
| Tool list matches what you shipped | ☐ | ☐ | Check for stale cached tools |
| Read tool call returns an answer and the model quotes numbers exactly | ☐ | ☐ | Compare against the dashboard |
| Write tool asks for confirmation and is idempotent on retry | ☐ | ☐ | Retry the same call twice |
| Tool error shows up, and the model recovers using the message | ☐ | ☐ | Force a "not found" |
| Protocol error comes back synchronously | ☐ | ☐ | Send a malformed request via Inspector |
| Revoke access, and the next call fails cleanly | ☐ | ☐ | Then reconnect |
3. Scripted evals. Freeze realistic operator questions with expected tool calls and numbers, and run them on every deploy: the eval gym idea applied to a connector. It catches regressions like a refactor that quietly changes what "this week" means.
Pre-launch checklist: how to start
If you're turning an existing backend into a remote MCP server, here's the order I'd follow:
- List the jobs operators actually want done. Ignore your tables.
- Design one tool per job with the checklist above, starting read-only.
- Move all math to the server and return definitions alongside numbers.
- Make the transport forgiving and honest: accept unknown fields, answer every request, never
202a request. - Wire OAuth 2.1 as a resource server: RFC 9728 metadata, PKCE, audience validation, per-tool scopes.
- Split connectors into read-only and admin if you have write tools.
- Harden the path: audit called endpoints, rotate leaked secrets, rate limit, confirm destructive writes.
- Log tool calls, redacted.
- Pass the Inspector, then the two-host matrix, then freeze an eval set.
- Launch to a small group and read the logs daily. The model will use your tools in ways you didn't predict.
Conclusion
The protocol isn't the hard part of a remote MCP server. The hard part is what only shows up in production: tools that return answers instead of rows, numbers the model can't get wrong because it never computes them, a transport that tolerates small differences between hosts, and auth that treats a connector like the remote control it is.
Build for the operator who'll ask a question from their phone and believe the answer. If the server is honest with that person, it'll work fine in both hosts.