| @@ -1,5 +1,5 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer, Option, Predicate, Schema } from "effect"; | 2 | import { Effect, Layer, Option, Predicate, Schema, Tracer } from "effect"; |
| 3 | import { | 3 | import { |
| 4 | HttpClient, | 4 | HttpClient, |
| 5 | HttpClientRequest, | 5 | HttpClientRequest, |
| @@ -1034,3 +1034,85 @@ describe("mcpPlugin detect URL-token fallback", () => { · 1028 unmodified lines |
| 1034 | }), | 1034 | }), |
| 1035 | ); | 1035 | ); |
| 1036 | }); | 1036 | }); |
| | 1037 | |
| | 1038 | describe("mcpPlugin endpoint telemetry", () => { |
| | 1039 | // A credential in the endpoint's query string is a first-class supported |
| | 1040 | // input shape here (the shipped preset list carries one, and the add-flow |
| | 1041 | // passes the raw paste through), so the endpoint must be sanitized before it |
| | 1042 | // is stamped onto a span. Synthetic placeholders only. |
| | 1043 | const QUERY_TOKEN = "synthetic-endpoint-token"; |
| | 1044 | const USERINFO_PASSWORD = "synthetic-endpoint-password"; |
| | 1045 | |
| | 1046 | /** Records every span the program opens, so the stamped attributes can be |
| | 1047 | * read back. Port 1 connection-refuses immediately, so detection resolves |
| | 1048 | * without any network dependency. */ |
| | 1049 | const recordingTracer = (spans: Array<Tracer.NativeSpan>) => |
| | 1050 | Tracer.make({ |
| | 1051 | span: (options) => { |
| | 1052 | const span = new Tracer.NativeSpan(options); |
| | 1053 | spans.push(span); |
| | 1054 | return span; |
| | 1055 | }, |
| | 1056 | context: (primitive, fiber) => primitive["~effect/Effect/evaluate"](fiber), |
| | 1057 | }); |
| | 1058 | |
| | 1059 | it.effect("stamps a sanitized endpoint on the detect span", () => |
| | 1060 | Effect.gen(function* () { |
| | 1061 | const spans: Array<Tracer.NativeSpan> = []; |
| | 1062 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); |
| | 1063 | |
| | 1064 | yield* executor.integrations |
| | 1065 | .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`) |
| | 1066 | .pipe(Effect.provideService(Tracer.Tracer, recordingTracer(spans))); |
| | 1067 | |
| | 1068 | const detect = spans.find((span) => span.name === "mcp.plugin.detect"); |
| | 1069 | expect(detect).toBeDefined(); |
| | 1070 | expect(detect?.attributes.get("mcp.endpoint")).toBe("http://127.0.0.1:1/api/mcp"); |
| | 1071 | // The non-sensitive companions keep the trace debuggable. |
| | 1072 | expect(detect?.attributes.get("mcp.endpoint.origin")).toBe("http://127.0.0.1:1"); |
| | 1073 | expect(detect?.attributes.get("mcp.endpoint.has_query")).toBe(true); |
| | 1074 | expect(detect?.attributes.get("mcp.endpoint.has_userinfo")).toBe(true); |
| | 1075 | |
| | 1076 | // Scoped to the plugin's own spans. Effect's HttpClient separately |
| | 1077 | // stamps `url.full`/`url.query` on its outgoing client spans |
| | 1078 | // (`effect/unstable/http/HttpClient.ts:685,690`); those are scrubbed |
| | 1079 | // downstream by the cloud export pipeline's `UrlRedactingSpanProcessor`, |
| | 1080 | // which is not installed at this level. |
| | 1081 | const serialized = JSON.stringify( |
| | 1082 | spans |
| | 1083 | .filter((span) => span.name.startsWith("mcp.plugin.")) |
| | 1084 | .map((span) => Object.fromEntries(span.attributes.entries())), |
| | 1085 | ); |
| | 1086 | expect(serialized).not.toContain(QUERY_TOKEN); |
| | 1087 | expect(serialized).not.toContain(USERINFO_PASSWORD); |
| | 1088 | }), |
| | 1089 | ); |
| | 1090 | |
| | 1091 | it.effect("stamps a sanitized endpoint on the probe_endpoint span", () => |
| | 1092 | Effect.gen(function* () { |
| | 1093 | const spans: Array<Tracer.NativeSpan> = []; |
| | 1094 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); |
| | 1095 | |
| | 1096 | yield* executor.mcp |
| | 1097 | .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`) |
| | 1098 | .pipe(Effect.exit, Effect.provideService(Tracer.Tracer, recordingTracer(spans))); |
| | 1099 | |
| | 1100 | const probe = spans.find((span) => span.name === "mcp.plugin.probe_endpoint"); |
| | 1101 | expect(probe).toBeDefined(); |
| | 1102 | expect(probe?.attributes.get("mcp.endpoint")).toBe("http://127.0.0.1:1/mcp"); |
| | 1103 | expect(probe?.attributes.get("mcp.endpoint.has_query")).toBe(true); |
| | 1104 | |
| | 1105 | // Scoped to the plugin's own spans. Effect's HttpClient separately |
| | 1106 | // stamps `url.full`/`url.query` on its outgoing client spans |
| | 1107 | // (`effect/unstable/http/HttpClient.ts:685,690`); those are scrubbed |
| | 1108 | // downstream by the cloud export pipeline's `UrlRedactingSpanProcessor`, |
| | 1109 | // which is not installed at this level. |
| | 1110 | const serialized = JSON.stringify( |
| | 1111 | spans |
| | 1112 | .filter((span) => span.name.startsWith("mcp.plugin.")) |
| | 1113 | .map((span) => Object.fromEntries(span.attributes.entries())), |
| | 1114 | ); |
| | 1115 | expect(serialized).not.toContain(QUERY_TOKEN); |
| | 1116 | }), |
| | 1117 | ); |
| | 1118 | }); |