cd /news/ai-agents/build-an-mcp-client-for-ai-agents-co… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-140686] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Build an MCP Client for AI Agents: Config, Auth, Transport

A developer detailed a pattern for building an MCP-compatible client that generates per-platform configuration files for clients like Cursor, Claude Desktop, and Windsurf, rather than hand-pasting JSON. The approach pins a single upstream URL, emits auth headers deliberately, normalizes non-object JSON-RPC params before validation, and monkey-patches the session layer so clients that skip the initialize handshake still get a stateless session. The writeup argues that MCP client failures stem from divergent config shapes across desktop clients, not from protocol complexity.

by read18 min views2 publishedSep 28, 2026

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

(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 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

getting-started documentation

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.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/build-an-mcp-client-…] indexed:0 read:18min 2026-09-28 Β· β€”