|
| 1 | +// --------------------------------------------------------------------------- |
| 2 | +// MCP connection-pool key |
| 3 | +// |
| 4 | +// The key decides which pooled session a call may reuse, and it is retained as |
| 5 | +// a `Map` key for the POOL's lifetime — much longer than the call that produced |
| 6 | +// it. Two things therefore have to hold at once, and they pull in opposite |
| 7 | +// directions: |
| 8 | +// |
| 9 | +// * it must still SEPARATE identities — a different credential value, or a |
| 10 | +// different rendered auth header, must never reuse somebody else's |
| 11 | +// authenticated session; |
| 12 | +// * it must not RETAIN the credential — the secret that distinguishes two |
| 13 | +// identities must not survive in the key that distinguishes them. |
| 14 | +// |
| 15 | +// A digest satisfies both. These tests pin both halves, because a change that |
| 16 | +// satisfied only the second (say, dropping the credential from the key) would |
| 17 | +// look like a privacy improvement and be a session-hijack bug. |
| 18 | +// |
| 19 | +// The pool itself composes on top: it is a `Map` keyed by this string, so |
| 20 | +// "different key" ⇒ "different session" is the pool's own property, covered in |
| 21 | +// `connection-pool.test.ts`. |
| 22 | +// --------------------------------------------------------------------------- |
| 23 | + |
| 24 | +import { describe, expect, it } from "@effect/vitest"; |
| 25 | +import { Effect } from "effect"; |
| 26 | + |
| 27 | +import { connectionPoolKey } from "./plugin"; |
| 28 | +import type { ConnectorInput } from "./connection"; |
| 29 | + |
| 30 | +const SECRET = "sk-live-poolkey-Zq7!x-SECRET"; |
| 31 | +const OTHER_SECRET = "sk-live-poolkey-Zq7!x-ROTATED"; |
| 32 | + |
| 33 | +type RemoteInput = Extract<ConnectorInput, { readonly transport: "remote" }>; |
| 34 | + |
| 35 | +const remoteInput = (overrides: Partial<RemoteInput> = {}): RemoteInput => ({ |
| 36 | + transport: "remote", |
| 37 | + endpoint: "https://mcp.example.com/sse", |
| 38 | + remoteTransport: "streamable-http", |
| 39 | + headers: { authorization: `Bearer ${SECRET}` }, |
| 40 | + ...overrides, |
| 41 | +}); |
| 42 | + |
| 43 | +describe("MCP connection-pool key", () => { |
| 44 | + it.effect("is a bare SHA-256 digest — no plaintext rides along", () => |
| 45 | + Effect.gen(function* () { |
| 46 | + const key = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET }); |
| 47 | + |
| 48 | + // Asserted positively as well as negatively: "does not contain the |
| 49 | + // secret" alone would still pass for a key that appended the digest to |
| 50 | + // the plaintext identity. |
| 51 | + expect(key).toMatch(/^[0-9a-f]{64}$/); |
| 52 | + expect(key).not.toContain(SECRET); |
| 53 | + expect(key).not.toContain(`Bearer ${SECRET}`); |
| 54 | + expect(key).not.toContain("mcp.example.com"); |
| 55 | + }), |
| 56 | + ); |
| 57 | + |
| 58 | + it.effect("the same identity keeps producing the same key, so reuse is unchanged", () => |
| 59 | + Effect.gen(function* () { |
| 60 | + const first = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET }); |
| 61 | + const second = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET }); |
| 62 | + |
| 63 | + expect(first).toBe(second); |
| 64 | + }), |
| 65 | + ); |
| 66 | + |
| 67 | + it.effect("a rotated credential value produces a different key", () => |
| 68 | + Effect.gen(function* () { |
| 69 | + // The case this field exists for: a refreshed access token must dial a |
| 70 | + // fresh session rather than reuse one authenticated with the old token. |
| 71 | + const before = yield* connectionPoolKey(remoteInput({ headers: {} }), "bearer", { |
| 72 | + token: SECRET, |
| 73 | + }); |
| 74 | + const after = yield* connectionPoolKey(remoteInput({ headers: {} }), "bearer", { |
| 75 | + token: OTHER_SECRET, |
| 76 | + }); |
| 77 | + |
| 78 | + expect(after).not.toBe(before); |
| 79 | + }), |
| 80 | + ); |
| 81 | + |
| 82 | + it.effect("a different rendered auth header produces a different key", () => |
| 83 | + Effect.gen(function* () { |
| 84 | + // `buildConnectorInput` renders apikey placements onto `headers`, so the |
| 85 | + // same secret reaches the key by a second route. Separation has to hold |
| 86 | + // there too. |
| 87 | + const mine = yield* connectionPoolKey( |
| 88 | + remoteInput({ headers: { authorization: `Bearer ${SECRET}` } }), |
| 89 | + "bearer", |
| 90 | + {}, |
| 91 | + ); |
| 92 | + const theirs = yield* connectionPoolKey( |
| 93 | + remoteInput({ headers: { authorization: `Bearer ${OTHER_SECRET}` } }), |
| 94 | + "bearer", |
| 95 | + {}, |
| 96 | + ); |
| 97 | + |
| 98 | + expect(theirs).not.toBe(mine); |
| 99 | + }), |
| 100 | + ); |
| 101 | + |
| 102 | + it.effect("a credential carried in a query param separates too", () => |
| 103 | + Effect.gen(function* () { |
| 104 | + // Servers that authenticate via `?token=` put the secret here instead. |
| 105 | + const mine = yield* connectionPoolKey( |
| 106 | + remoteInput({ headers: {}, queryParams: { token: SECRET } }), |
| 107 | + "query", |
| 108 | + {}, |
| 109 | + ); |
| 110 | + const theirs = yield* connectionPoolKey( |
| 111 | + remoteInput({ headers: {}, queryParams: { token: OTHER_SECRET } }), |
| 112 | + "query", |
| 113 | + {}, |
| 114 | + ); |
| 115 | + |
| 116 | + expect(theirs).not.toBe(mine); |
| 117 | + expect(mine).not.toContain(SECRET); |
| 118 | + }), |
| 119 | + ); |
| 120 | + |
| 121 | + it.effect("insertion order does not split one identity into two", () => |
| 122 | + Effect.gen(function* () { |
| 123 | + // `sortedRecord` exists for this: a key that changed with property order |
| 124 | + // would silently dial a new session per call and never reuse anything. |
| 125 | + const oneWay = yield* connectionPoolKey( |
| 126 | + remoteInput({ headers: { authorization: `Bearer ${SECRET}`, "x-team": "acme" } }), |
| 127 | + "bearer", |
| 128 | + { token: SECRET, region: "eu" }, |
| 129 | + ); |
| 130 | + const otherWay = yield* connectionPoolKey( |
| 131 | + remoteInput({ headers: { "x-team": "acme", authorization: `Bearer ${SECRET}` } }), |
| 132 | + "bearer", |
| 133 | + { region: "eu", token: SECRET }, |
| 134 | + ); |
| 135 | + |
| 136 | + expect(otherWay).toBe(oneWay); |
| 137 | + }), |
| 138 | + ); |
| 139 | + |
| 140 | + it.effect("a different endpoint or template separates identities", () => |
| 141 | + Effect.gen(function* () { |
| 142 | + const base = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET }); |
| 143 | + const otherEndpoint = yield* connectionPoolKey( |
| 144 | + remoteInput({ endpoint: "https://mcp.other.example.com/sse" }), |
| 145 | + "bearer", |
| 146 | + { token: SECRET }, |
| 147 | + ); |
| 148 | + const otherTemplate = yield* connectionPoolKey(remoteInput(), "apikey", { token: SECRET }); |
| 149 | + |
| 150 | + expect(otherEndpoint).not.toBe(base); |
| 151 | + expect(otherTemplate).not.toBe(base); |
| 152 | + }), |
| 153 | + ); |
| 154 | +}); |
0 commit comments