* 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 | 29 | import { createExecutor } from "./executor"; |
| 30 | 30 | import { StorageError, type FumaDb } from "./fuma-runtime"; |
| 31 | 31 | import { HealthCheckResult } from "./health-check"; |
| 32 | −import { definePlugin } from "./plugin"; | |
| 32 | +import { definePlugin, type ResolveToolsResult } from "./plugin"; | |
| 33 | 33 | import type { CredentialProvider } from "./provider"; |
| 34 | 34 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 35 | 35 | import { ToolResult } from "./tool-result"; |
| @@ -2327,6 +2327,10 @@ const makeHealthHarness = (options?: { · 2291 unmodified lines | ||
| 2327 | 2327 | // Cleared before it runs, so the conflicting write it performs (through |
| 2328 | 2328 | // the unwrapped `config.db`) is not intercepted again. |
| 2329 | 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 | 2335 | // Wraps the executor's FumaDb handle so `beforeHealthPersist` can commit a |
| 2332 | 2336 | // conflicting write in the exact window the write guards must close. |
| @@ -2379,7 +2383,11 @@ const makeHealthHarness = (options?: { · 46 unmodified lines | ||
| 2379 | 2383 | credentialProviders: [countingProvider], |
| 2380 | 2384 | storage: () => ({}), |
| 2381 | 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 | 2391 | invokeTool: ({ toolRow, credential, args }) => |
| 2384 | 2392 | Effect.as( |
| 2385 | 2393 | hooks.onInvoke, |
| @@ -2977,6 +2985,41 @@ describe("verdict write guards close the check-to-write window", () => { · 591 unmodified lines | ||
| 2977 | 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 | 3025 | describe("credential-only health path", () => { |
| @@ -125,11 +125,20 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines | ||
| 125 | 125 | const diagnosticsPlugin = definePlugin(() => ({ |
| 126 | 126 | id: "diagnostics" as const, |
| 127 | 127 | storage: () => ({}), |
| 128 | − resolveTools: () => | |
| 128 | + resolveTools: ({ connection }) => | |
| 129 | 129 | Effect.succeed({ |
| 130 | 130 | tools: [], |
| 131 | 131 | incomplete: true, |
| 132 | 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 | 143 | extension: (ctx) => ({ |
| 135 | 144 | seed: () => |
| @@ -138,6 +147,12 @@ const diagnosticsPlugin = definePlugin(() => ({ · 2 unmodified lines | ||
| 138 | 147 | description: "Diagnostics", |
| 139 | 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 | 526 | it.effect("hands pasted credential entry to the web UI", () => |
| 474 | 527 | Effect.gen(function* () { |
| 475 | 528 | const executor = yield* makeTestExecutor({ |
| @@ -3309,22 +3309,44 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3308 unmodified lines | ||
| 3309 | 3309 | // clears it, and reconnect re-syncs tools anyway). Read fresh, since |
| 3310 | 3310 | // the recorder wrote AFTER this sync's row was loaded. Keep stamping |
| 3311 | 3311 | // the sync time so the stale-catalog check does not re-attempt this |
| 3312 | − // connection on every read. | |
| 3313 | − const stampSyncedWithHealth = (reason: string) => | |
| 3312 | + // connection on every read. A plugin-supplied actionable `health` | |
| 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 | 3326 | findConnectionRow(ref).pipe( |
| 3315 | 3327 | Effect.flatMap((fresh) => |
| 3316 | − core.updateMany("connection", { | |
| 3317 | − where: connectionWhere, | |
| 3318 | − set: | |
| 3319 | − fresh !== null && | |
| 3320 | − oauthReauthRequiredFromProviderState(fresh.provider_state) !== null | |
| 3321 | − ? { tools_synced_at: Date.now() } | |
| 3322 | − : { | |
| 3323 | − tools_synced_at: Date.now(), | |
| 3324 | − last_health: toolSyncHealth(reason), | |
| 3325 | − updated_at: new Date(), | |
| 3326 | − }, | |
| 3327 | − }), | |
| 3328 | + fresh === null | |
| 3329 | + ? Effect.void | |
| 3330 | + : core | |
| 3331 | + .updateMany("connection", { | |
| 3332 | + where: (b: AnyCb) => | |
| 3333 | + b.and( | |
| 3334 | + connectionWhere(b), | |
| 3335 | + b("updated_at", "=", fresh.updated_at), | |
| 3336 | + fresh.tools_synced_at == null | |
| 3337 | + ? b.isNull("tools_synced_at") | |
| 3338 | + : b("tools_synced_at", "=", fresh.tools_synced_at), | |
| 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 | 3412 | // server isn't re-dialed on every read; the freshness TTL re-attempts |
| 3391 | 3413 | // later. |
| 3392 | 3414 | const reason = syncHealthReason(result); |
| 3393 | − yield* stampSyncedWithHealth(reason); | |
| 3415 | + yield* stampSyncedWithHealth(reason, result.health); | |
| 3394 | 3416 | yield* Effect.logWarning("executor tool sync preserved catalog", { |
| 3395 | 3417 | reason, |
| 3396 | 3418 | integration: String(ref.integration), |
| @@ -367,6 +367,10 @@ export interface ResolveToolsResult { · 366 unmodified lines | ||
| 367 | 367 | /** Human-readable reason for an incomplete listing. Persisted by core when it |
| 368 | 368 | * preserves the prior catalog so operators can see why data is stale. */ |
| 369 | 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 | 376 | export interface ProjectToolSchemaInput<TStore = unknown> { |
| @@ -49,6 +49,10 @@ export type RemoteConnectorInput = Omit< · 48 unmodified lines | ||
| 49 | 49 | readonly headers?: Record<string, string>; |
| 50 | 50 | readonly queryParams?: Record<string, string>; |
| 51 | 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 | 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 | 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 | 176 | const externalTransportCodes = (cause: unknown): ReadonlySet<string> => { |
| 157 | 177 | const codes = new Set<string>(); |
| 158 | 178 | let current: unknown = cause; |
| @@ -228,8 +248,53 @@ const awaitAbort = (signal: AbortSignal): Effect.Effect<void> => · 69 unmodified lines | ||
| 228 | 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 | 295 | const fetchFromHttpClientLayer = ( |
| 232 | 296 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 297 | + staticOAuthBearer: boolean, | |
| 233 | 298 | ): FetchLike => { |
| 234 | 299 | const execute: FetchLike = async (url, init) => { |
| 235 | 300 | const headers = headersFrom(init?.headers); |
| @@ -262,6 +327,10 @@ const fetchFromHttpClientLayer = ( · 26 unmodified lines | ||
| 262 | 327 | headers: responseHeaders, |
| 263 | 328 | }); |
| 264 | 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 | 334 | // A 403 carrying an RFC 6750 insufficient_scope challenge is intercepted |
| 266 | 335 | // HERE, below the SDK: with an authProvider the SDK would consume the |
| 267 | 336 | // challenge and re-run auth ("upscoping"), which our static-token |
| @@ -270,9 +339,39 @@ const fetchFromHttpClientLayer = ( · 2 unmodified lines | ||
| 270 | 339 | // tagged error from the fetch adapter (a true runtime edge: the SDK |
| 271 | 340 | // consumes promise rejections) so it reaches the invoke/connect catch |
| 272 | 341 | // sites verbatim. |
| 273 | − const promise = Effect.runPromise(effect).then((response) => { | |
| 274 | − if (response.status === 403) { | |
| 275 | − const challenge = response.headers.get("www-authenticate"); | |
| 342 | + const promise = Effect.runPromise(effect).then(async (response) => { | |
| 343 | + let settled = response; | |
| 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 | 375 | if ( |
| 277 | 376 | challenge !== null && |
| 278 | 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 | 388 | // Mark the request promise observed (a no-op handler on the ORIGINAL |
| 290 | 389 | // promise; callers still see the rejection). The MCP SDK fires some |
| @@ -335,7 +434,7 @@ const connectionFailure = ( · 44 unmodified lines | ||
| 335 | 434 | message: string, |
| 336 | 435 | cause: unknown, |
| 337 | 436 | ): McpConnectionError | McpOAuthReauthorizationRequired => { |
| 338 | − if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) { | |
| 437 | + if (hasNestedOAuthReauthorization(cause)) { | |
| 339 | 438 | return new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required" }); |
| 340 | 439 | } |
| 341 | 440 | if (Predicate.isTagged(cause, "McpInsufficientScopeError")) { |
| @@ -495,7 +594,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 153 unmodified lines | ||
| 495 | 594 | const headers = input.headers ?? {}; |
| 496 | 595 | const remoteTransport = input.remoteTransport ?? "auto"; |
| 497 | 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 | 601 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); |
| 501 | 602 | |
| @@ -4,8 +4,9 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 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 | 8 | import { McpToolDiscoveryError } from "./errors"; |
| 9 | +import { httpStatusFromCause } from "./http-status"; | |
| 9 | 10 | import { |
| 10 | 11 | decodeListToolsPage, |
| 11 | 12 | extractManifestFromListToolsResult, |
| @@ -49,11 +50,34 @@ const listAllTools = ( · 37 unmodified lines | ||
| 49 | 50 | const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor }; |
| 50 | 51 | const listResult = yield* Effect.tryPromise({ |
| 51 | 52 | try: () => connection.client.listTools(params), |
| 52 | − catch: () => | |
| 53 | − new McpToolDiscoveryError({ | |
| 53 | + // A bearer accepted at the handshake can be rejected by the time the | |
| 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 | 73 | stage: "list_tools", |
| 55 | − message: "Failed listing MCP tools", | |
| 56 | − }), | |
| 74 | + message: | |
| 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 | 83 | const decoded = decodeListToolsPage(listResult); |
| @@ -116,6 +140,10 @@ export const discoverTools = ( · 56 unmodified lines | ||
| 116 | 140 | const httpStatus = Predicate.isTagged(failure, "McpConnectionError") |
| 117 | 141 | ? failure.httpStatus |
| 118 | 142 | : undefined; |
| 143 | + const reauthorizationRequired = Predicate.isTagged( | |
| 144 | + failure, | |
| 145 | + "McpOAuthReauthorizationRequired", | |
| 146 | + ); | |
| 119 | 147 | const timedOut = |
| 120 | 148 | Predicate.isTagged(failure, "McpConnectionError") && |
| 121 | 149 | failure.failureKind === "timeout"; |
| @@ -123,6 +151,7 @@ export const discoverTools = ( · 1 unmodified line | ||
| 123 | 151 | stage: "connect", |
| 124 | 152 | message: `Failed connecting to MCP server: ${failure.message}`, |
| 125 | 153 | ...(httpStatus !== undefined ? { httpStatus } : {}), |
| 154 | + ...(reauthorizationRequired ? { reauthorizationRequired: true } : {}), | |
| 126 | 155 | ...(timedOut ? { timedOut } : {}), |
| 127 | 156 | }); |
| 128 | 157 | }), |
| @@ -40,8 +40,13 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 39 unmodified lines | ||
| 40 | 40 | { |
| 41 | 41 | stage: Schema.Literals(["connect", "list_tools"]), |
| 42 | 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 | 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 | 50 | /** Discovery hit its deadline (`discoverTools` timeout). Structural, so |
| 46 | 51 | * the health check can report `probe_timeout` — a slow-but-alive server — |
| 47 | 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 | 232 | // `tools/call` responders. Both embed a "do-not-leak" sentinel the assertions |
| 107 | 233 | // confirm never reaches the caller-facing failure. |
| 108 | 234 | const httpStatusCallTool = |
| @@ -335,6 +461,128 @@ describe("joinToolPath", () => { · 226 unmodified lines | ||
| 335 | 461 | // --------------------------------------------------------------------------- |
| 336 | 462 | |
| 337 | 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 | 586 | it.effect("creates executor with mcp plugin", () => |
| 339 | 587 | Effect.gen(function* () { |
| 340 | 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 | 1031 | it.effect( |
| 751 | 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 | 659 | queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, |
| 660 | 660 | headers: Object.keys(headers).length > 0 ? headers : undefined, |
| 661 | 661 | authProvider, |
| 662 | + ...(authProvider === undefined ? {} : { staticOAuthBearer: true }), | |
| 662 | 663 | httpClientLayer, |
| 663 | 664 | }); |
| 664 | 665 | }; |
| @@ -1343,10 +1344,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 678 unmodified lines | ||
| 1343 | 1344 | }), |
| 1344 | 1345 | ); |
| 1345 | 1346 | if (Result.isFailure(discovered)) { |
| 1347 | + const reauthorizationRequired = discovered.failure.reauthorizationRequired === true; | |
| 1346 | 1348 | return { |
| 1347 | 1349 | tools: [] as readonly ToolDef[], |
| 1348 | 1350 | incomplete: true, |
| 1349 | 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 | 1363 | return { tools: discovered.success.tools.map(toToolDef) }; |
| @@ -1355,7 +1366,12 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 2 unmodified lines | ||
| 1355 | 1366 | attributes: { "mcp.connection.name": String(connection.name) }, |
| 1356 | 1367 | }), |
| 1357 | 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 | 1375 | StorageFailure |
| 1360 | 1376 | >, |
| 1361 | 1377 | |
| @@ -19,6 +19,12 @@ export type McpTestServer = { · 18 unmodified lines | ||
| 19 | 19 | readonly forgetSessions: Effect.Effect<void>; |
| 20 | 20 | /** Rejects the next request carrying an MCP session id with this status. */ |
| 21 | 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 | 30 | export type McpTestRequest = { |
| @@ -92,6 +98,29 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 67 unmodified lines | ||
| 92 | 98 | const path = options.path ?? "/"; |
| 93 | 99 | let sessions = 0; |
| 94 | 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 | 125 | const handleMcpRequest = ( |
| 97 | 126 | request: http.IncomingMessage, |
| @@ -141,16 +170,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 43 unmodified lines | ||
| 141 | 170 | if (options.auth) { |
| 142 | 171 | const accepted = yield* options.auth.validateAuthorization(authorization); |
| 143 | 172 | if (!accepted) { |
| 144 | − writeJson( | |
| 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 | − ); | |
| 173 | + writeUnauthorized(response, origin); | |
| 154 | 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 | 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 | 210 | yield* Effect.tryPromise({ |
| 173 | 211 | try: () => existingTransport.handleRequest(request, response), |
| 174 | 212 | catch: (cause) => new McpTestServerError({ cause }), |
| @@ -268,6 +306,10 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 93 unmodified lines | ||
| 268 | 306 | Effect.sync(() => { |
| 269 | 307 | nextSessionRequestStatus = status; |
| 270 | 308 | }), |
| 309 | + rejectSessionMethod: (method: string, status: number) => | |
| 310 | + Effect.sync(() => { | |
| 311 | + sessionMethodRejection = { method, status }; | |
| 312 | + }), | |
| 271 | 313 | close: Effect.gen(function* () { |
| 272 | 314 | for (const transport of allTransports) { |
| 273 | 315 | yield* Effect.tryPromise({ |