| @@ -0,0 +1,111 @@ |
| | 1 | // --------------------------------------------------------------------------- |
| | 2 | // A health check must not report `healthy` for a connection whose credential is |
| | 3 | // missing. |
| | 4 | // |
| | 5 | // Rendering SKIPS a placement whose value is unresolved — that is the renderer's |
| | 6 | // documented behaviour, and callers own the policy. The invoke path already |
| | 7 | // refuses (it names "dialing unauthenticated" as the thing it is avoiding), and |
| | 8 | // the OpenAPI health check reports `expired`. The MCP health check had neither, |
| | 9 | // so it dialled unauthenticated, and any server that lists tools without auth |
| | 10 | // answered — reporting a connection with no credential as healthy. |
| | 11 | // |
| | 12 | // That matters more than an ordinary wrong status: health is the signal telling |
| | 13 | // a user to re-authenticate, so `healthy` is the one answer it must never give |
| | 14 | // when the credential is gone. |
| | 15 | // |
| | 16 | // Driven against the plugin's own `checkHealth` rather than through a live |
| | 17 | // connection, because the precondition under test — a connection that EXISTS but |
| | 18 | // whose credential does not resolve — is exactly the state the connection APIs |
| | 19 | // are designed to prevent you from creating. Calling the seam directly is the |
| | 20 | // only way to construct it without faking the thing being tested. |
| | 21 | // --------------------------------------------------------------------------- |
| | 22 | |
| | 23 | import { describe, expect, it } from "@effect/vitest"; |
| | 24 | import { Effect, Layer } from "effect"; |
| | 25 | import { FetchHttpClient } from "effect/unstable/http"; |
| | 26 | |
| | 27 | import { mcpPlugin } from "./plugin"; |
| | 28 | |
| | 29 | const ENDPOINT = "https://mcp.example.test/sse"; |
| | 30 | |
| | 31 | /** A remote MCP integration whose api-key method needs one input. */ |
| | 32 | const config = { |
| | 33 | transport: "remote" as const, |
| | 34 | endpoint: ENDPOINT, |
| | 35 | remoteTransport: "streamable-http" as const, |
| | 36 | authenticationTemplate: [ |
| | 37 | { |
| | 38 | slug: "api_key", |
| | 39 | kind: "apikey" as const, |
| | 40 | placements: [{ carrier: "header" as const, name: "X-Api-Key", variable: "token" }], |
| | 41 | }, |
| | 42 | ], |
| | 43 | }; |
| | 44 | |
| | 45 | /** Answers anything with 200 — standing in for a server that lists tools with no |
| | 46 | * auth at all, which is what turned a missing credential into `healthy`. */ |
| | 47 | const permissiveClientLayer = FetchHttpClient.layer.pipe( |
| | 48 | Layer.provide( |
| | 49 | Layer.succeed(FetchHttpClient.Fetch)( |
| | 50 | (async (_input: RequestInfo | URL) => |
| | 51 | new Response("{}", { |
| | 52 | status: 200, |
| | 53 | headers: { "content-type": "application/json" }, |
| | 54 | })) as typeof globalThis.fetch, |
| | 55 | ), |
| | 56 | ), |
| | 57 | ); |
| | 58 | |
| | 59 | const checkHealthWith = (values: Record<string, string | null>) => |
| | 60 | Effect.gen(function* () { |
| | 61 | const plugin = mcpPlugin(); |
| | 62 | const checkHealth = (plugin as { readonly checkHealth?: unknown }).checkHealth; |
| | 63 | if (typeof checkHealth !== "function") { |
| | 64 | return yield* Effect.die("mcpPlugin no longer exposes checkHealth"); |
| | 65 | } |
| | 66 | return yield* ( |
| | 67 | checkHealth as (input: { |
| | 68 | readonly ctx: { readonly httpClientLayer: typeof permissiveClientLayer }; |
| | 69 | readonly credential: { |
| | 70 | readonly config: typeof config; |
| | 71 | readonly values: Record<string, string | null>; |
| | 72 | readonly template: string; |
| | 73 | readonly connection: string; |
| | 74 | readonly integration: string; |
| | 75 | }; |
| | 76 | }) => Effect.Effect<{ readonly status: string; readonly detail?: string }> |
| | 77 | )({ |
| | 78 | ctx: { httpClientLayer: permissiveClientLayer }, |
| | 79 | credential: { |
| | 80 | config, |
| | 81 | values, |
| | 82 | template: "api_key", |
| | 83 | connection: "main", |
| | 84 | integration: "health_mcp", |
| | 85 | }, |
| | 86 | }); |
| | 87 | }); |
| | 88 | |
| | 89 | describe("MCP health check with an unresolved credential", () => { |
| | 90 | it.effect("reports expired, not healthy, when the api-key input is missing", () => |
| | 91 | Effect.gen(function* () { |
| | 92 | const health = yield* checkHealthWith({}); |
| | 93 | |
| | 94 | // The fetch above answers everything 200, so nothing except the gate |
| | 95 | // stands between this and `healthy`. |
| | 96 | expect(health.status).not.toBe("healthy"); |
| | 97 | expect(health.status).toBe("expired"); |
| | 98 | expect(String(health.detail ?? "")).toContain("token"); |
| | 99 | }), |
| | 100 | ); |
| | 101 | |
| | 102 | it.effect("does not short-circuit when the input IS resolved", () => |
| | 103 | Effect.gen(function* () { |
| | 104 | // The other half: a gate that returned `expired` unconditionally would |
| | 105 | // satisfy the test above while breaking every healthy connection. |
| | 106 | const health = yield* checkHealthWith({ token: "sk-present" }); |
| | 107 | |
| | 108 | expect(health.status).not.toBe("expired"); |
| | 109 | }), |
| | 110 | ); |
| | 111 | }); |