Short answer: building an MCP-compatible client for AI agents is 20% protocol and 80%
edge-case handling. Each client (Cursor, Claude Desktop, Windsurf, OpenClaw, Hermes) wants
a slightly different JSON shape for the same Streamable HTTP server; the only way to keep
one server working across all of them is to generate that shape per platform, pin the
single upstream URL in one place, forward exactly two headers deliberately, normalise
non-object JSON-RPC params before validation, and stay stateless for clients that never
send initialize. mcp client is searched about 1,600 times a month; almost none of those
readers need the specification - they need the config that does not 401.

Key takeaways

  • One server, one upstream string. mcpStreamableUpstreamUrl trims a trailing slash and returns <root>/mcp/; every client config and every proxy hop resolves the same value, so a base-URL change is one edit instead of five pasted files.
  • The config shape is per client, not per protocol. type versus transport, url versus serverUrl, mcpServers versus mcp.servers - four differences that each cost an afternoon of "the server is fine, the paste is wrong".
  • Two headers are load-bearing. buildMcpAuthHeaders emits the API key and the agent platform header, because a gateway that cannot attribute the caller cannot audit it.
  • A proxy must not silently drop identity. forwardMcpRequestHeaders carries Authorization, Content-Type, the platform header and the trace id, and defaults the platform instead of failing the request.
  • Normalise before you validate. Cursor sends "params": [] for tools/list and notifications/initialized; pydantic rejects a list where it expects an object, so normalize_jsonrpc_body rewrites non-object params to an empty object and logs the method.
  • Compatibility can be a patch, not a fork. apply_mcp_session_compat monkey-patches the session layer once, idempotently, so clients that skip the handshake still get a stateless session.
  • Do this next: open your own client config, find which of the four shape differences your setup assumes, and move the URL and the headers into functions before the next client is added.

The short version for whoever owns the integration

If you are evaluating this for a product decision, three sentences are enough. An
MCP-compatible client does not fail because the protocol is hard; it fails because every
desktop client wants a different JSON file, and the differences are invisible until a user
pastes the wrong one. The durable fix is to treat the client configuration as generated
output: one function per platform that emits the file, one function that builds the auth
headers, and one place that decides what the upstream URL is. Everything else in this
article is the edge cases that generation exposes - the ones that show up as 401s, empty
tool lists and mystery sessions.

For a gateway, the second-order benefit is bigger than the first. Once every client
arrives through generated configuration, the platform header travels with each request, and
per-agent audit - which platform, which key, which trace - stops being a guess.

Why client compatibility is the real MCP problem

The Model Context Protocol specification defines two standard transports, and the one that
works over a network is Streamable HTTP: the client POSTs JSON-RPC to a single endpoint and
the server answers either with a JSON object or with a stream
(MCP transports).
The protocol also defines a lifecycle with an initialize handshake, capability
negotiation and version agreement
(MCP lifecycle).

Read the two documents side by side and you can see the gap this article is about. The
specification defines what a client must do; it has no opinion about where that client
stores the endpoint, whether it calls the field url or serverUrl, or whether its
transport key is type or transport. Those are product decisions, made independently, by
teams shipping on their own schedules. The result is that "MCP-compatible" describes the
wire format, not the paste-ability.

The version half of that surface moves without a client release, so it is worth writing the
mcp protocol you pinned next to the config shape you
generated; when the shape is not enough, the
mcp specification walkthrough goes one level below it, to the
fields the handshake actually carries.

The scale of the surface is why this matters commercially as well as technically. mcp
client
carries roughly 1,600 searches a month in the US alone, claude desktop mcp about
480, and cursor mcp config about 30 - a thin tail whose intent is "I am trying to make this
work right now" (Google Ads search_volume, US/en, 2026-09-15). Nobody searching those phrases wants the
lifecycle diagram. They want the file.

SmartGate is an MCP-native algorithm gateway for token control, traffic shaping, and agent
audit, and it ships a public MCP endpoint per client. The code below is the compatibility
layer that makes that endpoint work across those clients, quoted from the shipped
implementation: configuration builders for five named platforms plus a generic fallback,
the header builder they share, the proxy that forwards identity, and the two server-side
normalisers that exist because real clients violate the tidy version of the spec.

buildCursorMcpConfigJson: a config generator, not a config file

The first builder is the smallest complete example of the pattern.

# lib/connect/mcp-config-templates.ts — source lines 56–73 (buildCursorMcpConfigJson) function buildCursorMcpConfigJson( mcpUrl: string, apiKeyPlaceholder: string = API_KEY_PLACEHOLDER, ): string { return JSON.stringify( { mcpServers: { smartgate: { type: "streamable-http", url: mcpUrl, headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Cursor), }, }, }, null, 2, ); } 

Three decisions are visible in nine lines of JSON. The transport is named
streamable-http, matching the specification's network transport rather than the
deprecated HTTP+SSE one. The endpoint comes in as a parameter, so the function never
hard-codes a host. And the headers are not pasted strings - they are produced by the shared
header builder with the platform identity of the caller, PLATFORM_AGENT_ID.Cursor, which
is what lets the gateway tell a Cursor session from a Claude Desktop session later.

buildClaudeDesktopMcpConfigJson: the same server, a different file

Claude Desktop is the client that makes the per-platform approach obviously correct.

# lib/connect/mcp-config-templates.ts — source lines 76–96 (buildClaudeDesktopMcpConfigJson) function buildClaudeDesktopMcpConfigJson( mcpUrl: string, apiKeyPlaceholder: string = API_KEY_PLACEHOLDER, ): string { return JSON.stringify( { mcpServers: { smartgate: { type: "streamable-http", url: mcpUrl, headers: buildMcpAuthHeaders( apiKeyPlaceholder, PLATFORM_AGENT_ID["Claude Desktop"], ), }, }, }, null, 2, ); } 

The JSON is byte-for-byte the same shape as the Cursor config, and it is still a separate
function, for one reason: the platform identifier. Merge the two builders and you either
lose the platform header or you invent a runtime parameter that the caller can get wrong.
Two small functions that differ in one constant are cheaper than one clever function and a
support thread about audit attribution. If you are setting this up by hand, the
getting-started documentation
covers the client side of the handshake.

buildWindsurfMcpConfigJson: one renamed field breaks the paste

Here is the first genuine incompatibility, and it is a single field name.

# lib/connect/mcp-config-templates.ts — source lines 99–116 (buildWindsurfMcpConfigJson) function buildWindsurfMcpConfigJson( mcpUrl: string, apiKeyPlaceholder: string = API_KEY_PLACEHOLDER, ): string { return JSON.stringify( { mcpServers: { smartgate: { type: "streamable-http", serverUrl: mcpUrl, headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Windsurf), }, }, }, null, 2, ); } 

Windsurf takes serverUrl where Cursor and Claude Desktop take url. Nothing about the
protocol changes; the client's schema simply uses a different key, and a paste with the
wrong one either fails validation or connects to nothing. This is the argument for
generation in one screenshot: a generator that reads the platform and emits the right key
cannot be wrong, while a documentation page listing four variants will eventually be
copied from the wrong row.

buildHermesMcpConfigJson: same shape, named client

# lib/connect/mcp-config-templates.ts — source lines 118–135 (buildHermesMcpConfigJson) function buildHermesMcpConfigJson( mcpUrl: string, apiKeyPlaceholder: string = API_KEY_PLACEHOLDER, ): string { return JSON.stringify( { mcpServers: { smartgate: { type: "streamable-http", url: mcpUrl, headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Hermes), }, }, }, null, 2, ); } 

Hermes keeps the mcpServers shape, so the difference from the first two builders is only
the platform constant. It is included here because "the other clients are all the same"
is exactly the assumption that breaks: the only way to know which shapes are identical is
to write them down side by side and pin each one to a symbol.

buildOpenClawMcpConfigJson: nested differently, named differently

OpenClaw demonstrates the second real incompatibility - two at once.

# lib/connect/mcp-config-templates.ts — source lines 137–159 (buildOpenClawMcpConfigJson) function buildOpenClawMcpConfigJson( mcpUrl: string, apiKeyPlaceholder: string = API_KEY_PLACEHOLDER, ): string { return JSON.stringify( { mcp: { servers: { smartgate: { url: mcpUrl, transport: "streamable-http", headers: buildMcpAuthHeaders( apiKeyPlaceholder, PLATFORM_AGENT_ID.OpenClaw, ), }, }, }, }, null, 2, ); } 

The server entry lives under mcp.servers rather than a top-level mcpServers, and the
transport key is transport: "streamable-http" rather than type. Two renames and a
nested object, on the same protocol, for the same endpoint. If your integration ships a
single template with a note that says "adjust for your client", this is the note that gets
misread; if it ships one function per platform, this is a function that either exists or
does not.

buildGenericMcpConfigJson: the fallback that keeps unknown clients working

Every platform list is incomplete, so the layer needs a default that behaves predictably.

# lib/connect/mcp-config-templates.ts — source lines 161–181 (buildGenericMcpConfigJson) function buildGenericMcpConfigJson( mcpUrl: string, apiKeyPlaceholder: string = API_KEY_PLACEHOLDER, ): string { return JSON.stringify( { mcpServers: { smartgate: { type: "streamable-http", url: mcpUrl, headers: buildMcpAuthHeaders( apiKeyPlaceholder, PLATFORM_AGENT_ID.Generic, ), }, }, }, null, 2, ); } 

The generic builder emits the most common shape - top-level mcpServers, streamable-http,
key-based auth - and tags the caller as Generic. That last choice is the interesting one:
an unidentified client is recorded as unidentified rather than guessed to be Cursor, so the
audit trail stays honest and the product team learns which clients people are actually
connecting.

buildMcpAuthHeaders: two headers, and the second one is the one you forget

Every builder above calls the same function for authentication, which is why it deserves
its own section.

# lib/connect/mcp-config-templates.ts — source lines 26–34 (buildMcpAuthHeaders) function buildMcpAuthHeaders( apiKeyPlaceholder: string, agentPlatform: string, ): Record<string, string> { return { Authorization: `Bearer ${apiKeyPlaceholder}`, [AGENT_PLATFORM_HEADER]: agentPlatform, }; } 

The key travels as a bearer token, and the platform travels as a dedicated header
(AGENT_PLATFORM_HEADER). Client-side this looks like a formality; gateway-side it is the
difference between "12,000 requests today" and "12,000 requests today, 9,400 of them from
Cursor, 1,100 from Claude Desktop, and 1,500 unlabelled". Quotas, per-agent budget caps and
audit records all key off that header - the
SmartGate docs describe the same attribute on the
server side. A missing platform header is not an auth failure; it is a silent downgrade of
your own observability.

mcpStreamableUpstreamUrl: one upstream string, normalised once

# lib/connect/mcp-proxy.ts — source lines 8–11 (mcpStreamableUpstreamUrl) function mcpStreamableUpstreamUrl(base?: string): string { const root = (base ?? smartgateBackendBase()).replace(/\/$/, ""); return `${root}/mcp/`; } 

Four lines, one job: produce the single canonical URL, <root>/mcp/, with any trailing
slash on the incoming base removed first. It looks trivial until you count how many places
a URL like this normally gets typed - one per client config, plus a proxy environment
variable, plus a health check. The function exists so the count is one. Point it at a
local base during development and at the hosted endpoint in production, and no client file
changes.

forwardMcpRequestHeaders: what a proxy must not drop

When a client talks to your gateway instead of directly to the server, the proxy decides
which parts of the request survive. This is that decision, written down.

# lib/connect/mcp-proxy.ts — source lines 14–37 (forwardMcpRequestHeaders) function forwardMcpRequestHeaders(incoming: Headers): Headers { const out = new Headers(); const auth = incoming.get("Authorization"); if (auth) out.set("Authorization", auth); const contentType = incoming.get("Content-Type"); if (contentType) out.set("Content-Type", contentType); out.set("Accept", "application/json"); const platform = incoming.get("X-SmartGate-Agent-Platform") ?? incoming.get("x-smartgate-agent-platform"); if (platform) { out.set("X-SmartGate-Agent-Platform", platform); } else { out.set("X-SmartGate-Agent-Platform", "cursor"); } const trace = incoming.get("X-SmartGate-Trace-Id") ?? incoming.get("x-smartgate-trace-id"); if (trace) out.set("X-SmartGate-Trace-Id", trace); return out; } 

Four rules, in order of how much they cost when they are missing. Authorization is
forwarded only if present - the proxy does not invent credentials. Content-Type is
forwarded, then Accept is set to application/json, which is what makes the upstream
answer with a JSON body instead of a stream the proxy would have to relay. The platform
header is forwarded, and when it is absent the proxy writes cursor rather than dropping
the attribute - a deliberate default that keeps attribution non-null for the client that
most often omits it. The trace id is forwarded when present, so one request is one row in
the logs across both hops.

normalize_jsonrpc_body: Cursor sends params as an empty list

Now the server side, where the compatibility work is less about config and more about
tolerance.

# backend/smartgate/api/mcp_sse_compat.py — source lines 67–99 (normalize_jsonrpc_body) def normalize_jsonrpc_body(body: bytes) -> bytes: """Coerce non-object JSON-RPC params (e.g. []) to {} for pydantic validation.""" if not body: return body try: data: Any = json.loads(body) except (json.JSONDecodeError, UnicodeDecodeError): return body if not isinstance(data, dict): return body changed = _rewrite_direct_tool_method(data) params = data.get("params") if params is None: data["params"] = {} changed = True elif isinstance(params, list): # Cursor: tools/list, notifications/initialized with "params": []  data["params"] = {} changed = True elif isinstance(params, dict): if _normalize_params_object(params): changed = True if not changed: return body logger.info( "Normalized JSON-RPC body: method=%s params_type=%s", data.get("method"), type(data.get("params")).__name__, ) return json.dumps(data, separators=(",", ":")).encode("utf-8") 

The docstring states the rule: coerce non-object JSON-RPC params to an empty object for
validation. The comment on the list branch names the exact client and methods - Cursor
sends "params": [] for tools/list and notifications/initialized, both of which a
strict schema expects as an object. Two design choices are worth copying. First, the
function fails open on anything it cannot parse: a non-dict body, or a body that is not
JSON, is returned untouched, so a normaliser can never become the reason a valid request is
rejected. Second, changes are logged with the method name, which is how you find out which
client is sending the odd shape instead of guessing.

apply_mcp_session_compat: stateless sessions for clients that skip the handshake

The last piece is the one that surprises people who read the specification first.

# backend/smartgate/api/mcp_session_compat.py — source lines 89–103 (apply_mcp_session_compat) def apply_mcp_session_compat() -> None: """Idempotent patches applied before mounting MCP SSE.""" global _PATCHED if _PATCHED: return ServerSession._received_request = _compat_received_request # type: ignore[method-assign]  ServerSession._received_notification = _compat_received_notification # type: ignore[method-assign]  if not hasattr(_stateless_server_run, "_orig"): _stateless_server_run._orig = lowlevel_server.Server.run # type: ignore[attr-defined]  lowlevel_server.Server.run = _stateless_server_run # type: ignore[method-assign]  _PATCHED = True logger.info("MCP session compat enabled (stateless SSE + relaxed init gate)") 

The lifecycle in the specification is a state machine: initialize, an
initialized notification, then requests. Some real clients reconnect mid-session, or jump
straight to tools/list, and a strict server refuses them. Rather than fork the server for
each client, this function applies two patches once - the session layer's request and
notification hooks, and a stateless run - guarded by a module flag so calling it twice is
harmless. The log line names the trade explicitly: stateless SSE + relaxed init gate. That
is a compatibility decision with a cost, and it should be recorded as one.

The config shape nobody pins down

One section of this rebuild could not be quoted, and the reason is worth publishing
because it is the same reason client compatibility is hard. The symbol that dispatches to
the right platform builder (buildMcpConfigJson) matched the keyword locally by whole-name
containment, and the slice API's slot-proof step then returned no asset for it - so there
is no verified code to show, and none is shown. The dispatch pattern is nonetheless visible
in the eleven builders above: a platform in, a JSON string out, no shared mutable state.

Written from the specification instead, the rule is: the dispatch decides which shape,
never which endpoint or which credentials. Endpoint and credentials come from one URL
function and one header function, both shown above. A dispatcher that also owns the URL is
the component that turns "add a client" into "change five files".

How SmartGate compares

The honest comparison is not "gateway versus no gateway" - for a single laptop client,
pointing a config file straight at a server is correct.

What you configure Who handles client drift What you pay
Direct connection to the server One config file per client, maintained by hand You, per client, per schema change Free, until the second client
Local proxy script A proxy process plus its own config You - the proxy encodes one client's assumptions Your time, plus a process to keep alive
Hosted gateway (SmartGate's model) Generated per-platform configs against one URL The gateway: one endpoint, per-platform files generated Free tier: 2M tokens/mo, all seven tools, no card; Pro from $18/mo
Generic API gateway (Kong AI Gateway, Portkey, LiteLLM) Routing and keys; usually HTTP APIs rather than MCP clients You, for MCP-specific shapes Platform pricing plus integration work

SmartGate's billing model is the part that differs most from an API-router pricing page:
"pay for the platform, share only when you save" - a flat platform fee, and a share only
once measured savings pass a threshold (a $36/mo cap on Pro). The per-platform config
builders above are the reason the comparison is fair: with generated configs, the gateway is
the thing that absorbs client drift instead of your README.

How to get started

  1. Pick one endpoint, one place. Point your client at a single Streamable HTTP URL and keep it in a function rather than in five pasted JSON files.
  2. Generate the config per platform. Even two platforms justify it; the four shape differences above will otherwise live in your documentation as footnotes.
  3. Send both headers. The API key authenticates the request; the platform header makes it attributable. Add the trace header if your client supports one.
  4. Normalise params before validation. Accept an empty list where you expect an object, and log when you do, so the client that sends it is visible in your logs.
  5. Decide your session policy out loud. Stateless with a relaxed handshake is a valid choice for desktop clients; make it a documented decision rather than an incident.
  6. When the paste fails, look at the wire. A client that ignores your entry usually never sent a request at all, which no amount of config editing will reveal — that needs MCP inspector alternatives that show the handshake the server actually received.

Start on the free tier - 2M tokens a month, all seven smart_* tools, no card:
start free, then check which caps
your client fleet would actually hit on the
pricing page. If you are wiring an internal client
fleet and want the platform header mapped to your own identities, the
contact form is the enterprise path....