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

> Source: <https://dev.to/smartgate/build-an-mcp-client-for-ai-agents-config-auth-transport-2ia4>
> Published: 2026-09-28 01:49:10+00:00

**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](https://smartgate.network/integration/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](https://smartgate.network/industry/model-context-protocol-explained) 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports)).

The protocol also defines a lifecycle with an `initialize` handshake, capability

negotiation and version agreement

([MCP lifecycle](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning)).

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](https://smartgate.network/industry/mcp-protocol-versions-and-transports) you pinned next to the config shape you

generated; when the shape is not enough, the

[mcp specification](https://smartgate.network/industry/mcp-specification-walkthrough) 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, 

client`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.

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.

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](https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro)

covers the client side of the handshake.

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.

```
# 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.

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.

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.

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](https://smartgate.network/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.

```
# 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.

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.

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.

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.

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](https://smartgate.network/login?from=/dashboard)**, then check which caps

your client fleet would actually hit on the

**[pricing page](https://smartgate.network/pricing)**. If you are wiring an internal client

fleet and want the platform header mapped to your own identities, the

**[contact form](https://smartgate.network/contact)** is the enterprise path.

Two things are worth reading once before the paste, not after. The

[mcp tools](https://smartgate.network/industry/mcp-tools-reference) 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](https://smartgate.network/industry/python-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](https://smartgate.network/industry/mcp-specification-walkthrough)?**

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.
