Client fingerprint capture originally landed on rs/mcp-do-shared-layer as commit d5818e3d but wasn't included in the merge of PR #281. Ports the logic into this PR's Effect-native annotateMcpRequest helper, replacing the pre-port trace.getActiveSpan().setAttributes call with Effect.annotateCurrentSpan. Captures on every /mcp hit (including unauthorized ones): - CF request metadata (country/city/region/timezone, ASN/AS-org, TLS, HTTP protocol, colo) - Whitelisted request headers under mcp.http.header.* plus the full sorted header-name list to surface anything unexpected; Authorization recorded only as scheme + length - Verified JWT claims (org id + account id) when present - For POST requests, the parsed JSON-RPC body — mcp.rpc.method and method-specific attrs (initialize → mcp.client.{name,version,title, protocol_version}, capability keys, bounded capabilities/info JSON; tools/call → mcp.tool.name; resources/read → mcp.resource.uri; prompts/get → mcp.prompt.name)
Unified · Split
| @@ -108,25 +108,155 @@ export const McpAuthLive = Layer.succeed(McpAuth, { · 107 unmodified lines | |||
| 108 | }); | 108 | }); |
| 109 | 109 | ||
| 110 | // --------------------------------------------------------------------------- | 110 | // --------------------------------------------------------------------------- |
| 111 | // Span annotation | 111 | // Client fingerprint capture |
| 112 | // --------------------------------------------------------------------------- | 112 | // --------------------------------------------------------------------------- |
| 113 | // Annotates the Effect span (which nests under the otel-cf-workers fetch | 113 | // Annotates the Effect span (which nests under the otel-cf-workers fetch |
| 114 | // span) with the minimum we always know about an MCP request. Richer | 114 | // span) with everything we can learn about a connecting MCP client: the |
| 115 | // fingerprint capture (parsed JSON-RPC body, whitelisted headers, CF meta) | 115 | // parsed JSON-RPC body, whitelisted request headers, CF request metadata, |
| 116 | // lives on rs/mcp-do-shared-layer and slots in here when that branch lands. | 116 | // and verified-JWT claims. Lets us compare how each client (Claude Code, |
| 117 | // Claude.ai web, ChatGPT, custom scripts, ...) actually reports over the | ||
| 118 | // wire. Runs before dispatch so unauthorized requests still get fingerprinted. | ||
| 119 | // --------------------------------------------------------------------------- | ||
| 120 | |||
| 121 | type CfRequestMetadata = { | ||
| 122 | country?: string; | ||
| 123 | city?: string; | ||
| 124 | region?: string; | ||
| 125 | timezone?: string; | ||
| 126 | asn?: number; | ||
| 127 | asOrganization?: string; | ||
| 128 | tlsVersion?: string; | ||
| 129 | tlsCipher?: string; | ||
| 130 | httpProtocol?: string; | ||
| 131 | colo?: string; | ||
| 132 | }; | ||
| 133 | |||
| 134 | const getCfMeta = (request: Request): CfRequestMetadata => | ||
| 135 | ((request as unknown as { cf?: CfRequestMetadata }).cf ?? {}) as CfRequestMetadata; | ||
| 136 | |||
| 137 | const HEADERS_TO_DUMP = [ | ||
| 138 | "accept", | ||
| 139 | "accept-encoding", | ||
| 140 | "accept-language", | ||
| 141 | "cache-control", | ||
| 142 | "content-type", | ||
| 143 | "mcp-protocol-version", | ||
| 144 | "origin", | ||
| 145 | "referer", | ||
| 146 | "sec-fetch-dest", | ||
| 147 | "sec-fetch-mode", | ||
| 148 | "sec-fetch-site", | ||
| 149 | "user-agent", | ||
| 150 | "x-client-name", | ||
| 151 | "x-client-version", | ||
| 152 | "x-requested-with", | ||
| 153 | ] as const; | ||
| 154 | |||
| 155 | const dumpHeaders = (request: Request): Record<string, string> => { | ||
| 156 | const out: Record<string, string> = {}; | ||
| 157 | for (const name of HEADERS_TO_DUMP) { | ||
| 158 | const value = request.headers.get(name); | ||
| 159 | if (value !== null) out[`mcp.http.header.${name}`] = value; | ||
| 160 | } | ||
| 161 | const authHeader = request.headers.get("authorization"); | ||
| 162 | if (authHeader) { | ||
| 163 | out["mcp.http.header.authorization.scheme"] = authHeader.split(" ", 1)[0] ?? ""; | ||
| 164 | out["mcp.http.header.authorization.length"] = String(authHeader.length); | ||
| 165 | } | ||
| 166 | // Record the full header name list too — surfaces anything unexpected | ||
| 167 | // without us having to enumerate every possibility up front. | ||
| 168 | out["mcp.http.header.names"] = Array.from(request.headers.keys()).sort().join(","); | ||
| 169 | return out; | ||
| 170 | }; | ||
| 171 | |||
| 172 | type JsonRpcRequestLike = { | ||
| 173 | method?: string; | ||
| 174 | id?: string | number; | ||
| 175 | params?: Record<string, unknown>; | ||
| 176 | }; | ||
| 177 | |||
| 178 | const safeParseJson = async (request: Request): Promise<JsonRpcRequestLike | null> => { | ||
| 179 | try { | ||
| 180 | const clone = request.clone(); | ||
| 181 | const text = await clone.text(); | ||
| 182 | if (!text) return null; | ||
| 183 | const parsed = JSON.parse(text) as JsonRpcRequestLike; | ||
| 184 | if (typeof parsed !== "object" || parsed === null) return null; | ||
| 185 | return parsed; | ||
| 186 | } catch { | ||
| 187 | return null; | ||
| 188 | } | ||
| 189 | }; | ||
| 190 | |||
| 191 | const rpcPayloadAttributes = (payload: JsonRpcRequestLike | null): Record<string, unknown> => { | ||
| 192 | if (!payload) return {}; | ||
| 193 | const attrs: Record<string, unknown> = {}; | ||
| 194 | if (typeof payload.method === "string") attrs["mcp.rpc.method"] = payload.method; | ||
| 195 | if (payload.id !== undefined) attrs["mcp.rpc.id"] = String(payload.id); | ||
| 196 | |||
| 197 | const params = (payload.params ?? {}) as Record<string, unknown>; | ||
| 198 | |||
| 199 | if (payload.method === "initialize") { | ||
| 200 | const clientInfo = (params.clientInfo ?? {}) as Record<string, unknown>; | ||
| 201 | const capabilities = (params.capabilities ?? {}) as Record<string, unknown>; | ||
| 202 | if (typeof clientInfo.name === "string") attrs["mcp.client.name"] = clientInfo.name; | ||
| 203 | if (typeof clientInfo.version === "string") attrs["mcp.client.version"] = clientInfo.version; | ||
| 204 | if (typeof clientInfo.title === "string") attrs["mcp.client.title"] = clientInfo.title; | ||
| 205 | if (typeof params.protocolVersion === "string") { | ||
| 206 | attrs["mcp.client.protocol_version"] = params.protocolVersion; | ||
| 207 | } | ||
| 208 | attrs["mcp.client.capability.keys"] = Object.keys(capabilities).sort().join(","); | ||
| 209 | // Bounded JSON dumps for ad-hoc inspection. Length caps guard against a | ||
| 210 | // pathological client bloating a single span. | ||
| 211 | attrs["mcp.client.info.json"] = JSON.stringify(clientInfo).slice(0, 2000); | ||
| 212 | attrs["mcp.client.capabilities.json"] = JSON.stringify(capabilities).slice(0, 2000); | ||
| 213 | } else if (payload.method === "tools/call") { | ||
| 214 | const name = params.name; | ||
| 215 | if (typeof name === "string") attrs["mcp.tool.name"] = name; | ||
| 216 | } else if (payload.method === "resources/read" || payload.method === "resources/subscribe") { | ||
| 217 | const uri = params.uri; | ||
| 218 | if (typeof uri === "string") attrs["mcp.resource.uri"] = uri; | ||
| 219 | } else if (payload.method === "prompts/get") { | ||
| 220 | const name = params.name; | ||
| 221 | if (typeof name === "string") attrs["mcp.prompt.name"] = name; | ||
| 222 | } | ||
| 223 | |||
| 224 | return attrs; | ||
| 225 | }; | ||
| 117 | 226 | ||
| 118 | const annotateMcpRequest = ( | 227 | const annotateMcpRequest = ( |
| 119 | request: Request, | 228 | request: Request, |
| 120 | opts: { token: VerifiedToken | null }, | 229 | opts: { token: VerifiedToken | null; parseBody: boolean }, |
| 121 | ): Effect.Effect<void> => | 230 | ): Effect.Effect<void> => |
| 122 | Effect.annotateCurrentSpan({ | 231 | Effect.gen(function* () { |
| 123 | "mcp.request.method": request.method, | 232 | const cf = getCfMeta(request); |
| 124 | "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), | 233 | const baseAttrs: Record<string, unknown> = { |
| 125 | "mcp.request.session_id": request.headers.get("mcp-session-id") ?? "", | 234 | "mcp.request.method": request.method, |
| 126 | "mcp.auth.has_bearer": (request.headers.get("authorization") ?? "").startsWith(BEARER_PREFIX), | 235 | "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), |
| 127 | "mcp.auth.verified": !!opts.token, | 236 | "mcp.request.session_id": request.headers.get("mcp-session-id") ?? "", |
| 128 | "mcp.auth.organization_id": opts.token?.organizationId ?? "", | 237 | "mcp.auth.has_bearer": (request.headers.get("authorization") ?? "").startsWith(BEARER_PREFIX), |
| 129 | "mcp.auth.account_id": opts.token?.accountId ?? "", | 238 | "mcp.auth.verified": !!opts.token, |
| 239 | "mcp.auth.organization_id": opts.token?.organizationId ?? "", | ||
| 240 | "mcp.auth.account_id": opts.token?.accountId ?? "", | ||
| 241 | "cf.country": cf.country ?? "", | ||
| 242 | "cf.city": cf.city ?? "", | ||
| 243 | "cf.region": cf.region ?? "", | ||
| 244 | "cf.timezone": cf.timezone ?? "", | ||
| 245 | "cf.asn": cf.asn ?? 0, | ||
| 246 | "cf.as_organization": cf.asOrganization ?? "", | ||
| 247 | "cf.tls_version": cf.tlsVersion ?? "", | ||
| 248 | "cf.tls_cipher": cf.tlsCipher ?? "", | ||
| 249 | "cf.http_protocol": cf.httpProtocol ?? "", | ||
| 250 | "cf.colo": cf.colo ?? "", | ||
| 251 | ...dumpHeaders(request), | ||
| 252 | }; | ||
| 253 | |||
| 254 | const payload = opts.parseBody ? yield* Effect.promise(() => safeParseJson(request)) : null; | ||
| 255 | |||
| 256 | yield* Effect.annotateCurrentSpan({ | ||
| 257 | ...baseAttrs, | ||
| 258 | ...rpcPayloadAttributes(payload), | ||
| 259 | }); | ||
| 130 | }); | 260 | }); |
| 131 | 261 | ||
| 132 | // --------------------------------------------------------------------------- | 262 | // --------------------------------------------------------------------------- |
| @@ -227,8 +357,10 @@ const mcpApp: Effect.Effect< · 94 unmodified lines | |||
| 227 | const auth = yield* McpAuth; | 357 | const auth = yield* McpAuth; |
| 228 | const token = yield* auth.verifyBearer(request); | 358 | const token = yield* auth.verifyBearer(request); |
| 229 | 359 | ||
| 230 | // Annotate before dispatch so even 401s show up with what we know. | 360 | // Annotate before dispatch so even 401s show up with what we know. Only |
| 231 | yield* annotateMcpRequest(request, { token }); | 361 | // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE |
| 362 | // don't carry one. | ||
| 363 | yield* annotateMcpRequest(request, { token, parseBody: request.method === "POST" }); | ||
| 232 | 364 | ||
| 233 | if (!token) return unauthorized; | 365 | if (!token) return unauthorized; |
| 234 | switch (request.method) { | 366 | switch (request.method) { |