* fix(mcp): surface OAuth reauthorization during discovery * e2e: cover MCP OAuth reauthorization during tool refresh * Surface reauthorization from tools/list failures and guard the sync verdict write * Cover post-handshake tools/list 401, lone-401 retry, and the sync verdict swap * Restrict the 401 replay to read-only JSON-RPC methods ---------
Unified · Split
| @@ -0,0 +1,12 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/sdk": patch | ||
| 3 | "@executor-js/plugin-mcp": patch | ||
| 4 | --- | ||
| 5 | |||
| 6 | **Rejected MCP OAuth grants now request reconnect without registering a disposable client** | ||
| 7 | |||
| 8 | Remote MCP catalog discovery used the MCP SDK's interactive OAuth fallback when an upstream rejected Executor's stored bearer with `401`. A background refresh cannot finish that browser authorization, but the SDK first fetched OAuth metadata and dynamically registered another client. Executor then preserved the old catalog under a generic degraded health verdict, so clients saw zero or stale tools without a reliable reconnect signal. | ||
| 9 | |||
| 10 | Executor now stops at the authenticated HTTP boundary for OAuth-backed MCP transports. A rejected stored bearer becomes a structured reauthorization result before OAuth discovery or Dynamic Client Registration runs. Catalog refresh still preserves the last authoritative tools, but records the connection as expired with a reconnect-required detail so the UI and API can direct the user through authorization again. | ||
| 11 | |||
| 12 | API-key and unauthenticated MCP transports keep their existing `401` behavior, and ordinary incomplete discovery results remain degraded. | ||
| @@ -0,0 +1,361 @@ | |||
| 1 | // Selfhost repros for #1816: a tool catalog refresh that meets an OAuth | ||
| 2 | // reauthorization condition must surface it as an actionable expired verdict, | ||
| 3 | // preserve the previously synced catalog, and must never dynamically register | ||
| 4 | // a fresh OAuth client — the saved connection already references one. | ||
| 5 | // | ||
| 6 | // Three variants: | ||
| 7 | // 1. The upstream MCP endpoint rejects a bearer executor still considers | ||
| 8 | // unexpired (the live report): the refresh dials with the stored token, | ||
| 9 | // gets 401, and must come back as reconnect-required without the MCP SDK's | ||
| 10 | // interactive OAuth fallback registering a disposable client. | ||
| 11 | // 2. The token is expired locally and the refresh-token grant is rejected | ||
| 12 | // with `invalid_grant` during the sync's credential resolution: the | ||
| 13 | // recorded dead grant must present as expired on the connection read, not | ||
| 14 | // be buried under a generic tool-sync verdict. | ||
| 15 | // 3. The bearer is honoured at the handshake and revoked by the time | ||
| 16 | // `tools/list` runs: the reauthorization condition surfaces from the | ||
| 17 | // LISTING failure, which the connect-path classification never sees, and | ||
| 18 | // must reach the same expired verdict. | ||
| 19 | import { randomBytes } from "node:crypto"; | ||
| 20 | |||
| 21 | import { Effect } from "effect"; | ||
| 22 | import { expect } from "@effect/vitest"; | ||
| 23 | import type { HttpApiClient } from "effect/unstable/httpapi"; | ||
| 24 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 25 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 26 | import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 27 | import { | ||
| 28 | AuthTemplateSlug, | ||
| 29 | ConnectionName, | ||
| 30 | IntegrationSlug, | ||
| 31 | OAuthClientSlug, | ||
| 32 | } from "@executor-js/sdk/shared"; | ||
| 33 | import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing"; | ||
| 34 | |||
| 35 | import { scenario } from "../src/scenario"; | ||
| 36 | import { Api, Target } from "../src/services"; | ||
| 37 | |||
| 38 | const api = composePluginApi([mcpHttpPlugin()] as const); | ||
| 39 | type Client = HttpApiClient.ForApi<typeof api>; | ||
| 40 | |||
| 41 | const name = ConnectionName.make("main"); | ||
| 42 | const template = AuthTemplateSlug.make("oauth2"); | ||
| 43 | |||
| 44 | const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; | ||
| 45 | |||
| 46 | /** A real MCP server (serves `tools/list` for a one-tool catalog) that only | ||
| 47 | * accepts bearers the OAuth test server issued and still honours. */ | ||
| 48 | const serveTokenGatedMcpServer = (oauth: OAuthTestServerShape) => | ||
| 49 | serveMcpServer(() => makeGreetingMcpServer(), { | ||
| 50 | auth: { | ||
| 51 | validateAuthorization: oauth.acceptsAuthorizationHeader, | ||
| 52 | authorizationServerUrls: [oauth.issuerUrl], | ||
| 53 | scopes: ["channels:history", "users:read"], | ||
| 54 | }, | ||
| 55 | }); | ||
| 56 | |||
| 57 | const requiredRedirect = (response: Response, from: string): string => { | ||
| 58 | const location = response.headers.get("location"); | ||
| 59 | if (!location) { | ||
| 60 | throw new Error(`Expected redirect from ${from}, got HTTP ${response.status}`); | ||
| 61 | } | ||
| 62 | return new URL(location, from).toString(); | ||
| 63 | }; | ||
| 64 | |||
| 65 | /** The test server's login page is plain text with Basic-auth POST — nothing a | ||
| 66 | * browser can click. Complete it out of band and hand back the callback URL. */ | ||
| 67 | const submitProviderLogin = async (loginUrl: string): Promise<string> => { | ||
| 68 | const credentials = Buffer.from("alice:password").toString("base64"); | ||
| 69 | const response = await fetch(loginUrl, { | ||
| 70 | method: "POST", | ||
| 71 | redirect: "manual", | ||
| 72 | headers: { authorization: `Basic ${credentials}` }, | ||
| 73 | }); | ||
| 74 | const location = response.headers.get("location"); | ||
| 75 | if (response.status !== 302 || !location) { | ||
| 76 | throw new Error(`provider login did not redirect (${response.status})`); | ||
| 77 | } | ||
| 78 | return new URL(location, loginUrl).toString(); | ||
| 79 | }; | ||
| 80 | |||
| 81 | const completeAuthorization = (authorizationUrl: string) => | ||
| 82 | Effect.promise(async () => { | ||
| 83 | const login = await fetch(authorizationUrl, { redirect: "manual" }); | ||
| 84 | const loginUrl = requiredRedirect(login, authorizationUrl); | ||
| 85 | const callbackUrl = await submitProviderLogin(loginUrl); | ||
| 86 | const parsed = new URL(callbackUrl); | ||
| 87 | const code = parsed.searchParams.get("code"); | ||
| 88 | if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`); | ||
| 89 | return { code }; | ||
| 90 | }); | ||
| 91 | |||
| 92 | const seedDcrMcpOAuthConnection = ( | ||
| 93 | client: Client, | ||
| 94 | prefix: string, | ||
| 95 | oauth: OAuthTestServerShape, | ||
| 96 | endpoint: string, | ||
| 97 | ) => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const slug = IntegrationSlug.make(freshSlug(prefix)); | ||
| 100 | const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`)); | ||
| 101 | |||
| 102 | yield* client.mcp.addServer({ | ||
| 103 | payload: { | ||
| 104 | transport: "remote", | ||
| 105 | name: `OAuth refresh repro ${String(slug)}`, | ||
| 106 | endpoint, | ||
| 107 | slug: String(slug), | ||
| 108 | authenticationTemplate: [{ kind: "oauth2" }], | ||
| 109 | }, | ||
| 110 | }); | ||
| 111 | yield* Effect.addFinalizer(() => | ||
| 112 | client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore), | ||
| 113 | ); | ||
| 114 | |||
| 115 | const probe = yield* client.oauth.probe({ payload: { url: endpoint } }); | ||
| 116 | if (!probe.registrationEndpoint) { | ||
| 117 | return yield* Effect.die("OAuth probe did not discover a DCR registration endpoint"); | ||
| 118 | } | ||
| 119 | |||
| 120 | const registered = yield* client.oauth.registerDynamic({ | ||
| 121 | payload: { | ||
| 122 | owner: "org", | ||
| 123 | slug: clientSlug, | ||
| 124 | issuer: probe.issuer ?? null, | ||
| 125 | registrationEndpoint: probe.registrationEndpoint, | ||
| 126 | authorizationUrl: probe.authorizationUrl, | ||
| 127 | tokenUrl: probe.tokenUrl, | ||
| 128 | resource: probe.resource ?? endpoint, | ||
| 129 | scopes: probe.scopesSupported ?? [], | ||
| 130 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 131 | clientName: "Executor e2e MCP OAuth refresh repro", | ||
| 132 | originIntegration: slug, | ||
| 133 | }, | ||
| 134 | }); | ||
| 135 | yield* Effect.addFinalizer(() => | ||
| 136 | client.oauth | ||
| 137 | .removeClient({ params: { slug: registered.client }, payload: { owner: "org" } }) | ||
| 138 | .pipe(Effect.ignore), | ||
| 139 | ); | ||
| 140 | |||
| 141 | const started = yield* client.oauth.start({ | ||
| 142 | payload: { | ||
| 143 | owner: "org", | ||
| 144 | client: registered.client, | ||
| 145 | clientOwner: "org", | ||
| 146 | name, | ||
| 147 | integration: slug, | ||
| 148 | template, | ||
| 149 | }, | ||
| 150 | }); | ||
| 151 | expect(started.status, "DCR MCP OAuth starts an authorization-code redirect").toBe("redirect"); | ||
| 152 | if (started.status !== "redirect") return yield* Effect.die("OAuth start did not redirect"); | ||
| 153 | |||
| 154 | const callback = yield* completeAuthorization(started.authorizationUrl); | ||
| 155 | yield* client.oauth.complete({ payload: { state: started.state, code: callback.code } }); | ||
| 156 | yield* Effect.addFinalizer(() => | ||
| 157 | client.connections | ||
| 158 | .remove({ params: { owner: "org", integration: slug, name } }) | ||
| 159 | .pipe(Effect.ignore), | ||
| 160 | ); | ||
| 161 | yield* oauth.clearRequests; | ||
| 162 | |||
| 163 | return { slug }; | ||
| 164 | }); | ||
| 165 | |||
| 166 | const registrationRequests = (oauth: OAuthTestServerShape) => | ||
| 167 | Effect.map(oauth.requests, (requests) => | ||
| 168 | requests | ||
| 169 | .filter((request) => request.path === "/register") | ||
| 170 | .map((request) => `${request.method} ${request.path}`), | ||
| 171 | ); | ||
| 172 | |||
| 173 | scenario( | ||
| 174 | "MCP OAuth · tool refresh on an upstream-rejected bearer surfaces reconnect without re-registering the DCR client", | ||
| 175 | { | ||
| 176 | timeout: 180_000, | ||
| 177 | }, | ||
| 178 | Effect.scoped( | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const target = yield* Target; | ||
| 181 | const { client: makeApiClient } = yield* Api; | ||
| 182 | const identity = yield* target.newIdentity(); | ||
| 183 | const client = yield* makeApiClient(api, identity); | ||
| 184 | |||
| 185 | // Long-lived tokens: executor's stored expiry stays in the future, so | ||
| 186 | // the refresh dials the MCP endpoint with the stored bearer. | ||
| 187 | const oauth = yield* serveOAuthTestServer({ | ||
| 188 | scopes: ["channels:history", "users:read"], | ||
| 189 | }); | ||
| 190 | const mcp = yield* serveTokenGatedMcpServer(oauth); | ||
| 191 | const { slug } = yield* seedDcrMcpOAuthConnection( | ||
| 192 | client, | ||
| 193 | "mcp-refresh-401", | ||
| 194 | oauth, | ||
| 195 | mcp.endpoint, | ||
| 196 | ); | ||
| 197 | |||
| 198 | // Baseline: with the bearer honoured, the refresh syncs the real catalog. | ||
| 199 | const synced = yield* client.connections.refresh({ | ||
| 200 | params: { owner: "org", integration: slug, name }, | ||
| 201 | }); | ||
| 202 | expect( | ||
| 203 | synced.map((tool) => String(tool.name)), | ||
| 204 | "the healthy connection syncs the server's catalog", | ||
| 205 | ).toEqual(["simple_echo"]); | ||
| 206 | |||
| 207 | // The provider revokes the grant server-side; executor has no idea and | ||
| 208 | // still considers the stored token unexpired. | ||
| 209 | const issued = yield* oauth.issuedAccessTokens; | ||
| 210 | expect(issued.length, "the completed OAuth flow minted a bearer").toBeGreaterThan(0); | ||
| 211 | yield* Effect.forEach(issued, (token) => oauth.revokeAccessToken(token)); | ||
| 212 | yield* oauth.clearRequests; | ||
| 213 | |||
| 214 | const refreshed = yield* client.connections.refresh({ | ||
| 215 | params: { owner: "org", integration: slug, name }, | ||
| 216 | }); | ||
| 217 | |||
| 218 | const registers = yield* registrationRequests(oauth); | ||
| 219 | expect( | ||
| 220 | registers, | ||
| 221 | "a noninteractive tool refresh must not dynamically register a fresh OAuth client", | ||
| 222 | ).toEqual([]); | ||
| 223 | |||
| 224 | expect( | ||
| 225 | refreshed.map((tool) => String(tool.name)), | ||
| 226 | "the previously synced catalog is preserved through the failed refresh", | ||
| 227 | ).toEqual(["simple_echo"]); | ||
| 228 | |||
| 229 | const reread = yield* client.connections.get({ | ||
| 230 | params: { owner: "org", integration: slug, name }, | ||
| 231 | }); | ||
| 232 | console.info(`[BUG repro] post-refresh health: ${JSON.stringify(reread.lastHealth ?? null)}`); | ||
| 233 | expect( | ||
| 234 | reread.lastHealth?.status, | ||
| 235 | "an upstream-rejected bearer is a reauthorization condition, not an anonymous degraded sync", | ||
| 236 | ).toBe("expired"); | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ); | ||
| 240 | |||
| 241 | scenario( | ||
| 242 | "MCP OAuth · invalid_grant during tool refresh presents expired, not a buried sync failure", | ||
| 243 | { | ||
| 244 | timeout: 180_000, | ||
| 245 | }, | ||
| 246 | Effect.scoped( | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const target = yield* Target; | ||
| 249 | const { client: makeApiClient } = yield* Api; | ||
| 250 | const identity = yield* target.newIdentity(); | ||
| 251 | const client = yield* makeApiClient(api, identity); | ||
| 252 | |||
| 253 | // Every minted token is already expired and the refresh grant is dead: | ||
| 254 | // the sync's own credential resolution meets `invalid_grant`. | ||
| 255 | const oauth = yield* serveOAuthTestServer({ | ||
| 256 | scopes: ["channels:history", "users:read"], | ||
| 257 | supportRefresh: false, | ||
| 258 | tokenExpiresInSeconds: 0, | ||
| 259 | invalidRefreshTokenDescription: "Grant not found", | ||
| 260 | }); | ||
| 261 | const mcp = yield* serveTokenGatedMcpServer(oauth); | ||
| 262 | const { slug } = yield* seedDcrMcpOAuthConnection( | ||
| 263 | client, | ||
| 264 | "mcp-refresh-dead", | ||
| 265 | oauth, | ||
| 266 | mcp.endpoint, | ||
| 267 | ); | ||
| 268 | yield* oauth.clearRequests; | ||
| 269 | |||
| 270 | yield* client.connections.refresh({ | ||
| 271 | params: { owner: "org", integration: slug, name }, | ||
| 272 | }); | ||
| 273 | |||
| 274 | const registers = yield* registrationRequests(oauth); | ||
| 275 | expect( | ||
| 276 | registers, | ||
| 277 | "a dead-grant tool refresh must not dynamically register a fresh OAuth client", | ||
| 278 | ).toEqual([]); | ||
| 279 | |||
| 280 | const reread = yield* client.connections.get({ | ||
| 281 | params: { owner: "org", integration: slug, name }, | ||
| 282 | }); | ||
| 283 | console.info(`[BUG repro] post-refresh health: ${JSON.stringify(reread.lastHealth ?? null)}`); | ||
| 284 | expect( | ||
| 285 | reread.lastHealth?.status, | ||
| 286 | "the recorded dead grant presents as expired on the connection read", | ||
| 287 | ).toBe("expired"); | ||
| 288 | expect( | ||
| 289 | reread.lastHealth?.detail, | ||
| 290 | "the provider rejection detail survives to the user", | ||
| 291 | ).toContain("Grant not found"); | ||
| 292 | }), | ||
| 293 | ), | ||
| 294 | ); | ||
| 295 | |||
| 296 | scenario( | ||
| 297 | "MCP OAuth · a bearer rejected during tools/list after a successful handshake surfaces reconnect without re-registering", | ||
| 298 | { | ||
| 299 | timeout: 180_000, | ||
| 300 | }, | ||
| 301 | Effect.scoped( | ||
| 302 | Effect.gen(function* () { | ||
| 303 | const target = yield* Target; | ||
| 304 | const { client: makeApiClient } = yield* Api; | ||
| 305 | const identity = yield* target.newIdentity(); | ||
| 306 | const client = yield* makeApiClient(api, identity); | ||
| 307 | |||
| 308 | const oauth = yield* serveOAuthTestServer({ | ||
| 309 | scopes: ["channels:history", "users:read"], | ||
| 310 | }); | ||
| 311 | const mcp = yield* serveTokenGatedMcpServer(oauth); | ||
| 312 | const { slug } = yield* seedDcrMcpOAuthConnection( | ||
| 313 | client, | ||
| 314 | "mcp-refresh-list-401", | ||
| 315 | oauth, | ||
| 316 | mcp.endpoint, | ||
| 317 | ); | ||
| 318 | |||
| 319 | // Baseline: with the bearer honoured, the refresh syncs the real catalog. | ||
| 320 | const synced = yield* client.connections.refresh({ | ||
| 321 | params: { owner: "org", integration: slug, name }, | ||
| 322 | }); | ||
| 323 | expect( | ||
| 324 | synced.map((tool) => String(tool.name)), | ||
| 325 | "the healthy connection syncs the server's catalog", | ||
| 326 | ).toEqual(["simple_echo"]); | ||
| 327 | |||
| 328 | // Revocation landing between the handshake and the listing: the server | ||
| 329 | // keeps honouring the bearer for `initialize` but answers every | ||
| 330 | // `tools/list` with the auth wall. The connect-path 401 classification | ||
| 331 | // never fires — the reauthorization signal must survive the listing | ||
| 332 | // failure instead. | ||
| 333 | yield* mcp.rejectSessionMethod("tools/list", 401); | ||
| 334 | yield* oauth.clearRequests; | ||
| 335 | |||
| 336 | const refreshed = yield* client.connections.refresh({ | ||
| 337 | params: { owner: "org", integration: slug, name }, | ||
| 338 | }); | ||
| 339 | |||
| 340 | const registers = yield* registrationRequests(oauth); | ||
| 341 | expect( | ||
| 342 | registers, | ||
| 343 | "a noninteractive tool refresh must not dynamically register a fresh OAuth client", | ||
| 344 | ).toEqual([]); | ||
| 345 | |||
| 346 | expect( | ||
| 347 | refreshed.map((tool) => String(tool.name)), | ||
| 348 | "the previously synced catalog is preserved through the failed refresh", | ||
| 349 | ).toEqual(["simple_echo"]); | ||
| 350 | |||
| 351 | const reread = yield* client.connections.get({ | ||
| 352 | params: { owner: "org", integration: slug, name }, | ||
| 353 | }); | ||
| 354 | console.info(`[BUG repro] post-refresh health: ${JSON.stringify(reread.lastHealth ?? null)}`); | ||
| 355 | expect( | ||
| 356 | reread.lastHealth?.status, | ||
| 357 | "a post-handshake 401 during listing is a reauthorization condition, not an anonymous degraded sync", | ||
| 358 | ).toBe("expired"); | ||
| 359 | }), | ||
| 360 | ), | ||
| 361 | ); | ||
| @@ -29,7 +29,7 @@ import { ConnectionAlreadyExistsError } from "./errors"; · 28 unmodified lines | |||
| 29 | import { createExecutor } from "./executor"; | 29 | import { createExecutor } from "./executor"; |
| 30 | import { StorageError, type FumaDb } from "./fuma-runtime"; | 30 | import { StorageError, type FumaDb } from "./fuma-runtime"; |
| 31 | import { HealthCheckResult } from "./health-check"; | 31 | import { HealthCheckResult } from "./health-check"; |
| 32 | import { definePlugin } from "./plugin"; | 32 | import { definePlugin, type ResolveToolsResult } from "./plugin"; |
| 33 | import type { CredentialProvider } from "./provider"; | 33 | import type { CredentialProvider } from "./provider"; |
| 34 | import { makeTestConfig, makeTestExecutor } from "./testing"; | 34 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 35 | import { ToolResult } from "./tool-result"; | 35 | import { ToolResult } from "./tool-result"; |
| @@ -2327,6 +2327,10 @@ const makeHealthHarness = (options?: { · 2291 unmodified lines | |||
| 2327 | // Cleared before it runs, so the conflicting write it performs (through | 2327 | // Cleared before it runs, so the conflicting write it performs (through |
| 2328 | // the unwrapped `config.db`) is not intercepted again. | 2328 | // the unwrapped `config.db`) is not intercepted again. |
| 2329 | beforeHealthPersist: null as Effect.Effect<void> | null, | 2329 | beforeHealthPersist: null as Effect.Effect<void> | null, |
| 2330 | // Replaces the plugin's `resolveTools` outcome, so a test can drive the | ||
| 2331 | // incomplete-catalog path that persists a plugin-supplied health verdict | ||
| 2332 | // (`stampSyncedWithHealth`). | ||
| 2333 | resolveTools: null as Effect.Effect<ResolveToolsResult> | null, | ||
| 2330 | }; | 2334 | }; |
| 2331 | // Wraps the executor's FumaDb handle so `beforeHealthPersist` can commit a | 2335 | // Wraps the executor's FumaDb handle so `beforeHealthPersist` can commit a |
| 2332 | // conflicting write in the exact window the write guards must close. | 2336 | // conflicting write in the exact window the write guards must close. |
| @@ -2379,7 +2383,11 @@ const makeHealthHarness = (options?: { · 46 unmodified lines | |||
| 2379 | credentialProviders: [countingProvider], | 2383 | credentialProviders: [countingProvider], |
| 2380 | storage: () => ({}), | 2384 | storage: () => ({}), |
| 2381 | resolveTools: () => | 2385 | resolveTools: () => |
| 2382 | Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), | 2386 | Effect.suspend( |
| 2387 | () => | ||
| 2388 | hooks.resolveTools ?? | ||
| 2389 | Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), | ||
| 2390 | ), | ||
| 2383 | invokeTool: ({ toolRow, credential, args }) => | 2391 | invokeTool: ({ toolRow, credential, args }) => |
| 2384 | Effect.as( | 2392 | Effect.as( |
| 2385 | hooks.onInvoke, | 2393 | hooks.onInvoke, |
| @@ -2977,6 +2985,41 @@ describe("verdict write guards close the check-to-write window", () => { · 591 unmodified lines | |||
| 2977 | expect(row?.lastHealth).toMatchObject({ status: "degraded", detail: SYNC_DETAIL }); | 2985 | expect(row?.lastHealth).toMatchObject({ status: "degraded", detail: SYNC_DETAIL }); |
| 2978 | }), | 2986 | }), |
| 2979 | ); | 2987 | ); |
| 2988 | |||
| 2989 | it.effect("tool-sync verdict persist: a reconnect landing inside the window wins", () => | ||
| 2990 | Effect.gen(function* () { | ||
| 2991 | const { executor, stamp, persisted, hooks } = yield* makeHealthHarness(); | ||
| 2992 | // Age the stamp so the reconnect's bump lands in a different granule. | ||
| 2993 | yield* stamp({ updated_at: new Date(Date.now() - STALE_MS) }); | ||
| 2994 | // The refresh's discovery meets a reauthorization condition on the OLD | ||
| 2995 | // credential and reports an actionable expired verdict... | ||
| 2996 | hooks.resolveTools = Effect.succeed({ | ||
| 2997 | tools: [], | ||
| 2998 | incomplete: true, | ||
| 2999 | incompleteReason: "MCP OAuth re-authorization required", | ||
| 3000 | health: { | ||
| 3001 | status: "expired" as const, | ||
| 3002 | checkedAt: Date.now(), | ||
| 3003 | detail: "MCP OAuth reauthorization required", | ||
| 3004 | }, | ||
| 3005 | }); | ||
| 3006 | // ...while a reconnect commits inside the check-to-write window: the | ||
| 3007 | // replacement grant clears the verdict and any dead-grant state and | ||
| 3008 | // bumps the stamp. The read-side dead-grant guard cannot refuse the | ||
| 3009 | // stale verdict — the reconnected row has nothing for it to observe — | ||
| 3010 | // so only the compare-and-swap can. | ||
| 3011 | hooks.beforeHealthPersist = stamp({ | ||
| 3012 | provider_state: null, | ||
| 3013 | last_health: null, | ||
| 3014 | updated_at: new Date(), | ||
| 3015 | }).pipe(Effect.asVoid); | ||
| 3016 | |||
| 3017 | yield* executor.connections.refresh(REF); | ||
| 3018 | |||
| 3019 | const row = yield* persisted(); | ||
| 3020 | expect(row?.lastHealth ?? null).toBeNull(); | ||
| 3021 | }), | ||
| 3022 | ); | ||
| 2980 | }); | 3023 | }); |
| 2981 | 3024 | ||
| 2982 | describe("credential-only health path", () => { | 3025 | describe("credential-only health path", () => { |
| @@ -125,11 +125,20 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines | |||
| 125 | const diagnosticsPlugin = definePlugin(() => ({ | 125 | const diagnosticsPlugin = definePlugin(() => ({ |
| 126 | id: "diagnostics" as const, | 126 | id: "diagnostics" as const, |
| 127 | storage: () => ({}), | 127 | storage: () => ({}), |
| 128 | resolveTools: () => | 128 | resolveTools: ({ connection }) => |
| 129 | Effect.succeed({ | 129 | Effect.succeed({ |
| 130 | tools: [], | 130 | tools: [], |
| 131 | incomplete: true, | 131 | incomplete: true, |
| 132 | incompleteReason: "Schema introspection was rejected", | 132 | incompleteReason: "Schema introspection was rejected", |
| 133 | ...(String(connection.integration) === "diagnostics_expired" | ||
| 134 | ? { | ||
| 135 | health: { | ||
| 136 | status: "expired" as const, | ||
| 137 | checkedAt: Date.now(), | ||
| 138 | detail: "Reconnect the upstream OAuth grant", | ||
| 139 | }, | ||
| 140 | } | ||
| 141 | : {}), | ||
| 133 | }), | 142 | }), |
| 134 | extension: (ctx) => ({ | 143 | extension: (ctx) => ({ |
| 135 | seed: () => | 144 | seed: () => |
| @@ -138,6 +147,12 @@ const diagnosticsPlugin = definePlugin(() => ({ · 2 unmodified lines | |||
| 138 | description: "Diagnostics", | 147 | description: "Diagnostics", |
| 139 | config: {}, | 148 | config: {}, |
| 140 | }), | 149 | }), |
| 150 | seedExpired: () => | ||
| 151 | ctx.core.integrations.register({ | ||
| 152 | slug: IntegrationSlug.make("diagnostics_expired"), | ||
| 153 | description: "Expired diagnostics", | ||
| 154 | config: {}, | ||
| 155 | }), | ||
| 141 | }), | 156 | }), |
| 142 | }))(); | 157 | }))(); |
| 143 | 158 | ||
| @@ -470,6 +485,44 @@ describe("createExecutor", () => { · 326 unmodified lines | |||
| 470 | }), | 485 | }), |
| 471 | ); | 486 | ); |
| 472 | 487 | ||
| 488 | it.effect("preserves actionable health from an incomplete tool catalog", () => | ||
| 489 | Effect.gen(function* () { | ||
| 490 | const executor = yield* makeTestExecutor({ | ||
| 491 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, | ||
| 492 | coreTools: {}, | ||
| 493 | }); | ||
| 494 | yield* executor.diagnostics.seedExpired(); | ||
| 495 | |||
| 496 | yield* executor.execute( | ||
| 497 | ToolAddress.make("executor.coreTools.connections.create"), | ||
| 498 | { | ||
| 499 | owner: "org", | ||
| 500 | name: "main", | ||
| 501 | integration: "diagnostics_expired", | ||
| 502 | template: "none", | ||
| 503 | }, | ||
| 504 | { onElicitation: "accept-all" }, | ||
| 505 | ); | ||
| 506 | |||
| 507 | const refreshed = yield* executor.execute( | ||
| 508 | ToolAddress.make("executor.coreTools.connections.refresh"), | ||
| 509 | { | ||
| 510 | owner: "org", | ||
| 511 | name: "main", | ||
| 512 | integration: "diagnostics_expired", | ||
| 513 | }, | ||
| 514 | { onElicitation: "accept-all" }, | ||
| 515 | ); | ||
| 516 | expect(refreshed).toMatchObject({ | ||
| 517 | tools: [], | ||
| 518 | lastHealth: { | ||
| 519 | status: "expired", | ||
| 520 | detail: "Reconnect the upstream OAuth grant", | ||
| 521 | }, | ||
| 522 | }); | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | |||
| 473 | it.effect("hands pasted credential entry to the web UI", () => | 526 | it.effect("hands pasted credential entry to the web UI", () => |
| 474 | Effect.gen(function* () { | 527 | Effect.gen(function* () { |
| 475 | const executor = yield* makeTestExecutor({ | 528 | const executor = yield* makeTestExecutor({ |
| @@ -3309,22 +3309,44 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3308 unmodified lines | |||
| 3309 | // clears it, and reconnect re-syncs tools anyway). Read fresh, since | 3309 | // clears it, and reconnect re-syncs tools anyway). Read fresh, since |
| 3310 | // the recorder wrote AFTER this sync's row was loaded. Keep stamping | 3310 | // the recorder wrote AFTER this sync's row was loaded. Keep stamping |
| 3311 | // the sync time so the stale-catalog check does not re-attempt this | 3311 | // the sync time so the stale-catalog check does not re-attempt this |
| 3312 | // connection on every read. | 3312 | // connection on every read. A plugin-supplied actionable `health` |
| 3313 | const stampSyncedWithHealth = (reason: string) => | 3313 | // (e.g. the MCP plugin's reauthorization-required verdict) replaces |
| 3314 | // the generic tool-sync verdict, never a recorded dead grant's. | ||
| 3315 | // | ||
| 3316 | // The UPDATE compare-and-swaps on the stamps the fresh read observed | ||
| 3317 | // (the same legs as `persistHealthResult`): the fresh read alone | ||
| 3318 | // leaves a check-to-write window, and a reconnect landing inside it | ||
| 3319 | // clears the dead-grant state this guard reads — the reconnected row | ||
| 3320 | // has nothing left to observe, so only the swap can refuse stamping | ||
| 3321 | // the old credential's verdict onto the new grant. The loser is a | ||
| 3322 | // silent no-op (the conflicting writer is newer evidence, and a | ||
| 3323 | // reconnect re-syncs tools anyway); at worst the skipped | ||
| 3324 | // `tools_synced_at` stamp makes the next read re-attempt the sync. | ||
| 3325 | const stampSyncedWithHealth = (reason: string, health?: HealthCheckResult) => | ||
| 3314 | findConnectionRow(ref).pipe( | 3326 | findConnectionRow(ref).pipe( |
| 3315 | Effect.flatMap((fresh) => | 3327 | Effect.flatMap((fresh) => |
| 3316 | core.updateMany("connection", { | 3328 | fresh === null |
| 3317 | where: connectionWhere, | 3329 | ? Effect.void |
| 3318 | set: | 3330 | : core |
| 3319 | fresh !== null && | 3331 | .updateMany("connection", { |
| 3320 | oauthReauthRequiredFromProviderState(fresh.provider_state) !== null | 3332 | where: (b: AnyCb) => |
| 3321 | ? { tools_synced_at: Date.now() } | 3333 | b.and( |
| 3322 | : { | 3334 | connectionWhere(b), |
| 3323 | tools_synced_at: Date.now(), | 3335 | b("updated_at", "=", fresh.updated_at), |
| 3324 | last_health: toolSyncHealth(reason), | 3336 | fresh.tools_synced_at == null |
| 3325 | updated_at: new Date(), | 3337 | ? b.isNull("tools_synced_at") |
| 3326 | }, | 3338 | : b("tools_synced_at", "=", fresh.tools_synced_at), |
| 3327 | }), | 3339 | ), |
| 3340 | set: | ||
| 3341 | oauthReauthRequiredFromProviderState(fresh.provider_state) !== null | ||
| 3342 | ? { tools_synced_at: Date.now() } | ||
| 3343 | : { | ||
| 3344 | tools_synced_at: Date.now(), | ||
| 3345 | last_health: health ?? toolSyncHealth(reason), | ||
| 3346 | updated_at: new Date(), | ||
| 3347 | }, | ||
| 3348 | }) | ||
| 3349 | .pipe(Effect.asVoid), | ||
| 3328 | ), | 3350 | ), |
| 3329 | ); | 3351 | ); |
| 3330 | 3352 | ||
| @@ -3390,7 +3412,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 59 unmodified lines | |||
| 3390 | // server isn't re-dialed on every read; the freshness TTL re-attempts | 3412 | // server isn't re-dialed on every read; the freshness TTL re-attempts |
| 3391 | // later. | 3413 | // later. |
| 3392 | const reason = syncHealthReason(result); | 3414 | const reason = syncHealthReason(result); |
| 3393 | yield* stampSyncedWithHealth(reason); | 3415 | yield* stampSyncedWithHealth(reason, result.health); |
| 3394 | yield* Effect.logWarning("executor tool sync preserved catalog", { | 3416 | yield* Effect.logWarning("executor tool sync preserved catalog", { |
| 3395 | reason, | 3417 | reason, |
| 3396 | integration: String(ref.integration), | 3418 | integration: String(ref.integration), |
| @@ -367,6 +367,10 @@ export interface ResolveToolsResult { · 366 unmodified lines | |||
| 367 | /** Human-readable reason for an incomplete listing. Persisted by core when it | 367 | /** Human-readable reason for an incomplete listing. Persisted by core when it |
| 368 | * preserves the prior catalog so operators can see why data is stale. */ | 368 | * preserves the prior catalog so operators can see why data is stale. */ |
| 369 | readonly incompleteReason?: string; | 369 | readonly incompleteReason?: string; |
| 370 | /** An actionable connection-health outcome discovered while enumerating the | ||
| 371 | * catalog. Core persists it while preserving the prior non-authoritative | ||
| 372 | * catalog. Omit for ordinary transient discovery failures. */ | ||
| 373 | readonly health?: HealthCheckResult; | ||
| 370 | } | 374 | } |
| 371 | 375 | ||
| 372 | export interface ProjectToolSchemaInput<TStore = unknown> { | 376 | export interface ProjectToolSchemaInput<TStore = unknown> { |
| @@ -49,6 +49,10 @@ export type RemoteConnectorInput = Omit< · 48 unmodified lines | |||
| 49 | readonly headers?: Record<string, string>; | 49 | readonly headers?: Record<string, string>; |
| 50 | readonly queryParams?: Record<string, string>; | 50 | readonly queryParams?: Record<string, string>; |
| 51 | readonly authProvider?: OAuthClientProvider; | 51 | readonly authProvider?: OAuthClientProvider; |
| 52 | /** This provider only replays a resolved bearer. A 401 cannot be recovered | ||
| 53 | * inside the MCP SDK and must return to core as reconnect-required before | ||
| 54 | * the SDK attempts discovery or Dynamic Client Registration. */ | ||
| 55 | readonly staticOAuthBearer?: boolean; | ||
| 52 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | 56 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| 53 | }; | 57 | }; |
| 54 | 58 | ||
| @@ -153,6 +157,22 @@ const nestedMcpHttpTransportError = (cause: unknown): Option.Option<McpHttpTrans · 98 unmodified lines | |||
| 153 | return Option.none(); | 157 | return Option.none(); |
| 154 | }; | 158 | }; |
| 155 | 159 | ||
| 160 | /** Walks a (possibly SDK-wrapped) failure cause for the fetch adapter's | ||
| 161 | * `McpOAuthReauthorizationRequired`. Shared with tool discovery: the same | ||
| 162 | * interception fires during `tools/list`, where the SDK wraps the rejection | ||
| 163 | * before it reaches the listing's catch site. */ | ||
| 164 | export const hasNestedOAuthReauthorization = (cause: unknown): boolean => { | ||
| 165 | let current: unknown = cause; | ||
| 166 | for (let depth = 0; depth < 8; depth += 1) { | ||
| 167 | if (Predicate.isTagged(current, "McpOAuthReauthorizationRequired")) return true; | ||
| 168 | const decodedCause = decodeExternalTransportCause(current); | ||
| 169 | if (Option.isNone(decodedCause)) return false; | ||
| 170 | current = decodedCause.value.cause ?? decodedCause.value.data?.cause; | ||
| 171 | if (current === undefined) return false; | ||
| 172 | } | ||
| 173 | return false; | ||
| 174 | }; | ||
| 175 | |||
| 156 | const externalTransportCodes = (cause: unknown): ReadonlySet<string> => { | 176 | const externalTransportCodes = (cause: unknown): ReadonlySet<string> => { |
| 157 | const codes = new Set<string>(); | 177 | const codes = new Set<string>(); |
| 158 | let current: unknown = cause; | 178 | let current: unknown = cause; |
| @@ -228,8 +248,53 @@ const awaitAbort = (signal: AbortSignal): Effect.Effect<void> => · 69 unmodified lines | |||
| 228 | signal.addEventListener("abort", () => resume(Effect.void), { once: true }); | 248 | signal.addEventListener("abort", () => resume(Effect.void), { once: true }); |
| 229 | }); | 249 | }); |
| 230 | 250 | ||
| 251 | /** JSON-RPC methods the 401 replay below may re-send. An HTTP 401 does not | ||
| 252 | * guarantee the server did no work before rejecting, so replay is limited to | ||
| 253 | * methods that are read-only or handshake-only: `initialize` and | ||
| 254 | * `notifications/initialized` (handshake), `ping` (side-effect-free by | ||
| 255 | * spec), and `tools/list` (discovery). That is every method this codebase | ||
| 256 | * sends except `tools/call`, which may have executed its side effect before | ||
| 257 | * the 401 and must NEVER run twice. The set is deliberately closed: an | ||
| 258 | * unlisted or unparseable method does not replay either. */ | ||
| 259 | const REPLAYABLE_JSONRPC_METHODS: ReadonlySet<string> = new Set([ | ||
| 260 | "initialize", | ||
| 261 | "notifications/initialized", | ||
| 262 | "ping", | ||
| 263 | "tools/list", | ||
| 264 | ]); | ||
| 265 | |||
| 266 | const JsonRpcMethodOnly = Schema.Struct({ method: Schema.String }); | ||
| 267 | const decodeJsonRpcMethods = Schema.decodeUnknownOption( | ||
| 268 | Schema.fromJsonString(Schema.Union([JsonRpcMethodOnly, Schema.Array(JsonRpcMethodOnly)])), | ||
| 269 | ); | ||
| 270 | |||
| 271 | /** Whether a 401-rejected request is safe to replay once. Only requests whose | ||
| 272 | * buffered JSON-RPC body consists entirely of allowlisted read-only methods | ||
| 273 | * qualify (a batch replays only when EVERY element is allowlisted). The one | ||
| 274 | * bodyless exception is `GET`, the streamable-http server->client stream | ||
| 275 | * open, which carries no JSON-RPC request and is read-only by HTTP | ||
| 276 | * semantics. Everything else — `tools/call` above all — fails closed. */ | ||
| 277 | const isReplaySafeRequest = (init: RequestInit | undefined): boolean => { | ||
| 278 | const body = init?.body; | ||
| 279 | if (body == null) return httpMethodFrom(init?.method) === "GET"; | ||
| 280 | // Both remote SDK transports send JSON-RPC bodies as `JSON.stringify` | ||
| 281 | // strings. Any other body shape cannot be verified read-only here. | ||
| 282 | if (typeof body !== "string") return false; | ||
| 283 | return Option.match(decodeJsonRpcMethods(body), { | ||
| 284 | onNone: () => false, | ||
| 285 | onSome: (parsed) => { | ||
| 286 | const messages = Array.isArray(parsed) ? parsed : [parsed]; | ||
| 287 | return ( | ||
| 288 | messages.length > 0 && | ||
| 289 | messages.every((message) => REPLAYABLE_JSONRPC_METHODS.has(message.method)) | ||
| 290 | ); | ||
| 291 | }, | ||
| 292 | }); | ||
| 293 | }; | ||
| 294 | |||
| 231 | const fetchFromHttpClientLayer = ( | 295 | const fetchFromHttpClientLayer = ( |
| 232 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | 296 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 297 | staticOAuthBearer: boolean, | ||
| 233 | ): FetchLike => { | 298 | ): FetchLike => { |
| 234 | const execute: FetchLike = async (url, init) => { | 299 | const execute: FetchLike = async (url, init) => { |
| 235 | const headers = headersFrom(init?.headers); | 300 | const headers = headersFrom(init?.headers); |
| @@ -262,6 +327,10 @@ const fetchFromHttpClientLayer = ( · 26 unmodified lines | |||
| 262 | headers: responseHeaders, | 327 | headers: responseHeaders, |
| 263 | }); | 328 | }); |
| 264 | }).pipe(Effect.mapError(normalizeHttpClientFailure), Effect.provide(httpClientLayer)); | 329 | }).pipe(Effect.mapError(normalizeHttpClientFailure), Effect.provide(httpClientLayer)); |
| 330 | // Executor resolves and refreshes OAuth credentials before constructing | ||
| 331 | // this transport. If that stored bearer is rejected, the MCP SDK cannot | ||
| 332 | // complete its interactive fallback in a catalog refresh and would perform | ||
| 333 | // avoidable DCR first. Stop at the authenticated HTTP boundary instead. | ||
| 265 | // A 403 carrying an RFC 6750 insufficient_scope challenge is intercepted | 334 | // A 403 carrying an RFC 6750 insufficient_scope challenge is intercepted |
| 266 | // HERE, below the SDK: with an authProvider the SDK would consume the | 335 | // HERE, below the SDK: with an authProvider the SDK would consume the |
| 267 | // challenge and re-run auth ("upscoping"), which our static-token | 336 | // challenge and re-run auth ("upscoping"), which our static-token |
| @@ -270,9 +339,39 @@ const fetchFromHttpClientLayer = ( · 2 unmodified lines | |||
| 270 | // tagged error from the fetch adapter (a true runtime edge: the SDK | 339 | // tagged error from the fetch adapter (a true runtime edge: the SDK |
| 271 | // consumes promise rejections) so it reaches the invoke/connect catch | 340 | // consumes promise rejections) so it reaches the invoke/connect catch |
| 272 | // sites verbatim. | 341 | // sites verbatim. |
| 273 | const promise = Effect.runPromise(effect).then((response) => { | 342 | const promise = Effect.runPromise(effect).then(async (response) => { |
| 274 | if (response.status === 403) { | 343 | let settled = response; |
| 275 | const challenge = response.headers.get("www-authenticate"); | 344 | if (staticOAuthBearer && settled.status === 401) { |
| 345 | // One immediate replay before classifying — but only for requests the | ||
| 346 | // allowlist proves read-only (`isReplaySafeRequest`). A lone 401 on | ||
| 347 | // discovery/handshake traffic can be a transient upstream blip (a | ||
| 348 | // proxy hiccup, a racing key rotation on the server), and stamping | ||
| 349 | // reauthorization-required from a single sample forces a needless | ||
| 350 | // reconnect; the replay itself is possible because every body this | ||
| 351 | // adapter builds is buffered (`applyBody`), never a one-shot stream. | ||
| 352 | // A side-effectful method (`tools/call`) never replays: a 401 does | ||
| 353 | // not guarantee the server did no work first, so re-sending could | ||
| 354 | // execute the action twice. Its single 401 classifies directly — | ||
| 355 | // the invocation has already failed either way, and the | ||
| 356 | // transient-blip concern only justified the retry on read-only | ||
| 357 | // paths. Retrying/classifying here is preferred over demanding a | ||
| 358 | // `WWW-Authenticate` challenge because a headerless 401 | ||
| 359 | // (noncompliant server; the MCP auth spec requires the challenge) | ||
| 360 | // must STILL stop at this boundary — falling through would hand the | ||
| 361 | // 401 to the SDK, whose interactive fallback performs exactly the | ||
| 362 | // avoidable discovery/DCR this interception exists to prevent. | ||
| 363 | if (isReplaySafeRequest(init)) { | ||
| 364 | settled = await Effect.runPromise(effect); | ||
| 365 | } | ||
| 366 | if (settled.status === 401) { | ||
| 367 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Fetch-compatible adapter can only signal through a rejected promise | ||
| 368 | throw new McpOAuthReauthorizationRequired({ | ||
| 369 | message: "MCP OAuth re-authorization required", | ||
| 370 | }); | ||
| 371 | } | ||
| 372 | } | ||
| 373 | if (settled.status === 403) { | ||
| 374 | const challenge = settled.headers.get("www-authenticate"); | ||
| 276 | if ( | 375 | if ( |
| 277 | challenge !== null && | 376 | challenge !== null && |
| 278 | detectInsufficientScope({ headers: { "www-authenticate": challenge } }) !== null | 377 | detectInsufficientScope({ headers: { "www-authenticate": challenge } }) !== null |
| @@ -284,7 +383,7 @@ const fetchFromHttpClientLayer = ( · 5 unmodified lines | |||
| 284 | }); | 383 | }); |
| 285 | } | 384 | } |
| 286 | } | 385 | } |
| 287 | return response; | 386 | return settled; |
| 288 | }); | 387 | }); |
| 289 | // Mark the request promise observed (a no-op handler on the ORIGINAL | 388 | // Mark the request promise observed (a no-op handler on the ORIGINAL |
| 290 | // promise; callers still see the rejection). The MCP SDK fires some | 389 | // promise; callers still see the rejection). The MCP SDK fires some |
| @@ -335,7 +434,7 @@ const connectionFailure = ( · 44 unmodified lines | |||
| 335 | message: string, | 434 | message: string, |
| 336 | cause: unknown, | 435 | cause: unknown, |
| 337 | ): McpConnectionError | McpOAuthReauthorizationRequired => { | 436 | ): McpConnectionError | McpOAuthReauthorizationRequired => { |
| 338 | if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) { | 437 | if (hasNestedOAuthReauthorization(cause)) { |
| 339 | return new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required" }); | 438 | return new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required" }); |
| 340 | } | 439 | } |
| 341 | if (Predicate.isTagged(cause, "McpInsufficientScopeError")) { | 440 | if (Predicate.isTagged(cause, "McpInsufficientScopeError")) { |
| @@ -495,7 +594,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 153 unmodified lines | |||
| 495 | const headers = input.headers ?? {}; | 594 | const headers = input.headers ?? {}; |
| 496 | const remoteTransport = input.remoteTransport ?? "auto"; | 595 | const remoteTransport = input.remoteTransport ?? "auto"; |
| 497 | const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; | 596 | const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; |
| 498 | const fetch = input.httpClientLayer ? fetchFromHttpClientLayer(input.httpClientLayer) : undefined; | 597 | const fetch = input.httpClientLayer |
| 598 | ? fetchFromHttpClientLayer(input.httpClientLayer, input.staticOAuthBearer === true) | ||
| 599 | : undefined; | ||
| 499 | 600 | ||
| 500 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); | 601 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); |
| 501 | 602 | ||
| @@ -4,8 +4,9 @@ · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { Duration, Effect, Option, Predicate } from "effect"; | 5 | import { Duration, Effect, Option, Predicate } from "effect"; |
| 6 | 6 | ||
| 7 | import type { McpConnection, McpConnector } from "./connection"; | 7 | import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection"; |
| 8 | import { McpToolDiscoveryError } from "./errors"; | 8 | import { McpToolDiscoveryError } from "./errors"; |
| 9 | import { httpStatusFromCause } from "./http-status"; | ||
| 9 | import { | 10 | import { |
| 10 | decodeListToolsPage, | 11 | decodeListToolsPage, |
| 11 | extractManifestFromListToolsResult, | 12 | extractManifestFromListToolsResult, |
| @@ -49,11 +50,34 @@ const listAllTools = ( · 37 unmodified lines | |||
| 49 | const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor }; | 50 | const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor }; |
| 50 | const listResult = yield* Effect.tryPromise({ | 51 | const listResult = yield* Effect.tryPromise({ |
| 51 | try: () => connection.client.listTools(params), | 52 | try: () => connection.client.listTools(params), |
| 52 | catch: () => | 53 | // A bearer accepted at the handshake can be rejected by the time the |
| 53 | new McpToolDiscoveryError({ | 54 | // listing runs (revocation landing mid-discovery). The fetch adapter's |
| 55 | // reauthorization interception fires here exactly as during connect, | ||
| 56 | // so keep the structural signals — the reauthorization tag and the | ||
| 57 | // HTTP status — instead of collapsing every listing failure into a | ||
| 58 | // generic discovery error; discarding them left the connection on a | ||
| 59 | // sticky degraded verdict instead of reconnect-required. Statuses come | ||
| 60 | // from `httpStatusFromCause` only: the connection-only numeric-code | ||
| 61 | // decode must stay out, since JSON-RPC error codes are not HTTP | ||
| 62 | // statuses. | ||
| 63 | catch: (cause) => { | ||
| 64 | if (hasNestedOAuthReauthorization(cause)) { | ||
| 65 | return new McpToolDiscoveryError({ | ||
| 66 | stage: "list_tools", | ||
| 67 | message: "Failed listing MCP tools: MCP OAuth re-authorization required", | ||
| 68 | reauthorizationRequired: true, | ||
| 69 | }); | ||
| 70 | } | ||
| 71 | const httpStatus = httpStatusFromCause(cause); | ||
| 72 | return new McpToolDiscoveryError({ | ||
| 54 | stage: "list_tools", | 73 | stage: "list_tools", |
| 55 | message: "Failed listing MCP tools", | 74 | message: |
| 56 | }), | 75 | httpStatus === undefined |
| 76 | ? "Failed listing MCP tools" | ||
| 77 | : `Failed listing MCP tools (HTTP ${httpStatus})`, | ||
| 78 | ...(httpStatus === undefined ? {} : { httpStatus }), | ||
| 79 | }); | ||
| 80 | }, | ||
| 57 | }); | 81 | }); |
| 58 | 82 | ||
| 59 | const decoded = decodeListToolsPage(listResult); | 83 | const decoded = decodeListToolsPage(listResult); |
| @@ -116,6 +140,10 @@ export const discoverTools = ( · 56 unmodified lines | |||
| 116 | const httpStatus = Predicate.isTagged(failure, "McpConnectionError") | 140 | const httpStatus = Predicate.isTagged(failure, "McpConnectionError") |
| 117 | ? failure.httpStatus | 141 | ? failure.httpStatus |
| 118 | : undefined; | 142 | : undefined; |
| 143 | const reauthorizationRequired = Predicate.isTagged( | ||
| 144 | failure, | ||
| 145 | "McpOAuthReauthorizationRequired", | ||
| 146 | ); | ||
| 119 | const timedOut = | 147 | const timedOut = |
| 120 | Predicate.isTagged(failure, "McpConnectionError") && | 148 | Predicate.isTagged(failure, "McpConnectionError") && |
| 121 | failure.failureKind === "timeout"; | 149 | failure.failureKind === "timeout"; |
| @@ -123,6 +151,7 @@ export const discoverTools = ( · 1 unmodified line | |||
| 123 | stage: "connect", | 151 | stage: "connect", |
| 124 | message: `Failed connecting to MCP server: ${failure.message}`, | 152 | message: `Failed connecting to MCP server: ${failure.message}`, |
| 125 | ...(httpStatus !== undefined ? { httpStatus } : {}), | 153 | ...(httpStatus !== undefined ? { httpStatus } : {}), |
| 154 | ...(reauthorizationRequired ? { reauthorizationRequired: true } : {}), | ||
| 126 | ...(timedOut ? { timedOut } : {}), | 155 | ...(timedOut ? { timedOut } : {}), |
| 127 | }); | 156 | }); |
| 128 | }), | 157 | }), |
| @@ -40,8 +40,13 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 39 unmodified lines | |||
| 40 | { | 40 | { |
| 41 | stage: Schema.Literals(["connect", "list_tools"]), | 41 | stage: Schema.Literals(["connect", "list_tools"]), |
| 42 | message: Schema.String, | 42 | message: Schema.String, |
| 43 | /** HTTP status from the underlying connect failure, when known. */ | 43 | /** HTTP status from the underlying connect or tools/list failure, when |
| 44 | * known. */ | ||
| 44 | httpStatus: Schema.optional(Schema.Number), | 45 | httpStatus: Schema.optional(Schema.Number), |
| 46 | /** The MCP OAuth provider reached the interactive authorization boundary. | ||
| 47 | * Catalog callers use this structural signal to request reconnect without | ||
| 48 | * parsing or exposing an upstream error message. */ | ||
| 49 | reauthorizationRequired: Schema.optional(Schema.Boolean), | ||
| 45 | /** Discovery hit its deadline (`discoverTools` timeout). Structural, so | 50 | /** Discovery hit its deadline (`discoverTools` timeout). Structural, so |
| 46 | * the health check can report `probe_timeout` — a slow-but-alive server — | 51 | * the health check can report `probe_timeout` — a slow-but-alive server — |
| 47 | * without string-matching the message. */ | 52 | * without string-matching the message. */ |
| @@ -103,6 +103,132 @@ const serveCallToolServer = (callTool: CallToolResponder) => · 102 unmodified lines | |||
| 103 | }), | 103 | }), |
| 104 | ); | 104 | ); |
| 105 | 105 | ||
| 106 | const rejectedOAuthDiscoveryLayer = (endpoint: string) => { | ||
| 107 | const issuer = new URL(endpoint).origin; | ||
| 108 | const requests: string[] = []; | ||
| 109 | const layer = Layer.succeed(HttpClient.HttpClient)( | ||
| 110 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 111 | requests.push(request.url); | ||
| 112 | const url = new URL(request.url); | ||
| 113 | const response = | ||
| 114 | request.url === endpoint | ||
| 115 | ? new Response("", { status: 401 }) | ||
| 116 | : url.pathname === "/.well-known/oauth-protected-resource/mcp" | ||
| 117 | ? Response.json({ resource: endpoint, authorization_servers: [issuer] }) | ||
| 118 | : url.pathname === "/.well-known/oauth-authorization-server" | ||
| 119 | ? Response.json({ | ||
| 120 | issuer, | ||
| 121 | authorization_endpoint: `${issuer}/authorize`, | ||
| 122 | token_endpoint: `${issuer}/token`, | ||
| 123 | registration_endpoint: `${issuer}/register`, | ||
| 124 | response_types_supported: ["code"], | ||
| 125 | code_challenge_methods_supported: ["S256"], | ||
| 126 | }) | ||
| 127 | : url.pathname === "/register" | ||
| 128 | ? Response.json( | ||
| 129 | { | ||
| 130 | client_id: "replacement-client", | ||
| 131 | redirect_uris: ["http://localhost/oauth/callback"], | ||
| 132 | grant_types: ["authorization_code", "refresh_token"], | ||
| 133 | response_types: ["code"], | ||
| 134 | token_endpoint_auth_method: "none", | ||
| 135 | }, | ||
| 136 | { status: 201 }, | ||
| 137 | ) | ||
| 138 | : new Response("unexpected request", { status: 500 }); | ||
| 139 | return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | return { layer, requests }; | ||
| 143 | }; | ||
| 144 | |||
| 145 | const clientRpcOf = (request: HttpClientRequest.HttpClientRequest): JsonRpcRequest | undefined => | ||
| 146 | Predicate.isTagged(request.body, "Uint8Array") | ||
| 147 | ? Option.getOrUndefined(decodeJsonRpcRequest(new TextDecoder().decode(request.body.body))) | ||
| 148 | : undefined; | ||
| 149 | |||
| 150 | /** Streamable-http fixture for the post-handshake auth wall: the handshake | ||
| 151 | * methods succeed and `tools/list` answers 401 for the first | ||
| 152 | * `revokedListResponses` calls (Infinity = the bearer stays revoked). The | ||
| 153 | * OAuth discovery + DCR endpoints are served so an SDK fallback that DID see | ||
| 154 | * the 401 could register — the ledger proves it never gets there. Entries are | ||
| 155 | * `pathname` or `pathname#jsonRpcMethod`. */ | ||
| 156 | const listRejectionFixtureLayer = (endpoint: string, revokedListResponses: number) => { | ||
| 157 | const issuer = new URL(endpoint).origin; | ||
| 158 | const requests: string[] = []; | ||
| 159 | let remaining = revokedListResponses; | ||
| 160 | const jsonRpc = (rpc: JsonRpcRequest, result: unknown) => | ||
| 161 | Response.json({ jsonrpc: "2.0", id: rpc.id ?? null, result }); | ||
| 162 | const layer = Layer.succeed(HttpClient.HttpClient)( | ||
| 163 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 164 | const url = new URL(request.url); | ||
| 165 | const rpc = request.url === endpoint ? clientRpcOf(request) : undefined; | ||
| 166 | requests.push(rpc === undefined ? url.pathname : `${url.pathname}#${rpc.method}`); | ||
| 167 | const respond = (response: Response) => | ||
| 168 | Effect.succeed(HttpClientResponse.fromWeb(request, response)); | ||
| 169 | if (request.url === endpoint) { | ||
| 170 | if (rpc === undefined) return respond(new Response("SSE disabled", { status: 405 })); | ||
| 171 | if (rpc.method === "initialize") { | ||
| 172 | return respond( | ||
| 173 | jsonRpc(rpc, { | ||
| 174 | protocolVersion: "2025-06-18", | ||
| 175 | capabilities: { tools: {} }, | ||
| 176 | serverInfo: { name: "list-reject-fixture", version: "1.0.0" }, | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | if (rpc.method === "notifications/initialized") { | ||
| 181 | return respond(new Response("", { status: 202 })); | ||
| 182 | } | ||
| 183 | if (rpc.method === "tools/list") { | ||
| 184 | if (remaining > 0) { | ||
| 185 | remaining -= 1; | ||
| 186 | return respond(new Response("", { status: 401 })); | ||
| 187 | } | ||
| 188 | return respond( | ||
| 189 | jsonRpc(rpc, { | ||
| 190 | tools: [ | ||
| 191 | { | ||
| 192 | name: "echo_back", | ||
| 193 | description: "Echoes", | ||
| 194 | inputSchema: { type: "object", properties: {} }, | ||
| 195 | }, | ||
| 196 | ], | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | return respond(new Response("Unexpected JSON-RPC method", { status: 400 })); | ||
| 201 | } | ||
| 202 | const response = | ||
| 203 | url.pathname === "/.well-known/oauth-protected-resource/mcp" | ||
| 204 | ? Response.json({ resource: endpoint, authorization_servers: [issuer] }) | ||
| 205 | : url.pathname === "/.well-known/oauth-authorization-server" | ||
| 206 | ? Response.json({ | ||
| 207 | issuer, | ||
| 208 | authorization_endpoint: `${issuer}/authorize`, | ||
| 209 | token_endpoint: `${issuer}/token`, | ||
| 210 | registration_endpoint: `${issuer}/register`, | ||
| 211 | response_types_supported: ["code"], | ||
| 212 | code_challenge_methods_supported: ["S256"], | ||
| 213 | }) | ||
| 214 | : url.pathname === "/register" | ||
| 215 | ? Response.json( | ||
| 216 | { | ||
| 217 | client_id: "replacement-client", | ||
| 218 | redirect_uris: ["http://localhost/oauth/callback"], | ||
| 219 | grant_types: ["authorization_code", "refresh_token"], | ||
| 220 | response_types: ["code"], | ||
| 221 | token_endpoint_auth_method: "none", | ||
| 222 | }, | ||
| 223 | { status: 201 }, | ||
| 224 | ) | ||
| 225 | : new Response("unexpected request", { status: 500 }); | ||
| 226 | return respond(response); | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | return { layer, requests }; | ||
| 230 | }; | ||
| 231 | |||
| 106 | // `tools/call` responders. Both embed a "do-not-leak" sentinel the assertions | 232 | // `tools/call` responders. Both embed a "do-not-leak" sentinel the assertions |
| 107 | // confirm never reaches the caller-facing failure. | 233 | // confirm never reaches the caller-facing failure. |
| 108 | const httpStatusCallTool = | 234 | const httpStatusCallTool = |
| @@ -335,6 +461,128 @@ describe("joinToolPath", () => { · 226 unmodified lines | |||
| 335 | // --------------------------------------------------------------------------- | 461 | // --------------------------------------------------------------------------- |
| 336 | 462 | ||
| 337 | describe("mcpPlugin", () => { | 463 | describe("mcpPlugin", () => { |
| 464 | it.effect("surfaces OAuth reauthorization from resolveTools as expired health", () => | ||
| 465 | Effect.gen(function* () { | ||
| 466 | const endpoint = "https://mcp.example.test/mcp"; | ||
| 467 | const plugin = mcpPlugin(); | ||
| 468 | const ledger = rejectedOAuthDiscoveryLayer(endpoint); | ||
| 469 | const result = yield* plugin.resolveTools!({ | ||
| 470 | config: { | ||
| 471 | transport: "remote", | ||
| 472 | endpoint, | ||
| 473 | remoteTransport: "streamable-http", | ||
| 474 | authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }], | ||
| 475 | }, | ||
| 476 | connection: { | ||
| 477 | owner: "org", | ||
| 478 | integration: IntegrationSlug.make("oauth_mcp"), | ||
| 479 | name: ConnectionName.make("main"), | ||
| 480 | }, | ||
| 481 | template: AuthTemplateSlug.make("oauth2"), | ||
| 482 | getValues: () => Effect.succeed({ token: "rejected-token" }), | ||
| 483 | getValue: () => Effect.succeed("rejected-token"), | ||
| 484 | httpClientLayer: ledger.layer, | ||
| 485 | ctx: null as never, | ||
| 486 | integration: null as never, | ||
| 487 | storage: {}, | ||
| 488 | }); | ||
| 489 | |||
| 490 | expect(result).toMatchObject({ | ||
| 491 | tools: [], | ||
| 492 | incomplete: true, | ||
| 493 | health: { | ||
| 494 | status: "expired", | ||
| 495 | detail: expect.stringContaining("reauthorization"), | ||
| 496 | }, | ||
| 497 | }); | ||
| 498 | expect(ledger.requests.filter((url) => new URL(url).pathname === "/register")).toEqual([]); | ||
| 499 | }), | ||
| 500 | ); | ||
| 501 | |||
| 502 | // The connect path above classifies a handshake 401. This covers the other | ||
| 503 | // half of the window: the bearer is honoured at `initialize` and revoked by | ||
| 504 | // the time `tools/list` runs, so the reauthorization signal surfaces from | ||
| 505 | // the LISTING failure, not the connect failure. | ||
| 506 | it.effect( | ||
| 507 | "surfaces OAuth reauthorization when tools/list rejects a bearer the handshake accepted", | ||
| 508 | () => | ||
| 509 | Effect.gen(function* () { | ||
| 510 | const endpoint = "https://mcp.example.test/mcp"; | ||
| 511 | const plugin = mcpPlugin(); | ||
| 512 | const ledger = listRejectionFixtureLayer(endpoint, Number.POSITIVE_INFINITY); | ||
| 513 | const result = yield* plugin.resolveTools!({ | ||
| 514 | config: { | ||
| 515 | transport: "remote", | ||
| 516 | endpoint, | ||
| 517 | remoteTransport: "streamable-http", | ||
| 518 | authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }], | ||
| 519 | }, | ||
| 520 | connection: { | ||
| 521 | owner: "org", | ||
| 522 | integration: IntegrationSlug.make("oauth_mcp"), | ||
| 523 | name: ConnectionName.make("main"), | ||
| 524 | }, | ||
| 525 | template: AuthTemplateSlug.make("oauth2"), | ||
| 526 | getValues: () => Effect.succeed({ token: "revoked-after-handshake" }), | ||
| 527 | getValue: () => Effect.succeed("revoked-after-handshake"), | ||
| 528 | httpClientLayer: ledger.layer, | ||
| 529 | ctx: null as never, | ||
| 530 | integration: null as never, | ||
| 531 | storage: {}, | ||
| 532 | }); | ||
| 533 | |||
| 534 | expect(result).toMatchObject({ | ||
| 535 | tools: [], | ||
| 536 | incomplete: true, | ||
| 537 | health: { | ||
| 538 | status: "expired", | ||
| 539 | detail: expect.stringContaining("reauthorization"), | ||
| 540 | }, | ||
| 541 | }); | ||
| 542 | expect(ledger.requests.filter((entry) => entry === "/register")).toEqual([]); | ||
| 543 | // Exactly 2: the original tools/list plus the adapter's single | ||
| 544 | // read-only replay. A third request would mean the replay loops; a | ||
| 545 | // single one would mean a lone 401 classified without the | ||
| 546 | // transient-blip re-sample. | ||
| 547 | expect(ledger.requests.filter((entry) => entry === "/mcp#tools/list")).toHaveLength(2); | ||
| 548 | }), | ||
| 549 | ); | ||
| 550 | |||
| 551 | // A LONE 401 is not evidence of a revoked bearer: transient upstream blips | ||
| 552 | // must not stamp reauthorization-required. The adapter replays the request | ||
| 553 | // once and only a repeated 401 classifies. | ||
| 554 | it.effect("retries a lone tools/list 401 instead of demanding reauthorization", () => | ||
| 555 | Effect.gen(function* () { | ||
| 556 | const endpoint = "https://mcp.example.test/mcp"; | ||
| 557 | const plugin = mcpPlugin(); | ||
| 558 | const ledger = listRejectionFixtureLayer(endpoint, 1); | ||
| 559 | const result = yield* plugin.resolveTools!({ | ||
| 560 | config: { | ||
| 561 | transport: "remote", | ||
| 562 | endpoint, | ||
| 563 | remoteTransport: "streamable-http", | ||
| 564 | authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }], | ||
| 565 | }, | ||
| 566 | connection: { | ||
| 567 | owner: "org", | ||
| 568 | integration: IntegrationSlug.make("oauth_mcp"), | ||
| 569 | name: ConnectionName.make("main"), | ||
| 570 | }, | ||
| 571 | template: AuthTemplateSlug.make("oauth2"), | ||
| 572 | getValues: () => Effect.succeed({ token: "blipped-token" }), | ||
| 573 | getValue: () => Effect.succeed("blipped-token"), | ||
| 574 | httpClientLayer: ledger.layer, | ||
| 575 | ctx: null as never, | ||
| 576 | integration: null as never, | ||
| 577 | storage: {}, | ||
| 578 | }); | ||
| 579 | |||
| 580 | expect(result.incomplete).not.toBe(true); | ||
| 581 | expect(result.tools.map((tool) => String(tool.name))).toEqual(["echo_back"]); | ||
| 582 | expect(ledger.requests.filter((entry) => entry === "/mcp#tools/list")).toHaveLength(2); | ||
| 583 | }), | ||
| 584 | ); | ||
| 585 | |||
| 338 | it.effect("creates executor with mcp plugin", () => | 586 | it.effect("creates executor with mcp plugin", () => |
| 339 | Effect.gen(function* () { | 587 | Effect.gen(function* () { |
| 340 | const executor = yield* createExecutor( | 588 | const executor = yield* createExecutor( |
| @@ -747,6 +995,39 @@ describe("mcpPlugin", () => { · 406 unmodified lines | |||
| 747 | ); | 995 | ); |
| 748 | } | 996 | } |
| 749 | 997 | ||
| 998 | // The lone-401 replay above is restricted to read-only methods. A | ||
| 999 | // `tools/call` may have executed its side effect before the server answered | ||
| 1000 | // 401 (HTTP gives no such guarantee), so the adapter must never re-send it | ||
| 1001 | // — the single 401 classifies reauthorization directly instead. | ||
| 1002 | it.effect("never replays a tools/call 401: the action must not run twice", () => | ||
| 1003 | Effect.scoped( | ||
| 1004 | Effect.gen(function* () { | ||
| 1005 | let callToolRequests = 0; | ||
| 1006 | const { executor, toolAddress } = yield* seedCallToolExecutor({ | ||
| 1007 | slug: "call_replay_401", | ||
| 1008 | // oauth: the transport gets an authProvider, which is the | ||
| 1009 | // staticOAuthBearer path where the adapter's 401 replay lives. | ||
| 1010 | oauth: true, | ||
| 1011 | callTool: () => { | ||
| 1012 | callToolRequests += 1; | ||
| 1013 | return HttpServerResponse.text("do-not-leak: revoked mid-session", { status: 401 }); | ||
| 1014 | }, | ||
| 1015 | }); | ||
| 1016 | |||
| 1017 | const result = yield* executor.execute(toolAddress, {}, { onElicitation: "accept-all" }); | ||
| 1018 | |||
| 1019 | expect(result).toMatchObject({ | ||
| 1020 | ok: false, | ||
| 1021 | error: { | ||
| 1022 | code: "oauth_reauth_required", | ||
| 1023 | details: { category: "authentication" }, | ||
| 1024 | }, | ||
| 1025 | }); | ||
| 1026 | expect(callToolRequests, "a 401 tools/call must reach the server exactly once").toBe(1); | ||
| 1027 | }), | ||
| 1028 | ), | ||
| 1029 | ); | ||
| 1030 | |||
| 750 | it.effect( | 1031 | it.effect( |
| 751 | "classifies a scope-insufficient 403 as oauth_scope_insufficient, not connection_rejected", | 1032 | "classifies a scope-insufficient 403 as oauth_scope_insufficient, not connection_rejected", |
| 752 | () => | 1033 | () => |
| @@ -659,6 +659,7 @@ const buildConnectorInput = ( · 658 unmodified lines | |||
| 659 | queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, | 659 | queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, |
| 660 | headers: Object.keys(headers).length > 0 ? headers : undefined, | 660 | headers: Object.keys(headers).length > 0 ? headers : undefined, |
| 661 | authProvider, | 661 | authProvider, |
| 662 | ...(authProvider === undefined ? {} : { staticOAuthBearer: true }), | ||
| 662 | httpClientLayer, | 663 | httpClientLayer, |
| 663 | }); | 664 | }); |
| 664 | }; | 665 | }; |
| @@ -1343,10 +1344,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 678 unmodified lines | |||
| 1343 | }), | 1344 | }), |
| 1344 | ); | 1345 | ); |
| 1345 | if (Result.isFailure(discovered)) { | 1346 | if (Result.isFailure(discovered)) { |
| 1347 | const reauthorizationRequired = discovered.failure.reauthorizationRequired === true; | ||
| 1346 | return { | 1348 | return { |
| 1347 | tools: [] as readonly ToolDef[], | 1349 | tools: [] as readonly ToolDef[], |
| 1348 | incomplete: true, | 1350 | incomplete: true, |
| 1349 | incompleteReason: discovered.failure.message, | 1351 | incompleteReason: discovered.failure.message, |
| 1352 | ...(reauthorizationRequired | ||
| 1353 | ? { | ||
| 1354 | health: { | ||
| 1355 | status: "expired" as const, | ||
| 1356 | checkedAt: Date.now(), | ||
| 1357 | detail: "MCP OAuth reauthorization required", | ||
| 1358 | }, | ||
| 1359 | } | ||
| 1360 | : {}), | ||
| 1350 | }; | 1361 | }; |
| 1351 | } | 1362 | } |
| 1352 | return { tools: discovered.success.tools.map(toToolDef) }; | 1363 | return { tools: discovered.success.tools.map(toToolDef) }; |
| @@ -1355,7 +1366,12 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 2 unmodified lines | |||
| 1355 | attributes: { "mcp.connection.name": String(connection.name) }, | 1366 | attributes: { "mcp.connection.name": String(connection.name) }, |
| 1356 | }), | 1367 | }), |
| 1357 | ) as Effect.Effect< | 1368 | ) as Effect.Effect< |
| 1358 | { readonly tools: readonly ToolDef[]; readonly incomplete?: boolean }, | 1369 | { |
| 1370 | readonly tools: readonly ToolDef[]; | ||
| 1371 | readonly incomplete?: boolean; | ||
| 1372 | readonly incompleteReason?: string; | ||
| 1373 | readonly health?: HealthCheckResult; | ||
| 1374 | }, | ||
| 1359 | StorageFailure | 1375 | StorageFailure |
| 1360 | >, | 1376 | >, |
| 1361 | 1377 | ||
| @@ -19,6 +19,12 @@ export type McpTestServer = { · 18 unmodified lines | |||
| 19 | readonly forgetSessions: Effect.Effect<void>; | 19 | readonly forgetSessions: Effect.Effect<void>; |
| 20 | /** Rejects the next request carrying an MCP session id with this status. */ | 20 | /** Rejects the next request carrying an MCP session id with this status. */ |
| 21 | readonly rejectNextSessionRequest: (status: number) => Effect.Effect<void>; | 21 | readonly rejectNextSessionRequest: (status: number) => Effect.Effect<void>; |
| 22 | /** From now on, rejects every session-carrying POST whose JSON-RPC body | ||
| 23 | * names this method with the given status (401 answers with the same | ||
| 24 | * WWW-Authenticate shape as the auth gate). Models a bearer revoked right | ||
| 25 | * after the handshake: `initialize` succeeds, the named request meets the | ||
| 26 | * auth wall. */ | ||
| 27 | readonly rejectSessionMethod: (method: string, status: number) => Effect.Effect<void>; | ||
| 22 | }; | 28 | }; |
| 23 | 29 | ||
| 24 | export type McpTestRequest = { | 30 | export type McpTestRequest = { |
| @@ -92,6 +98,29 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 67 unmodified lines | |||
| 92 | const path = options.path ?? "/"; | 98 | const path = options.path ?? "/"; |
| 93 | let sessions = 0; | 99 | let sessions = 0; |
| 94 | let nextSessionRequestStatus: number | undefined; | 100 | let nextSessionRequestStatus: number | undefined; |
| 101 | let sessionMethodRejection: { readonly method: string; readonly status: number } | undefined; | ||
| 102 | |||
| 103 | const writeUnauthorized = (response: http.ServerResponse, origin: string) => | ||
| 104 | writeJson( | ||
| 105 | response, | ||
| 106 | 401, | ||
| 107 | { error: "invalid_token" }, | ||
| 108 | { | ||
| 109 | "www-authenticate": | ||
| 110 | options.auth?.wwwAuthenticate ?? | ||
| 111 | `Bearer resource_metadata="${origin}${protectedResourcePath}${path}", error="invalid_token"`, | ||
| 112 | }, | ||
| 113 | ); | ||
| 114 | |||
| 115 | const namesJsonRpcMethod = (parsedBody: unknown, method: string): boolean => { | ||
| 116 | const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody]; | ||
| 117 | return messages.some( | ||
| 118 | (message) => | ||
| 119 | typeof message === "object" && | ||
| 120 | message !== null && | ||
| 121 | (message as { readonly method?: unknown }).method === method, | ||
| 122 | ); | ||
| 123 | }; | ||
| 95 | 124 | ||
| 96 | const handleMcpRequest = ( | 125 | const handleMcpRequest = ( |
| 97 | request: http.IncomingMessage, | 126 | request: http.IncomingMessage, |
| @@ -141,16 +170,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 43 unmodified lines | |||
| 141 | if (options.auth) { | 170 | if (options.auth) { |
| 142 | const accepted = yield* options.auth.validateAuthorization(authorization); | 171 | const accepted = yield* options.auth.validateAuthorization(authorization); |
| 143 | if (!accepted) { | 172 | if (!accepted) { |
| 144 | writeJson( | 173 | writeUnauthorized(response, origin); |
| 145 | response, | ||
| 146 | 401, | ||
| 147 | { error: "invalid_token" }, | ||
| 148 | { | ||
| 149 | "www-authenticate": | ||
| 150 | options.auth.wwwAuthenticate ?? | ||
| 151 | `Bearer resource_metadata="${origin}${protectedResourcePath}${path}", error="invalid_token"`, | ||
| 152 | }, | ||
| 153 | ); | ||
| 154 | return; | 174 | return; |
| 155 | } | 175 | } |
| 156 | } | 176 | } |
| @@ -169,6 +189,24 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 12 unmodified lines | |||
| 169 | } | 189 | } |
| 170 | 190 | ||
| 171 | if (existingTransport) { | 191 | if (existingTransport) { |
| 192 | const rejection = sessionMethodRejection; | ||
| 193 | if (rejection !== undefined && request.method === "POST") { | ||
| 194 | const body = yield* readRequestBody(request); | ||
| 195 | const parsedBody = Option.getOrUndefined(decodeJsonBody(body)); | ||
| 196 | if (namesJsonRpcMethod(parsedBody, rejection.method)) { | ||
| 197 | if (rejection.status === 401 && options.auth) { | ||
| 198 | writeUnauthorized(response, origin); | ||
| 199 | } else { | ||
| 200 | writeText(response, rejection.status, `Forced HTTP ${rejection.status}`); | ||
| 201 | } | ||
| 202 | return; | ||
| 203 | } | ||
| 204 | yield* Effect.tryPromise({ | ||
| 205 | try: () => existingTransport.handleRequest(request, response, parsedBody), | ||
| 206 | catch: (cause) => new McpTestServerError({ cause }), | ||
| 207 | }); | ||
| 208 | return; | ||
| 209 | } | ||
| 172 | yield* Effect.tryPromise({ | 210 | yield* Effect.tryPromise({ |
| 173 | try: () => existingTransport.handleRequest(request, response), | 211 | try: () => existingTransport.handleRequest(request, response), |
| 174 | catch: (cause) => new McpTestServerError({ cause }), | 212 | catch: (cause) => new McpTestServerError({ cause }), |
| @@ -268,6 +306,10 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 93 unmodified lines | |||
| 268 | Effect.sync(() => { | 306 | Effect.sync(() => { |
| 269 | nextSessionRequestStatus = status; | 307 | nextSessionRequestStatus = status; |
| 270 | }), | 308 | }), |
| 309 | rejectSessionMethod: (method: string, status: number) => | ||
| 310 | Effect.sync(() => { | ||
| 311 | sessionMethodRejection = { method, status }; | ||
| 312 | }), | ||
| 271 | close: Effect.gen(function* () { | 313 | close: Effect.gen(function* () { |
| 272 | for (const transport of allTransports) { | 314 | for (const transport of allTransports) { |
| 273 | yield* Effect.tryPromise({ | 315 | yield* Effect.tryPromise({ |