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
108});108});
109 109
110// ---------------------------------------------------------------------------110// ---------------------------------------------------------------------------
111// Span annotation111// Client fingerprint capture
112// ---------------------------------------------------------------------------112// ---------------------------------------------------------------------------
113// Annotates the Effect span (which nests under the otel-cf-workers fetch113// Annotates the Effect span (which nests under the otel-cf-workers fetch
114// span) with the minimum we always know about an MCP request. Richer114// 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
121type 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
134const getCfMeta = (request: Request): CfRequestMetadata =>
135 ((request as unknown as { cf?: CfRequestMetadata }).cf ?? {}) as CfRequestMetadata;
136
137const 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
155const 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
172type JsonRpcRequestLike = {
173 method?: string;
174 id?: string | number;
175 params?: Record<string, unknown>;
176};
177
178const 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
191const 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
118const annotateMcpRequest = (227const 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) {