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
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.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".buildMcpAuthHeaders emits the API key forwardMcpRequestHeaders carries
Authorization, Content-Type, the platform header and the trace id, and defaults the
platform instead of failing the request."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.apply_mcp_session_compat monkey-patches the session layer once, idempotently, so clients
that skip the handshake still get a stateless session.
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.
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
The protocol also defines a lifecycle with an initialize handshake, capability
negotiation and version agreement
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 carries roughly 1,600 searches a month in the US alone,
clientclaude 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.
The first builder is the smallest complete example of the pattern.
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.
Claude Desktop is the client that makes the per-platform approach obviously correct.
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
covers the client side of the handshake.
Here is the first genuine incompatibility, and it is a single field name.
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.
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.
OpenClaw demonstrates the second real incompatibility - two at once.
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.
Every platform list is incomplete, so the layer needs a default that behaves predictably.
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.
Every builder above calls the same function for authentication, which is why it deserves
its own section.
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.
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.
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.
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.
Now the server side, where the compatibility work is less about config and more about
tolerance.
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):
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.
The last piece is the one that surprises people who read the specification first.
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.
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".
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.
params before validation.
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.
Two things are worth reading once before the paste, not after. The
mcp tools reference tells you what the client will actually forward,
because the annotations in tools/list are cheaper to design around than to retrofit; and if you
need a server of your own to point at, the
mcp server tutorial builds one in Python and then governs it,
which is the fixture most client bugs need before they reproduce.
Do I need to implement the full initialize handshake in my client?
Strictly, yes: capability negotiation is part of the lifecycle. Practically, many desktop
clients reconnect or jump straight to tools/list, which is why a gateway that accepts a
stateless session with a relaxed init gate is easier to onboard. If you control both ends,
implement the handshake and keep the compatibility layer as a fallback.
Why does one server need five different config files?
Because the clients are separate products with separate schemas. The transport and the wire
format are standardised; the field names for the endpoint and the transport inside each
client's own config file are not. Generating the file per platform keeps the difference in
one code path instead of in a support document.
What is the difference between type and transport in these configs?
Only the client's schema. Both label the same Streamable HTTP transport; Cursor, Claude
Desktop, Hermes and the generic builder use type, OpenClaw uses transport. If a client
silently ignores your server entry, check this key before debugging the network.
Is the agent platform header part of the MCP specification?
No. It is a gateway convention, which is exactly why it is easy to omit: the protocol works
without it, and only the audit trail and per-agent quotas notice. Set it in the same
function that sets Authorization so the two cannot drift apart.
Why normalise params instead of rejecting the request?
Because the request is a client bug you want to survive. An empty list where an object is
expected carries no information worth rejecting; rewriting it to an empty object lets
tools/list succeed, and the log line tells you which client to fix upstream.
Can I use the same config for a remote and a local server?
Yes if the URL comes from one function - that is the point of a single upstream-URL helper.
The environment decides the base; the config shape stays identical per platform.
Authorization: *** where the
shipped source has a bearer template literal. It is quoted as-is rather than reconstructed
by hand: nothing in this article is transcribed, and a masked line is preferable to a
rewritten one.
The code in this article is not transcribed. Each block was cut directly out of the slice
body returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of
that body before publication; the first line inside every fence records the file and the
exact source lines. Symbols were pinned by whole-name containment (rule A level 2) and
confirmed by the service's slot-proof endpoint before being written into the prose. One
planned section (the platform dispatcher) was left unquoted because slot-proof returned no
asset for it - the article says so where that section appears, and the provenance table
records only what was actually pinned.
| # | SERP keyword | Symbol | File | Source lines | How it was pinned | sha256(12) |
|---|---|---|---|---|---|---|
| 1 | buildCursorMcpConfigJson cursor mcp config json | buildCursorMcpConfigJson |
lib/connect/mcp-config-templates.ts |
56β73 | rule A L2 β slot-proof | 5ac3816f50b3 |
| 2 | buildClaudeDesktopMcpConfigJson claude desktop mcp config json | buildClaudeDesktopMcpConfigJson |
lib/connect/mcp-config-templates.ts |
76β96 | rule A L2 β slot-proof | 8f2a75a4ac53 |
| 3 | buildWindsurfMcpConfigJson windsurf mcp client config json | buildWindsurfMcpConfigJson |
lib/connect/mcp-config-templates.ts |
99β116 | rule A L2 β slot-proof | 82aabcf99dd1 |
| 4 | buildHermesMcpConfigJson hermes mcp client config json | buildHermesMcpConfigJson |
lib/connect/mcp-config-templates.ts |
118β135 | rule A L2 β slot-proof | f77a3bf1a87d |
| 5 | buildOpenClawMcpConfigJson openclaw mcp client config json | buildOpenClawMcpConfigJson |
lib/connect/mcp-config-templates.ts |
137β159 | rule A L2 β slot-proof | ede08601b46c |
| 6 | buildGenericMcpConfigJson generic mcp client config json | buildGenericMcpConfigJson |
lib/connect/mcp-config-templates.ts |
161β181 | rule A L2 β slot-proof | 51f127a16fc8 |
| 7 | buildMcpAuthHeaders mcp client auth headers api key | buildMcpAuthHeaders |
lib/connect/mcp-config-templates.ts |
26β34 | rule A L2 β slot-proof | e47d2e879ac0 |
| 8 | mcpStreamableUpstreamUrl streamable http mcp client upstream url | mcpStreamableUpstreamUrl |
lib/connect/mcp-proxy.ts |
8β11 | rule A L2 β slot-proof | 5772cf940885 |
| 9 | forwardMcpRequestHeaders forward mcp request headers from client | forwardMcpRequestHeaders |
lib/connect/mcp-proxy.ts |
14β37 | rule A L2 β slot-proof | 8b0ca1e1970b |
| 10 | normalize_jsonrpc_body normalize jsonrpc body for legacy mcp client | normalize_jsonrpc_body |
backend/smartgate/api/mcp_sse_compat.py |
67β99 | rule A L2 β slot-proof | d8359617452f |
| 11 | apply_mcp_session_compat mcp client session compatibility | apply_mcp_session_compat |
backend/smartgate/api/mcp_session_compat.py |
89β103 | rule A L2 β slot-proof | 2aec583c6238 |
Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before
publication. 11 of 12 sections pinned, 1 abstentions, 0 misses.