History

Surface MCP OAuth reauthorization during catalog discovery (#1818)

* 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 ---------

Ramiro committed 2 months ago

Commit 06bf7421 parent 22d06aa12 files · +1010 −41Browse files

Unified · Split

12 files
  1. .changeset/mcp-oauth-refresh-reauthorization.md +12 −0
  2. e2e/selfhost/mcp-oauth-tool-refresh-reauth.test.ts +361 −0
  3. packages/core/sdk/src/connections.test.ts +45 −2
  4. packages/core/sdk/src/executor.test.ts +54 −1
  5. packages/core/sdk/src/executor.ts +37 −15
  6. packages/core/sdk/src/plugin.ts +4 −0
  7. packages/plugins/mcp/src/sdk/connection.ts +107 −6
  8. packages/plugins/mcp/src/sdk/discover.ts +34 −5
  9. packages/plugins/mcp/src/sdk/errors.ts +6 −1
  10. packages/plugins/mcp/src/sdk/plugin.test.ts +281 −0
  11. packages/plugins/mcp/src/sdk/plugin.ts +17 −1
  12. packages/plugins/mcp/src/testing/server.ts +52 −10
.changeset/mcp-oauth-refresh-reauthorization.md+12−0
@@ -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
8Remote 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
10Executor 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
12API-key and unauthenticated MCP transports keep their existing `401` behavior, and ordinary incomplete discovery results remain degraded.
e2e/selfhost/mcp-oauth-tool-refresh-reauth.test.ts+361−0
@@ -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.
19import { randomBytes } from "node:crypto";
20
21import { Effect } from "effect";
22import { expect } from "@effect/vitest";
23import type { HttpApiClient } from "effect/unstable/httpapi";
24import { composePluginApi } from "@executor-js/api/server";
25import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
26import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
27import {
28 AuthTemplateSlug,
29 ConnectionName,
30 IntegrationSlug,
31 OAuthClientSlug,
32} from "@executor-js/sdk/shared";
33import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing";
34
35import { scenario } from "../src/scenario";
36import { Api, Target } from "../src/services";
37
38const api = composePluginApi([mcpHttpPlugin()] as const);
39type Client = HttpApiClient.ForApi<typeof api>;
40
41const name = ConnectionName.make("main");
42const template = AuthTemplateSlug.make("oauth2");
43
44const 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. */
48const 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
57const 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. */
67const 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
81const 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
92const 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
166const 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
173scenario(
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
241scenario(
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
296scenario(
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);
packages/core/sdk/src/connections.test.ts+45−2
@@ -29,7 +29,7 @@ import { ConnectionAlreadyExistsError } from "./errors"; · 28 unmodified lines
29import { createExecutor } from "./executor";29import { createExecutor } from "./executor";
30import { StorageError, type FumaDb } from "./fuma-runtime";30import { StorageError, type FumaDb } from "./fuma-runtime";
31import { HealthCheckResult } from "./health-check";31import { HealthCheckResult } from "./health-check";
32import { definePlugin } from "./plugin";32import { definePlugin, type ResolveToolsResult } from "./plugin";
33import type { CredentialProvider } from "./provider";33import type { CredentialProvider } from "./provider";
34import { makeTestConfig, makeTestExecutor } from "./testing";34import { makeTestConfig, makeTestExecutor } from "./testing";
35import { ToolResult } from "./tool-result";35import { 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 (through2327 // 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 a2335 // 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
2982describe("credential-only health path", () => {3025describe("credential-only health path", () => {
packages/core/sdk/src/executor.test.ts+54−1
@@ -125,11 +125,20 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines
125const diagnosticsPlugin = definePlugin(() => ({125const 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({
packages/core/sdk/src/executor.ts+37−15
@@ -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, since3309 // clears it, and reconnect re-syncs tools anyway). Read fresh, since
3310 // the recorder wrote AFTER this sync's row was loaded. Keep stamping3310 // 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 this3311 // 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) !== null3332 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-attempts3412 // 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),
packages/core/sdk/src/plugin.ts+4−0
@@ -367,6 +367,10 @@ export interface ResolveToolsResult { · 366 unmodified lines
367 /** Human-readable reason for an incomplete listing. Persisted by core when it367 /** 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
372export interface ProjectToolSchemaInput<TStore = unknown> {376export interface ProjectToolSchemaInput<TStore = unknown> {
packages/plugins/mcp/src/sdk/connection.ts+107−6
@@ -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. */
164export 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
156const externalTransportCodes = (cause: unknown): ReadonlySet<string> => {176const 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. */
259const REPLAYABLE_JSONRPC_METHODS: ReadonlySet<string> = new Set([
260 "initialize",
261 "notifications/initialized",
262 "ping",
263 "tools/list",
264]);
265
266const JsonRpcMethodOnly = Schema.Struct({ method: Schema.String });
267const 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. */
277const 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
231const fetchFromHttpClientLayer = (295const 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 intercepted334 // A 403 carrying an RFC 6750 insufficient_scope challenge is intercepted
266 // HERE, below the SDK: with an authProvider the SDK would consume the335 // HERE, below the SDK: with an authProvider the SDK would consume the
267 // challenge and re-run auth ("upscoping"), which our static-token336 // 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 SDK339 // tagged error from the fetch adapter (a true runtime edge: the SDK
271 // consumes promise rejections) so it reaches the invoke/connect catch340 // 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 } }) !== null377 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 ORIGINAL388 // Mark the request promise observed (a no-op handler on the ORIGINAL
290 // promise; callers still see the rejection). The MCP SDK fires some389 // 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
packages/plugins/mcp/src/sdk/discover.ts+34−5
@@ -4,8 +4,9 @@ · 3 unmodified lines
4 4
5import { Duration, Effect, Option, Predicate } from "effect";5import { Duration, Effect, Option, Predicate } from "effect";
6 6
7import type { McpConnection, McpConnector } from "./connection";7import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";
8import { McpToolDiscoveryError } from "./errors";8import { McpToolDiscoveryError } from "./errors";
9import { httpStatusFromCause } from "./http-status";
9import {10import {
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.httpStatus141 ? 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 }),
packages/plugins/mcp/src/sdk/errors.ts+6−1
@@ -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, so50 /** 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. */
packages/plugins/mcp/src/sdk/plugin.test.ts+281−0
@@ -103,6 +103,132 @@ const serveCallToolServer = (callTool: CallToolResponder) => · 102 unmodified lines
103 }),103 }),
104 );104 );
105 105
106const 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
145const 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`. */
156const 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 assertions232// `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.
108const httpStatusCallTool =234const httpStatusCallTool =
@@ -335,6 +461,128 @@ describe("joinToolPath", () => { · 226 unmodified lines
335// ---------------------------------------------------------------------------461// ---------------------------------------------------------------------------
336 462
337describe("mcpPlugin", () => {463describe("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 () =>
packages/plugins/mcp/src/sdk/plugin.ts+17−1
@@ -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 StorageFailure1375 StorageFailure
1360 >,1376 >,
1361 1377
packages/plugins/mcp/src/testing/server.ts+52−10
@@ -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
24export type McpTestRequest = {30export 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({