sql: per-caller usage attribution via dedicated endpoint chains
Build & push patched image / build (push) Successful in 1m8s
Build & push patched image / build (push) Successful in 1m8s
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.
This commit is contained in:
@@ -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 * * *"
|
||||
|
||||
@@ -86,6 +86,64 @@ curl -sSo /dev/null -w "%{http_code}\n" -H "User-Agent: $UA" \
|
||||
"https://<your-metamcp-host>/metamcp/<a-public-endpoint>/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/<name>/mcp → namespace <name> → server <name>
|
||||
url: <same backend URL>
|
||||
headers: {"X-MCP-Client": "<name>/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 <postgres-container>:/tmp/
|
||||
docker exec <postgres-container> \
|
||||
psql -U <user> -d <db> -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
|
||||
`<server-name>__<tool>`, 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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user