feat(mcp): distribution-channel client marker in MCP telemetry (#706)

* feat(mcp): distribution-channel client marker in MCP telemetry

Record an optional client marker on mcp.tool_called, mcp.tools_list_called
and mcp.resource_read events so per-channel adoption (e.g. the OpenClaw
skill) is measurable in event_log (180-day TTL).

- Server reads X-Gnubok-Client header, falling back to a ?client= query
  param on the endpoint URL. Sanitized ([A-Za-z0-9._-]{1,64}, lowercased),
  telemetry-only — same trust level as Mcp-Session-Id, never auth.
- The query param works with the already-published gnubok-mcp 1.0.1 via
  GNUBOK_URL, so no npm release is required to start measuring.
- Bridge 1.1.0 additionally forwards GNUBOK_CLIENT as X-Gnubok-Client.

OAuth-path attribution via DCR client_name is a possible follow-up — DCR
is stateless today, so client_name isn't recoverable at token time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): address PR #706 compliance findings

- ropa.yaml: declare the distribution-channel marker in the mcp.telemetry
  processing activity (GDPR Art. 30 — RoPA was drifting from actual flow)
- bridge: mirror the server's allow-list on GNUBOK_CLIENT so an invalid
  value degrades to no header instead of fetch() rejecting every request
- lib/events/types.ts: annotate client as client-supplied/telemetry-only
- test: pin that the allow-list runs on the percent-decoded ?client= value

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-10 14:33:24 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f9ea9c0082
commit 205610d200
7 changed files with 139 additions and 10 deletions
+9 -6
View File
@@ -282,12 +282,15 @@ processing_activities:
name: MCP/agent-telemetri i event_log
purpose: >-
Varje MCP-verktygsanrop loggar metadata (verktygsnamn, felkod,
felmeddelande max 500 tecken, latens, aktör, session) och varje
skill-laddning loggar slug/tier till event_log. Syftet är
tillförlitlighetsanalys av agentgränssnittet: felfrekvens per verktyg,
korrelation mellan laddade skills och efterföljande fel, samt agenters
egenrapporterade feedback (agent.feedback). Inga verktygsargument
eller verktygsresultat persisteras.
felmeddelande max 500 tecken, latens, aktör, session, frivillig
distributionskanal-markör — t.ex. 'openclaw' via X-Gnubok-Client-header
eller ?client=-parameter, sanerad mot allow-list och aldrig använd för
auktorisation) och varje skill-laddning loggar slug/tier till event_log.
Syftet är tillförlitlighetsanalys av agentgränssnittet: felfrekvens per
verktyg, adoption per distributionskanal, korrelation mellan laddade
skills och efterföljande fel, samt agenters egenrapporterade feedback
(agent.feedback). Inga verktygsargument eller verktygsresultat
persisteras.
lawful_basis: art_6_1_f # legitimate interest (service reliability/improvement)
special_category_basis: null
controller: gnubok-tenant
@@ -78,10 +78,15 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
import { handleMcpRequest } from '../server'
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
function mcpRequest(
method: string,
params?: Record<string, unknown>,
id: number | string = 1,
opts: { url?: string; headers?: Record<string, string> } = {}
): Request {
return new Request(opts.url ?? 'http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token', ...opts.headers },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
})
}
@@ -101,6 +106,7 @@ interface ToolCalledPayload {
requestId: string | number | null
userId: string
companyId: string
client: string | null
}
interface ToolsListCalledPayload {
@@ -112,6 +118,7 @@ interface ToolsListCalledPayload {
requestId: string | number | null
userId: string
companyId: string
client: string | null
}
interface ResourceReadPayload {
@@ -126,6 +133,7 @@ interface ResourceReadPayload {
requestId: string | number | null
userId: string
companyId: string
client: string | null
}
async function captureNextToolCalledEvent(): Promise<ToolCalledPayload> {
@@ -280,6 +288,92 @@ describe('mcp.tool_called telemetry', () => {
})
})
describe('client marker telemetry', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('records a lowercased X-Gnubok-Client header on mcp.tool_called', async () => {
const eventPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, {
headers: { 'X-Gnubok-Client': 'OpenClaw' },
})
)
const event = await eventPromise
expect(event.client).toBe('openclaw')
})
it('falls back to the ?client= query param when no header is present', async () => {
const eventPromise = captureNextToolsListEvent()
await handleMcpRequest(
mcpRequest('tools/list', undefined, 1, {
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?client=openclaw',
})
)
const event = await eventPromise
expect(event.client).toBe('openclaw')
})
it('prefers the header over the query param when both are present', async () => {
const eventPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, {
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?client=other',
headers: { 'X-Gnubok-Client': 'openclaw' },
})
)
const event = await eventPromise
expect(event.client).toBe('openclaw')
})
it('runs the allow-list on the percent-decoded query param value', async () => {
const eventPromise = captureNextToolCalledEvent()
// URLSearchParams.get() percent-decodes before our regex runs, so encoded
// payloads can't smuggle disallowed characters past the allow-list.
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, {
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?client=open%63law',
})
)
const event = await eventPromise
expect(event.client).toBe('openclaw')
})
it('drops markers that fail the charset/length sanitation and reports null', async () => {
const eventPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, {
headers: { 'X-Gnubok-Client': 'bad client!<script>' },
})
)
const event = await eventPromise
expect(event.client).toBeNull()
})
it('reports null when no marker is sent (existing clients unchanged)', async () => {
const eventPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} })
)
const event = await eventPromise
expect(event.client).toBeNull()
})
})
describe('mcp.tools_list_called telemetry', () => {
beforeEach(() => {
vi.clearAllMocks()
+17
View File
@@ -103,6 +103,12 @@ interface ActorContext {
* single agent conversation. Not used for auth.
*/
sessionId?: string | null
/**
* Distribution-channel marker from the `X-Gnubok-Client` header or the
* `client` query param on the endpoint URL (e.g. 'openclaw'). Telemetry-only
* — same trust level as Mcp-Session-Id, never used for auth or behavior.
*/
client?: string | null
}
// ── JSON-RPC types ───────────────────────────────────────────
@@ -9631,6 +9637,7 @@ function emitToolCallTelemetry(payload: {
userId: payload.userId,
companyId: payload.companyId,
sessionId: payload.actor.sessionId ?? null,
client: payload.actor.client ?? null,
},
})
.catch((err) => {
@@ -9662,6 +9669,7 @@ function emitToolsListTelemetry(payload: {
userId: payload.userId,
companyId: payload.companyId,
sessionId: payload.actor.sessionId ?? null,
client: payload.actor.client ?? null,
},
})
.catch((err) => {
@@ -9697,6 +9705,7 @@ function emitResourceReadTelemetry(payload: {
userId: payload.userId,
companyId: payload.companyId,
sessionId: payload.actor.sessionId ?? null,
client: payload.actor.client ?? null,
},
})
.catch((err) => {
@@ -9872,11 +9881,19 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// followed metric. It is NOT used for auth.
const rawSessionId = request.headers.get('mcp-session-id')
const sessionId = rawSessionId && /^[A-Za-z0-9_-]{1,128}$/.test(rawSessionId) ? rawSessionId : null
// Distribution-channel marker: `X-Gnubok-Client` header (gnubok-mcp bridge
// ≥1.1 forwards GNUBOK_CLIENT) or a `client` query param on the endpoint URL
// (works with any bridge version and direct HTTP clients). Lets us measure
// per-channel adoption (e.g. an OpenClaw skill) in event_log without auth
// implications.
const rawClient = request.headers.get('x-gnubok-client') ?? new URL(request.url).searchParams.get('client')
const client = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient.toLowerCase() : null
const actor: ActorContext = {
type: 'api_key',
id: apiKeyId,
label: apiKeyName ?? 'Unnamed API key',
sessionId,
client,
}
// ── Parse JSON-RPC ──
+4
View File
@@ -168,6 +168,8 @@ export type CoreEvent =
userId: string
companyId: string
sessionId: string | null // from Mcp-Session-Id header; null if absent
client: string | null // distribution-channel marker (X-Gnubok-Client header / ?client= param, e.g. 'openclaw').
// Client-supplied (allow-list-sanitized) — telemetry only, never identity or authz.
}}
// tools/list — informs us whether agents are using progressive discovery
// (gnubok_search_tools) or pulling the full list. Tool counts vary with
@@ -182,6 +184,7 @@ export type CoreEvent =
userId: string
companyId: string
sessionId: string | null // from Mcp-Session-Id header; null if absent
client: string | null // distribution-channel marker; null if absent
}}
// resources/read — informs us which skills/widgets/data resources actually
// get loaded by agents. `kind` discriminates by URI scheme so we can
@@ -199,6 +202,7 @@ export type CoreEvent =
userId: string
companyId: string
sessionId: string | null // from Mcp-Session-Id header; null if absent
client: string | null // distribution-channel marker; null if absent
}}
// Workflow lifecycle — agents declare "I'm starting month-end-close" via
// gnubok_load_skill (or implicitly by following a skill's recommended tool
+1
View File
@@ -42,6 +42,7 @@ Restart Claude Desktop. The Accounted tools appear in the client and you can sta
|---|---|---|---|
| `GNUBOK_API_KEY` | yes | — | Your `gnubok_sk_*` API key. |
| `GNUBOK_URL` | no | `https://app.gnubok.se/api/extensions/ext/mcp-server/mcp` | Override the MCP endpoint (e.g. for self-hosted Accounted). |
| `GNUBOK_CLIENT` | no | — | Distribution-channel marker (e.g. `openclaw`), sent as `X-Gnubok-Client`. Telemetry only — never affects auth or behavior. |
## Alternative: claude.ai connector (no API key)
+10
View File
@@ -20,6 +20,15 @@
const API_KEY = process.env.GNUBOK_API_KEY
const MCP_URL = process.env.GNUBOK_URL || 'https://app.gnubok.se/api/extensions/ext/mcp-server/mcp'
// Optional distribution-channel marker (e.g. 'openclaw'). Forwarded as
// X-Gnubok-Client and recorded in server telemetry only — never affects auth.
// Mirrors the server's allow-list so an invalid value degrades to "no header"
// instead of fetch() rejecting every request with an invalid-header error.
const rawClient = process.env.GNUBOK_CLIENT
const CLIENT = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient : undefined
if (rawClient && !CLIENT) {
process.stderr.write('gnubok-mcp: ignoring GNUBOK_CLIENT — must match [A-Za-z0-9._-]{1,64}\n')
}
if (!API_KEY) {
process.stderr.write(
@@ -82,6 +91,7 @@ async function handleMessage(line) {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
...(CLIENT ? { 'X-Gnubok-Client': CLIENT } : {}),
},
body: line,
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gnubok-mcp",
"version": "1.0.1",
"version": "1.1.0",
"description": "Connect Claude Desktop to your Accounted bookkeeping account",
"bin": {
"gnubok-mcp": "./index.mjs"