| @@ -1,5 +1,5 @@ |
| 1 | 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 | 3 | import { |
| 4 | 4 | HttpClient, |
| 5 | 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 | +}); |