{"slug": "build-an-mcp-client-for-ai-agents-config-auth-transport", "title": "Build an MCP Client for AI Agents: Config, Auth, Transport", "summary": "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.", "body_md": "**Short answer:** building an MCP-compatible client for AI agents is 20% protocol and 80%\n\nedge-case handling. Each client (Cursor, Claude Desktop, Windsurf, [OpenClaw](https://smartgate.network/integration/openclaw), Hermes) wants\n\na slightly different JSON shape for the same Streamable HTTP server; the only way to keep\n\none server working across all of them is to generate that shape per platform, pin the\n\nsingle upstream URL in one place, forward exactly two headers deliberately, normalise\n\nnon-object JSON-RPC `params` before validation, and stay stateless for clients that never\n\nsend `initialize`. `mcp client` is searched about 1,600 times a month; almost none of those\n\nreaders need the specification - they need the config that does not 401.\n\n**Key takeaways**\n\n`mcpStreamableUpstreamUrl` trims a trailing slash and\nreturns `<root>/mcp/`; every client config and every proxy hop resolves the same value, so\na base-URL change is one edit instead of five pasted files.`type` versus `transport`, `url`\nversus `serverUrl`, `mcpServers` versus `mcp.servers` - four differences that each cost an\nafternoon of \"the server is fine, the paste is wrong\".`buildMcpAuthHeaders` emits the API key `forwardMcpRequestHeaders` carries\n`Authorization`, `Content-Type`, the platform header and the trace id, and defaults the\nplatform instead of failing the request.`\"params\": []` for `tools/list` and\n`notifications/initialized`; pydantic rejects a list where it expects an object, so\n`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\nthat skip the handshake still get a stateless session.\nIf you are evaluating this for a product decision, three sentences are enough. An\n\nMCP-compatible client does not fail because the protocol is hard; it fails because every\n\ndesktop client wants a different JSON file, and the differences are invisible until a user\n\npastes the wrong one. The durable fix is to treat the client configuration as generated\n\noutput: one function per platform that emits the file, one function that builds the auth\n\nheaders, and one place that decides what the upstream URL is. Everything else in this\n\narticle is the edge cases that generation exposes - the ones that show up as 401s, empty\n\ntool lists and mystery sessions.\n\nFor a gateway, the second-order benefit is bigger than the first. Once every client\n\narrives through generated configuration, the platform header travels with each request, and\n\nper-agent audit - which platform, which key, which trace - stops being a guess.\n\nThe [Model Context Protocol](https://smartgate.network/industry/model-context-protocol-explained) specification defines two standard transports, and the one that\n\nworks over a network is Streamable HTTP: the client POSTs JSON-RPC to a single endpoint and\n\nthe server answers either with a JSON object or with a stream\n\n([MCP transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports)).\n\nThe protocol also defines a lifecycle with an `initialize` handshake, capability\n\nnegotiation and version agreement\n\n([MCP lifecycle](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning)).\n\nRead the two documents side by side and you can see the gap this article is about. The\n\nspecification defines what a client must do; it has no opinion about where that client\n\nstores the endpoint, whether it calls the field `url` or `serverUrl`, or whether its\n\ntransport key is `type` or `transport`. Those are product decisions, made independently, by\n\nteams shipping on their own schedules. The result is that \"MCP-compatible\" describes the\n\nwire format, not the paste-ability.\n\nThe version half of that surface moves without a client release, so it is worth writing the\n\n[mcp protocol](https://smartgate.network/industry/mcp-protocol-versions-and-transports) you pinned next to the config shape you\n\ngenerated; when the shape is not enough, the\n\n[mcp specification](https://smartgate.network/industry/mcp-specification-walkthrough) walkthrough goes one level below it, to the\n\nfields the handshake actually carries.\n\nThe 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, \n\nclient`claude desktop mcp` about\n\n480, and `cursor mcp config` about 30 - a thin tail whose intent is \"I am trying to make this\n\nwork right now\" (Google Ads `search_volume`, US/en, 2026-09-15). Nobody searching those phrases wants the\n\nlifecycle diagram. They want the file.\n\nSmartGate is an MCP-native algorithm gateway for token control, traffic shaping, and agent\n\naudit, and it ships a public MCP endpoint per client. The code below is the compatibility\n\nlayer that makes that endpoint work across those clients, quoted from the shipped\n\nimplementation: configuration builders for five named platforms plus a generic fallback,\n\nthe header builder they share, the proxy that forwards identity, and the two server-side\n\nnormalisers that exist because real clients violate the tidy version of the spec.\n\nThe first builder is the smallest complete example of the pattern.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 56–73 (buildCursorMcpConfigJson)\nfunction buildCursorMcpConfigJson(\n  mcpUrl: string,\n  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,\n): string {\n  return JSON.stringify(\n    {\n      mcpServers: {\n        smartgate: {\n          type: \"streamable-http\",\n          url: mcpUrl,\n          headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Cursor),\n        },\n      },\n    },\n    null,\n    2,\n  );\n}\n```\n\nThree decisions are visible in nine lines of JSON. The transport is named\n\n`streamable-http`, matching the specification's network transport rather than the\n\ndeprecated HTTP+SSE one. The endpoint comes in as a parameter, so the function never\n\nhard-codes a host. And the headers are not pasted strings - they are produced by the shared\n\nheader builder with the platform identity of the caller, `PLATFORM_AGENT_ID.Cursor`, which\n\nis what lets the gateway tell a Cursor session from a Claude Desktop session later.\n\nClaude Desktop is the client that makes the per-platform approach obviously correct.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 76–96 (buildClaudeDesktopMcpConfigJson)\nfunction buildClaudeDesktopMcpConfigJson(\n  mcpUrl: string,\n  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,\n): string {\n  return JSON.stringify(\n    {\n      mcpServers: {\n        smartgate: {\n          type: \"streamable-http\",\n          url: mcpUrl,\n          headers: buildMcpAuthHeaders(\n            apiKeyPlaceholder,\n            PLATFORM_AGENT_ID[\"Claude Desktop\"],\n          ),\n        },\n      },\n    },\n    null,\n    2,\n  );\n}\n```\n\nThe JSON is byte-for-byte the same shape as the Cursor config, and it is still a separate\n\nfunction, for one reason: the platform identifier. Merge the two builders and you either\n\nlose the platform header or you invent a runtime parameter that the caller can get wrong.\n\nTwo small functions that differ in one constant are cheaper than one clever function and a\n\nsupport thread about audit attribution. If you are setting this up by hand, the\n\n[getting-started documentation](https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro)\n\ncovers the client side of the handshake.\n\nHere is the first genuine incompatibility, and it is a single field name.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 99–116 (buildWindsurfMcpConfigJson)\nfunction buildWindsurfMcpConfigJson(\n  mcpUrl: string,\n  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,\n): string {\n  return JSON.stringify(\n    {\n      mcpServers: {\n        smartgate: {\n          type: \"streamable-http\",\n          serverUrl: mcpUrl,\n          headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Windsurf),\n        },\n      },\n    },\n    null,\n    2,\n  );\n}\n```\n\nWindsurf takes `serverUrl` where Cursor and Claude Desktop take `url`. Nothing about the\n\nprotocol changes; the client's schema simply uses a different key, and a paste with the\n\nwrong one either fails validation or connects to nothing. This is the argument for\n\ngeneration in one screenshot: a generator that reads the platform and emits the right key\n\ncannot be wrong, while a documentation page listing four variants will eventually be\n\ncopied from the wrong row.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 118–135 (buildHermesMcpConfigJson)\nfunction buildHermesMcpConfigJson(\n  mcpUrl: string,\n  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,\n): string {\n  return JSON.stringify(\n    {\n      mcpServers: {\n        smartgate: {\n          type: \"streamable-http\",\n          url: mcpUrl,\n          headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Hermes),\n        },\n      },\n    },\n    null,\n    2,\n  );\n}\n```\n\nHermes keeps the `mcpServers` shape, so the difference from the first two builders is only\n\nthe platform constant. It is included here because \"the other clients are all the same\"\n\nis exactly the assumption that breaks: the only way to know which shapes are identical is\n\nto write them down side by side and pin each one to a symbol.\n\nOpenClaw demonstrates the second real incompatibility - two at once.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 137–159 (buildOpenClawMcpConfigJson)\nfunction buildOpenClawMcpConfigJson(\n  mcpUrl: string,\n  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,\n): string {\n  return JSON.stringify(\n    {\n      mcp: {\n        servers: {\n          smartgate: {\n            url: mcpUrl,\n            transport: \"streamable-http\",\n            headers: buildMcpAuthHeaders(\n              apiKeyPlaceholder,\n              PLATFORM_AGENT_ID.OpenClaw,\n            ),\n          },\n        },\n      },\n    },\n    null,\n    2,\n  );\n}\n```\n\nThe server entry lives under `mcp.servers` rather than a top-level `mcpServers`, and the\n\ntransport key is `transport: \"streamable-http\"` rather than `type`. Two renames and a\n\nnested object, on the same protocol, for the same endpoint. If your integration ships a\n\nsingle template with a note that says \"adjust for your client\", this is the note that gets\n\nmisread; if it ships one function per platform, this is a function that either exists or\n\ndoes not.\n\nEvery platform list is incomplete, so the layer needs a default that behaves predictably.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 161–181 (buildGenericMcpConfigJson)\nfunction buildGenericMcpConfigJson(\n  mcpUrl: string,\n  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,\n): string {\n  return JSON.stringify(\n    {\n      mcpServers: {\n        smartgate: {\n          type: \"streamable-http\",\n          url: mcpUrl,\n          headers: buildMcpAuthHeaders(\n            apiKeyPlaceholder,\n            PLATFORM_AGENT_ID.Generic,\n          ),\n        },\n      },\n    },\n    null,\n    2,\n  );\n}\n```\n\nThe generic builder emits the most common shape - top-level `mcpServers`, `streamable-http`,\n\nkey-based auth - and tags the caller as `Generic`. That last choice is the interesting one:\n\nan unidentified client is recorded as unidentified rather than guessed to be Cursor, so the\n\naudit trail stays honest and the product team learns which clients people are actually\n\nconnecting.\n\nEvery builder above calls the same function for authentication, which is why it deserves\n\nits own section.\n\n```\n# lib/connect/mcp-config-templates.ts — source lines 26–34 (buildMcpAuthHeaders)\nfunction buildMcpAuthHeaders(\n  apiKeyPlaceholder: string,\n  agentPlatform: string,\n): Record<string, string> {\n  return {\n    Authorization: `Bearer ${apiKeyPlaceholder}`,\n    [AGENT_PLATFORM_HEADER]: agentPlatform,\n  };\n}\n```\n\nThe key travels as a bearer token, and the platform travels as a dedicated header\n\n(`AGENT_PLATFORM_HEADER`). Client-side this looks like a formality; gateway-side it is the\n\ndifference between \"12,000 requests today\" and \"12,000 requests today, 9,400 of them from\n\nCursor, 1,100 from Claude Desktop, and 1,500 unlabelled\". Quotas, per-agent budget caps and\n\naudit records all key off that header - the\n\n[SmartGate docs](https://smartgate.network/docs) describe the same attribute on the\n\nserver side. A missing platform header is not an auth failure; it is a silent downgrade of\n\nyour own observability.\n\n```\n# lib/connect/mcp-proxy.ts — source lines 8–11 (mcpStreamableUpstreamUrl)\nfunction mcpStreamableUpstreamUrl(base?: string): string {\n  const root = (base ?? smartgateBackendBase()).replace(/\\/$/, \"\");\n  return `${root}/mcp/`;\n}\n```\n\nFour lines, one job: produce the single canonical URL, `<root>/mcp/`, with any trailing\n\nslash on the incoming base removed first. It looks trivial until you count how many places\n\na URL like this normally gets typed - one per client config, plus a proxy environment\n\nvariable, plus a health check. The function exists so the count is one. Point it at a\n\nlocal base during development and at the hosted endpoint in production, and no client file\n\nchanges.\n\nWhen a client talks to your gateway instead of directly to the server, the proxy decides\n\nwhich parts of the request survive. This is that decision, written down.\n\n```\n# lib/connect/mcp-proxy.ts — source lines 14–37 (forwardMcpRequestHeaders)\nfunction forwardMcpRequestHeaders(incoming: Headers): Headers {\n  const out = new Headers();\n  const auth = incoming.get(\"Authorization\");\n  if (auth) out.set(\"Authorization\", auth);\n  const contentType = incoming.get(\"Content-Type\");\n  if (contentType) out.set(\"Content-Type\", contentType);\n  out.set(\"Accept\", \"application/json\");\n\n  const platform =\n    incoming.get(\"X-SmartGate-Agent-Platform\") ??\n    incoming.get(\"x-smartgate-agent-platform\");\n  if (platform) {\n    out.set(\"X-SmartGate-Agent-Platform\", platform);\n  } else {\n    out.set(\"X-SmartGate-Agent-Platform\", \"cursor\");\n  }\n\n  const trace =\n    incoming.get(\"X-SmartGate-Trace-Id\") ??\n    incoming.get(\"x-smartgate-trace-id\");\n  if (trace) out.set(\"X-SmartGate-Trace-Id\", trace);\n\n  return out;\n}\n```\n\nFour rules, in order of how much they cost when they are missing. `Authorization` is\n\nforwarded only if present - the proxy does not invent credentials. `Content-Type` is\n\nforwarded, then `Accept` is set to `application/json`, which is what makes the upstream\n\nanswer with a JSON body instead of a stream the proxy would have to relay. The platform\n\nheader is forwarded, and when it is absent the proxy writes `cursor` rather than dropping\n\nthe attribute - a deliberate default that keeps attribution non-null for the client that\n\nmost often omits it. The trace id is forwarded when present, so one request is one row in\n\nthe logs across both hops.\n\nNow the server side, where the compatibility work is less about config and more about\n\ntolerance.\n\n```\n# backend/smartgate/api/mcp_sse_compat.py — source lines 67–99 (normalize_jsonrpc_body)\ndef normalize_jsonrpc_body(body: bytes) -> bytes:\n    \"\"\"Coerce non-object JSON-RPC params (e.g. []) to {} for pydantic validation.\"\"\"\n    if not body:\n        return body\n    try:\n        data: Any = json.loads(body)\n    except (json.JSONDecodeError, UnicodeDecodeError):\n        return body\n    if not isinstance(data, dict):\n        return body\n\n    changed = _rewrite_direct_tool_method(data)\n\n    params = data.get(\"params\")\n    if params is None:\n        data[\"params\"] = {}\n        changed = True\n    elif isinstance(params, list):\n        # Cursor: tools/list, notifications/initialized with \"params\": []\n        data[\"params\"] = {}\n        changed = True\n    elif isinstance(params, dict):\n        if _normalize_params_object(params):\n            changed = True\n\n    if not changed:\n        return body\n    logger.info(\n        \"Normalized JSON-RPC body: method=%s params_type=%s\",\n        data.get(\"method\"),\n        type(data.get(\"params\")).__name__,\n    )\n    return json.dumps(data, separators=(\",\", \":\")).encode(\"utf-8\")\n```\n\nThe docstring states the rule: coerce non-object JSON-RPC `params` to an empty object for\n\nvalidation. The comment on the list branch names the exact client and methods - Cursor\n\nsends `\"params\": []` for `tools/list` and `notifications/initialized`, both of which a\n\nstrict schema expects as an object. Two design choices are worth copying. First, the\n\nfunction fails open on anything it cannot parse: a non-dict body, or a body that is not\n\nJSON, is returned untouched, so a normaliser can never become the reason a valid request is\n\nrejected. Second, changes are logged with the method name, which is how you find out which\n\nclient is sending the odd shape instead of guessing.\n\nThe last piece is the one that surprises people who read the specification first.\n\n```\n# backend/smartgate/api/mcp_session_compat.py — source lines 89–103 (apply_mcp_session_compat)\ndef apply_mcp_session_compat() -> None:\n    \"\"\"Idempotent patches applied before mounting MCP SSE.\"\"\"\n    global _PATCHED\n    if _PATCHED:\n        return\n\n    ServerSession._received_request = _compat_received_request  # type: ignore[method-assign]\n    ServerSession._received_notification = _compat_received_notification  # type: ignore[method-assign]\n\n    if not hasattr(_stateless_server_run, \"_orig\"):\n        _stateless_server_run._orig = lowlevel_server.Server.run  # type: ignore[attr-defined]\n        lowlevel_server.Server.run = _stateless_server_run  # type: ignore[method-assign]\n\n    _PATCHED = True\n    logger.info(\"MCP session compat enabled (stateless SSE + relaxed init gate)\")\n```\n\nThe lifecycle in the specification is a state machine: `initialize`, an\n\n`initialized` notification, then requests. Some real clients reconnect mid-session, or jump\n\nstraight to `tools/list`, and a strict server refuses them. Rather than fork the server for\n\neach client, this function applies two patches once - the session layer's request and\n\nnotification hooks, and a stateless `run` - guarded by a module flag so calling it twice is\n\nharmless. The log line names the trade explicitly: *stateless SSE + relaxed init gate*. That\n\nis a compatibility decision with a cost, and it should be recorded as one.\n\nOne section of this rebuild could not be quoted, and the reason is worth publishing\n\nbecause it is the same reason client compatibility is hard. The symbol that dispatches to\n\nthe right platform builder (`buildMcpConfigJson`) matched the keyword locally by whole-name\n\ncontainment, and the slice API's slot-proof step then returned no asset for it - so there\n\nis no verified code to show, and none is shown. The dispatch pattern is nonetheless visible\n\nin the eleven builders above: a platform in, a JSON string out, no shared mutable state.\n\nWritten from the specification instead, the rule is: the dispatch decides *which shape*,\n\nnever *which endpoint* or *which credentials*. Endpoint and credentials come from one URL\n\nfunction and one header function, both shown above. A dispatcher that also owns the URL is\n\nthe component that turns \"add a client\" into \"change five files\".\n\nThe honest comparison is not \"gateway versus no gateway\" - for a single laptop client,\n\npointing a config file straight at a server is correct.\n\n|  | What you configure | Who handles client drift | What you pay | \n|---|---|---|---|\n| **Direct connection to the server** | One config file per client, maintained by hand | You, per client, per schema change | Free, until the second client | \n| **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 | \n| **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** | \n| **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 | \n\nSmartGate's billing model is the part that differs most from an API-router pricing page:\n\n\"pay for the platform, share only when you save\" - a flat platform fee, and a share only\n\nonce measured savings pass a threshold (a $36/mo cap on Pro). The per-platform config\n\nbuilders above are the reason the comparison is fair: with generated configs, the gateway is\n\nthe thing that absorbs client drift instead of your README.\n\n`params` before validation.\nStart on the free tier - 2M tokens a month, all seven `smart_*` tools, no card:\n\n**[start free](https://smartgate.network/login?from=/dashboard)**, then check which caps\n\nyour client fleet would actually hit on the\n\n**[pricing page](https://smartgate.network/pricing)**. If you are wiring an internal client\n\nfleet and want the platform header mapped to your own identities, the\n\n**[contact form](https://smartgate.network/contact)** is the enterprise path.\n\nTwo things are worth reading once before the paste, not after. The\n\n[mcp tools](https://smartgate.network/industry/mcp-tools-reference) reference tells you what the client will actually forward,\n\nbecause the annotations in `tools/list` are cheaper to design around than to retrofit; and if you\n\nneed a server of your own to point at, the\n\n[mcp server tutorial](https://smartgate.network/industry/python-mcp-server-tutorial) builds one in Python and then governs it,\n\nwhich is the fixture most client bugs need before they reproduce.\n\n**Do I need to implement the full initialize handshake in my client?**\n\nStrictly, yes: capability negotiation is part of the lifecycle. Practically, many desktop\n\nclients reconnect or jump straight to `tools/list`, which is why a gateway that accepts a\n\nstateless session with a relaxed init gate is easier to onboard. If you control both ends,\n\nimplement the handshake and keep the compatibility layer as a fallback.\n\n**Why does one server need five different config files?**\n\nBecause the clients are separate products with separate schemas. The transport and the wire\n\nformat are standardised; the field names for the endpoint and the transport inside each\n\nclient's own config file are not. Generating the file per platform keeps the difference in\n\none code path instead of in a support document.\n\n**What is the difference between `type` and `transport` in these configs?**\n\nOnly the client's schema. Both label the same Streamable HTTP transport; Cursor, Claude\n\nDesktop, Hermes and the generic builder use `type`, OpenClaw uses `transport`. If a client\n\nsilently ignores your server entry, check this key before debugging the network.\n\n**Is the agent platform header part of the [MCP specification](https://smartgate.network/industry/mcp-specification-walkthrough)?**\n\nNo. It is a gateway convention, which is exactly why it is easy to omit: the protocol works\n\nwithout it, and only the audit trail and per-agent quotas notice. Set it in the same\n\nfunction that sets `Authorization` so the two cannot drift apart.\n\n**Why normalise `params` instead of rejecting the request?**\n\nBecause the request is a client bug you want to survive. An empty list where an object is\n\nexpected carries no information worth rejecting; rewriting it to an empty object lets\n\n`tools/list` succeed, and the log line tells you which client to fix upstream.\n\n**Can I use the same config for a remote and a local server?**\n\nYes if the URL comes from one function - that is the point of a single upstream-URL helper.\n\nThe environment decides the base; the config shape stays identical per platform.\n\n`Authorization: ***` where the\nshipped source has a bearer template literal. It is quoted as-is rather than reconstructed\nby hand: nothing in this article is transcribed, and a masked line is preferable to a\nrewritten one.\nThe code in this article is not transcribed. Each block was cut directly out of the slice\n\nbody returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of\n\nthat body before publication; the first line inside every fence records the file and the\n\nexact source lines. Symbols were pinned by whole-name containment (rule A level 2) and\n\nconfirmed by the service's slot-proof endpoint before being written into the prose. One\n\nplanned section (the platform dispatcher) was left unquoted because slot-proof returned no\n\nasset for it - the article says so where that section appears, and the provenance table\n\nrecords only what was actually pinned.\n\n| # | SERP keyword | Symbol | File | Source lines | How it was pinned | sha256(12) | \n|---|---|---|---|---|---|---|\n| 1 | buildCursorMcpConfigJson cursor mcp config json | `buildCursorMcpConfigJson` | `lib/connect/mcp-config-templates.ts` | 56–73 | rule A L2 → slot-proof | `5ac3816f50b3` | \n| 2 | buildClaudeDesktopMcpConfigJson claude desktop mcp config json | `buildClaudeDesktopMcpConfigJson` | `lib/connect/mcp-config-templates.ts` | 76–96 | rule A L2 → slot-proof | `8f2a75a4ac53` | \n| 3 | buildWindsurfMcpConfigJson windsurf mcp client config json | `buildWindsurfMcpConfigJson` | `lib/connect/mcp-config-templates.ts` | 99–116 | rule A L2 → slot-proof | `82aabcf99dd1` | \n| 4 | buildHermesMcpConfigJson hermes mcp client config json | `buildHermesMcpConfigJson` | `lib/connect/mcp-config-templates.ts` | 118–135 | rule A L2 → slot-proof | `f77a3bf1a87d` | \n| 5 | buildOpenClawMcpConfigJson openclaw mcp client config json | `buildOpenClawMcpConfigJson` | `lib/connect/mcp-config-templates.ts` | 137–159 | rule A L2 → slot-proof | `ede08601b46c` | \n| 6 | buildGenericMcpConfigJson generic mcp client config json | `buildGenericMcpConfigJson` | `lib/connect/mcp-config-templates.ts` | 161–181 | rule A L2 → slot-proof | `51f127a16fc8` | \n| 7 | buildMcpAuthHeaders mcp client auth headers api key | `buildMcpAuthHeaders` | `lib/connect/mcp-config-templates.ts` | 26–34 | rule A L2 → slot-proof | `e47d2e879ac0` | \n| 8 | mcpStreamableUpstreamUrl streamable http mcp client upstream url | `mcpStreamableUpstreamUrl` | `lib/connect/mcp-proxy.ts` | 8–11 | rule A L2 → slot-proof | `5772cf940885` | \n| 9 | forwardMcpRequestHeaders forward mcp request headers from client | `forwardMcpRequestHeaders` | `lib/connect/mcp-proxy.ts` | 14–37 | rule A L2 → slot-proof | `8b0ca1e1970b` | \n| 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` | \n| 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` | \n\nEvery fenced block above was cut from the slice body and re-asserted against it byte-for-byte before\n\npublication. 11 of 12 sections pinned, 1 abstentions, 0 misses.", "url": "https://wpnews.pro/news/build-an-mcp-client-for-ai-agents-config-auth-transport", "canonical_source": "https://dev.to/smartgate/build-an-mcp-client-for-ai-agents-config-auth-transport-2ia4", "published_at": "2026-09-28 01:49:10+00:00", "updated_at": "2026-09-28 02:00:05.511943+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "Cursor", "Claude Desktop", "Windsurf", "OpenClaw", "Hermes"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-an-mcp-client-for-ai-agents-config-auth-transport", "markdown": "https://wpnews.pro/news/build-an-mcp-client-for-ai-agents-config-auth-transport.md", "text": "https://wpnews.pro/news/build-an-mcp-client-for-ai-agents-config-auth-transport.txt", "jsonld": "https://wpnews.pro/news/build-an-mcp-client-for-ai-agents-config-auth-transport.jsonld"}}