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 | 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 | |
| 115 | −// fingerprint capture (parsed JSON-RPC body, whitelisted headers, CF meta) | |
| 116 | −// lives on rs/mcp-do-shared-layer and slots in here when that branch lands. | |
| 114 | +// span) with everything we can learn about a connecting MCP client: the | |
| 115 | +// parsed JSON-RPC body, whitelisted request headers, CF request metadata, | |
| 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 | 227 | const annotateMcpRequest = ( |
| 119 | 228 | request: Request, |
| 120 | − opts: { token: VerifiedToken | null }, | |
| 229 | + opts: { token: VerifiedToken | null; parseBody: boolean }, | |
| 121 | 230 | ): Effect.Effect<void> => |
| 122 | − Effect.annotateCurrentSpan({ | |
| 123 | − "mcp.request.method": request.method, | |
| 124 | − "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), | |
| 125 | − "mcp.request.session_id": request.headers.get("mcp-session-id") ?? "", | |
| 126 | − "mcp.auth.has_bearer": (request.headers.get("authorization") ?? "").startsWith(BEARER_PREFIX), | |
| 127 | − "mcp.auth.verified": !!opts.token, | |
| 128 | − "mcp.auth.organization_id": opts.token?.organizationId ?? "", | |
| 129 | − "mcp.auth.account_id": opts.token?.accountId ?? "", | |
| 231 | + Effect.gen(function* () { | |
| 232 | + const cf = getCfMeta(request); | |
| 233 | + const baseAttrs: Record<string, unknown> = { | |
| 234 | + "mcp.request.method": request.method, | |
| 235 | + "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), | |
| 236 | + "mcp.request.session_id": request.headers.get("mcp-session-id") ?? "", | |
| 237 | + "mcp.auth.has_bearer": (request.headers.get("authorization") ?? "").startsWith(BEARER_PREFIX), | |
| 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 | 357 | const auth = yield* McpAuth; |
| 228 | 358 | const token = yield* auth.verifyBearer(request); |
| 229 | 359 | |
| 230 | − // Annotate before dispatch so even 401s show up with what we know. | |
| 231 | − yield* annotateMcpRequest(request, { token }); | |
| 360 | + // Annotate before dispatch so even 401s show up with what we know. Only | |
| 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 | 365 | if (!token) return unauthorized; |
| 234 | 366 | switch (request.method) { |