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:
co-authored by
Claude Fable 5
parent
f9ea9c0082
commit
205610d200
@@ -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()
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user