History

feat(cloud): fold MCP client fingerprint capture into Effect HttpApp

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)

Rhys Sullivan committed 6 months ago

Commit 65df7321 parent d071c131 file · +147 −15Browse files

Unified · Split

apps/cloud/src/mcp.ts+147−15
@@ -108,25 +108,155 @@ export const McpAuthLive = Layer.succeed(McpAuth, { · 107 unmodified lines
108108 });
109109
110110 // ---------------------------------------------------------------------------
111−// Span annotation
111+// Client fingerprint capture
112112 // ---------------------------------------------------------------------------
113113 // 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+};
117226
118227 const annotateMcpRequest = (
119228 request: Request,
120− opts: { token: VerifiedToken | null },
229+ opts: { token: VerifiedToken | null; parseBody: boolean },
121230 ): 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+ });
130260 });
131261
132262 // ---------------------------------------------------------------------------
@@ -227,8 +357,10 @@ const mcpApp: Effect.Effect< · 94 unmodified lines
227357 const auth = yield* McpAuth;
228358 const token = yield* auth.verifyBearer(request);
229359
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" });
232364
233365 if (!token) return unauthorized;
234366 switch (request.method) {