From 26b27b2d9462ca8a2cd37a7735a2e7998f3c8251 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Mon, 27 Jul 2026 10:44:36 -0400 Subject: [PATCH] sql: per-caller usage attribution via dedicated endpoint chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetaMCP proxies with a fresh outbound connection and forwards no client identity — not User-Agent, not X-MCP-Client, not initialize.clientInfo. A downstream server that logs client identity therefore sees only MetaMCP's own Node UA, and every client behind the gateway collapses into one undifferentiated bucket. Verified by probe: three distinct identity values in, {"name": "node", "user_agent": "node"} logged out. Not patchable at reasonable cost: downstream connections are pooled and pre-warmed (idleSessions[serverUuid] / createIdleSessionAsync), so one connection serves many inbound callers and headers bind at connection creation. No per-request injection point exists without disabling pooling, re-keying the pool by (server, caller), or threading AsyncLocalStorage through the transport. But MetaMCP already supports static per-server custom headers — mcp_servers.headers is jsonb and flows into the outbound requestInit.headers. So give each caller its own endpoint → namespace → server chain, pointing at the SAME backend URL, differing only in a stamped X-MCP-Client header. Same container, same corpus, nothing duplicated downstream. Adds sql/zsupport-endpoint.sql (the worked example, applied to the live gateway 2026-07-27 — zSupport now attributes correctly) and a README section covering the pattern and its two gotchas: the UNIQUE (name, user_id) constraint forcing a new server name, and the resulting change of tool-name prefix that any client allow-list must track. sql/** added to the workflow's paths-ignore — config, not image. --- .gitea/workflows/build.yml | 3 ++ README.md | 58 ++++++++++++++++++++++++++++++++++++++ sql/zsupport-endpoint.sql | 48 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 sql/zsupport-endpoint.sql diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 644e256..68b0eef 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -18,6 +18,9 @@ on: - "README.md" - "LICENSE" - ".gitignore" + # sql/ holds MetaMCP *config* (rows applied to its Postgres), which has + # nothing to do with the image contents — don't rebuild on those edits. + - "sql/**" schedule: # Every day at 08:00 UTC — pulls upstream :latest and rebuilds. - cron: "0 8 * * *" diff --git a/README.md b/README.md index 0944e98..b3f1287 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,64 @@ curl -sSo /dev/null -w "%{http_code}\n" -H "User-Agent: $UA" \ "https:///metamcp//mcp" ``` +## `sql/` — per-caller usage attribution (config, not a patch) + +A second MetaMCP quirk, solved with configuration rather than a patch. + +**The problem.** MetaMCP terminates each inbound connection and opens a +*fresh outbound request* to the downstream MCP server. It forwards +neither the caller's `User-Agent` nor `X-MCP-Client`, nor the MCP +`initialize.clientInfo`. So a downstream server that logs client +identity sees only MetaMCP's own Node user-agent, and every client +behind the gateway — claude.ai, Claude Desktop, your own apps — +collapses into one undifferentiated bucket. + +Verified by probe: a request carrying three distinct identity values +arrived downstream logged as `{"name": "node", "user_agent": "node"}`. + +**Why not patch it.** Downstream connections are pooled and pre-warmed +(`idleSessions[serverUuid]`, `createIdleSessionAsync`), so one +connection serves many different inbound callers. Headers are bound at +connection-creation time; there is no per-request injection point. +Making it dynamic would mean disabling pooling, keying the pool by +`(server, caller)`, or threading `AsyncLocalStorage` through the +transport — all far more invasive than the Dockerfile patch above, and +all fragile across upstream bumps. + +**What works instead.** MetaMCP *does* support static per-server custom +headers — `mcp_servers.headers` is a jsonb column that flows straight +into the outbound `requestInit.headers`. So give each caller its own +endpoint chain, where the server row stamps a header identifying it: + +``` +/metamcp//mcp → namespace → server + url: + headers: {"X-MCP-Client": "/1.0"} +``` + +Same backend container, same corpus, same index — only the stamped +header differs. Nothing is duplicated downstream. + +`sql/zsupport-endpoint.sql` is a worked example that adds such a chain +for the zSupport portal against a `zerto-docs` backend. Adapt the names +and the header value for other callers. Apply with: + +```bash +docker cp sql/zsupport-endpoint.sql :/tmp/ +docker exec \ + psql -U -d -v ON_ERROR_STOP=1 -f /tmp/zsupport-endpoint.sql +``` + +**Two gotchas.** + +1. `mcp_servers` has `UNIQUE (name, user_id)`, so the new server row + cannot reuse the existing name. And tools are namespaced + `__`, so the new chain exposes a *different tool + prefix* — any client allow-list must be updated to match, or it will + silently filter out every tool. +2. The downstream server has to actually read the header. This pairs + with a logger that prefers `X-MCP-Client` over `User-Agent`. + ## Delete this repo the day upstream ships a toggle The right fix belongs in MetaMCP itself — either an env var like diff --git a/sql/zsupport-endpoint.sql b/sql/zsupport-endpoint.sql new file mode 100644 index 0000000..359efdf --- /dev/null +++ b/sql/zsupport-endpoint.sql @@ -0,0 +1,48 @@ +-- Dedicated MetaMCP chain for the zSupport portal. +-- +-- Why: MetaMCP proxies with a fresh outbound connection and forwards neither +-- the caller's User-Agent nor X-MCP-Client, so every client behind the gateway +-- lands in one undifferentiated "node" bucket in zerto-docs' usage log. +-- MetaMCP DOES support static per-server custom headers (mcp_servers.headers, +-- already used by the OB1 server for x-brain-key), and those flow into the +-- outbound requestInit.headers. So a second "view" of the same backend that +-- stamps X-MCP-Client gives us attribution with no code change on either side +-- (docs_mcp/usage.py already prefers X-MCP-Client over User-Agent). +-- +-- Same backend container, same corpus, same index — only the stamped header +-- differs. Nothing is duplicated on the zerto-docs side. +-- +-- To revert: DELETE the four rows (endpoint, mapping, namespace, server). + +BEGIN; + +-- 1. Server row — the header lives here (endpoints/namespaces have no such +-- column). Name can't be "zerto-docs": UNIQUE (name, user_id). +INSERT INTO mcp_servers (name, type, url, headers, user_id) +SELECT 'zerto-docs-zsupport', 'STREAMABLE_HTTP', url, + '{"X-MCP-Client": "zsupport/1.0"}'::jsonb, user_id +FROM mcp_servers WHERE name = 'zerto-docs'; + +-- 2. Namespace +INSERT INTO namespaces (name, description, user_id) +SELECT 'zerto-docs-zsupport', + 'zerto-docs corpus, dedicated view for the zSupport portal (adds X-MCP-Client for usage attribution)', + user_id +FROM namespaces WHERE name = 'zerto-docs'; + +-- 3. Map the server into the namespace +INSERT INTO namespace_server_mappings (namespace_uuid, mcp_server_uuid, status) +SELECT n.uuid, s.uuid, 'ACTIVE' +FROM namespaces n, mcp_servers s +WHERE n.name = 'zerto-docs-zsupport' AND s.name = 'zerto-docs-zsupport'; + +-- 4. Public endpoint — mirrors zerto-docs (no api-key auth, no oauth). +INSERT INTO endpoints (name, description, namespace_uuid, + enable_api_key_auth, use_query_param_auth, enable_oauth, user_id) +SELECT 'zerto-docs-zsupport', + 'Dedicated zerto-docs endpoint for the zSupport portal', + n.uuid, false, false, false, e.user_id +FROM namespaces n, endpoints e +WHERE n.name = 'zerto-docs-zsupport' AND e.name = 'zerto-docs'; + +COMMIT;