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+
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.
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.
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+);
packages/core/sdk/src/connections.test.ts+45−2
@@ -29,7 +29,7 @@ import { ConnectionAlreadyExistsError } from "./errors"; · 28 unmodified lines
2929 import { createExecutor } from "./executor";
3030 import { StorageError, type FumaDb } from "./fuma-runtime";
3131 import { HealthCheckResult } from "./health-check";
32−import { definePlugin } from "./plugin";
32+import { definePlugin, type ResolveToolsResult } from "./plugin";
3333 import type { CredentialProvider } from "./provider";
3434 import { makeTestConfig, makeTestExecutor } from "./testing";
3535 import { ToolResult } from "./tool-result";
@@ -2327,6 +2327,10 @@ const makeHealthHarness = (options?: { · 2291 unmodified lines
23272327 // Cleared before it runs, so the conflicting write it performs (through
23282328 // the unwrapped `config.db`) is not intercepted again.
23292329 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,
23302334 };
23312335 // Wraps the executor's FumaDb handle so `beforeHealthPersist` can commit a
23322336 // conflicting write in the exact window the write guards must close.
@@ -2379,7 +2383,11 @@ const makeHealthHarness = (options?: { · 46 unmodified lines
23792383 credentialProviders: [countingProvider],
23802384 storage: () => ({}),
23812385 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+ ),
23832391 invokeTool: ({ toolRow, credential, args }) =>
23842392 Effect.as(
23852393 hooks.onInvoke,
@@ -2977,6 +2985,41 @@ describe("verdict write guards close the check-to-write window", () => { · 591 unmodified lines
29772985 expect(row?.lastHealth).toMatchObject({ status: "degraded", detail: SYNC_DETAIL });
29782986 }),
29792987 );
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+ );
29803023 });
29813024
29823025 describe("credential-only health path", () => {
packages/core/sdk/src/executor.test.ts+54−1
@@ -125,11 +125,20 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines
125125 const diagnosticsPlugin = definePlugin(() => ({
126126 id: "diagnostics" as const,
127127 storage: () => ({}),
128− resolveTools: () =>
128+ resolveTools: ({ connection }) =>
129129 Effect.succeed({
130130 tools: [],
131131 incomplete: true,
132132 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+ : {}),
133142 }),
134143 extension: (ctx) => ({
135144 seed: () =>
@@ -138,6 +147,12 @@ const diagnosticsPlugin = definePlugin(() => ({ · 2 unmodified lines
138147 description: "Diagnostics",
139148 config: {},
140149 }),
150+ seedExpired: () =>
151+ ctx.core.integrations.register({
152+ slug: IntegrationSlug.make("diagnostics_expired"),
153+ description: "Expired diagnostics",
154+ config: {},
155+ }),
141156 }),
142157 }))();
143158
@@ -470,6 +485,44 @@ describe("createExecutor", () => { · 326 unmodified lines
470485 }),
471486 );
472487
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+
473526 it.effect("hands pasted credential entry to the web UI", () =>
474527 Effect.gen(function* () {
475528 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
33093309 // clears it, and reconnect re-syncs tools anyway). Read fresh, since
33103310 // the recorder wrote AFTER this sync's row was loaded. Keep stamping
33113311 // 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) =>
33143326 findConnectionRow(ref).pipe(
33153327 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),
33283350 ),
33293351 );
33303352
@@ -3390,7 +3412,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 59 unmodified lines
33903412 // server isn't re-dialed on every read; the freshness TTL re-attempts
33913413 // later.
33923414 const reason = syncHealthReason(result);
3393− yield* stampSyncedWithHealth(reason);
3415+ yield* stampSyncedWithHealth(reason, result.health);
33943416 yield* Effect.logWarning("executor tool sync preserved catalog", {
33953417 reason,
33963418 integration: String(ref.integration),
packages/core/sdk/src/plugin.ts+4−0
@@ -367,6 +367,10 @@ export interface ResolveToolsResult { · 366 unmodified lines
367367 /** Human-readable reason for an incomplete listing. Persisted by core when it
368368 * preserves the prior catalog so operators can see why data is stale. */
369369 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;
370374 }
371375
372376 export interface ProjectToolSchemaInput<TStore = unknown> {
packages/plugins/mcp/src/sdk/connection.ts+107−6
@@ -49,6 +49,10 @@ export type RemoteConnectorInput = Omit< · 48 unmodified lines
4949 readonly headers?: Record<string, string>;
5050 readonly queryParams?: Record<string, string>;
5151 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;
5256 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
5357 };
5458
@@ -153,6 +157,22 @@ const nestedMcpHttpTransportError = (cause: unknown): Option.Option<McpHttpTrans · 98 unmodified lines
153157 return Option.none();
154158 };
155159
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+
156176 const externalTransportCodes = (cause: unknown): ReadonlySet<string> => {
157177 const codes = new Set<string>();
158178 let current: unknown = cause;
@@ -228,8 +248,53 @@ const awaitAbort = (signal: AbortSignal): Effect.Effect<void> => · 69 unmodified lines
228248 signal.addEventListener("abort", () => resume(Effect.void), { once: true });
229249 });
230250
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+
231295 const fetchFromHttpClientLayer = (
232296 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
297+ staticOAuthBearer: boolean,
233298 ): FetchLike => {
234299 const execute: FetchLike = async (url, init) => {
235300 const headers = headersFrom(init?.headers);
@@ -262,6 +327,10 @@ const fetchFromHttpClientLayer = ( · 26 unmodified lines
262327 headers: responseHeaders,
263328 });
264329 }).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.
265334 // A 403 carrying an RFC 6750 insufficient_scope challenge is intercepted
266335 // HERE, below the SDK: with an authProvider the SDK would consume the
267336 // challenge and re-run auth ("upscoping"), which our static-token
@@ -270,9 +339,39 @@ const fetchFromHttpClientLayer = ( · 2 unmodified lines
270339 // tagged error from the fetch adapter (a true runtime edge: the SDK
271340 // consumes promise rejections) so it reaches the invoke/connect catch
272341 // 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");
276375 if (
277376 challenge !== null &&
278377 detectInsufficientScope({ headers: { "www-authenticate": challenge } }) !== null
@@ -284,7 +383,7 @@ const fetchFromHttpClientLayer = ( · 5 unmodified lines
284383 });
285384 }
286385 }
287− return response;
386+ return settled;
288387 });
289388 // Mark the request promise observed (a no-op handler on the ORIGINAL
290389 // promise; callers still see the rejection). The MCP SDK fires some
@@ -335,7 +434,7 @@ const connectionFailure = ( · 44 unmodified lines
335434 message: string,
336435 cause: unknown,
337436 ): McpConnectionError | McpOAuthReauthorizationRequired => {
338− if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) {
437+ if (hasNestedOAuthReauthorization(cause)) {
339438 return new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required" });
340439 }
341440 if (Predicate.isTagged(cause, "McpInsufficientScopeError")) {
@@ -495,7 +594,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 153 unmodified lines
495594 const headers = input.headers ?? {};
496595 const remoteTransport = input.remoteTransport ?? "auto";
497596 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;
499600
500601 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
501602
packages/plugins/mcp/src/sdk/discover.ts+34−5
@@ -4,8 +4,9 @@ · 3 unmodified lines
44
55 import { Duration, Effect, Option, Predicate } from "effect";
66
7−import type { McpConnection, McpConnector } from "./connection";
7+import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";
88 import { McpToolDiscoveryError } from "./errors";
9+import { httpStatusFromCause } from "./http-status";
910 import {
1011 decodeListToolsPage,
1112 extractManifestFromListToolsResult,
@@ -49,11 +50,34 @@ const listAllTools = ( · 37 unmodified lines
4950 const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor };
5051 const listResult = yield* Effect.tryPromise({
5152 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({
5473 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+ },
5781 });
5882
5983 const decoded = decodeListToolsPage(listResult);
@@ -116,6 +140,10 @@ export const discoverTools = ( · 56 unmodified lines
116140 const httpStatus = Predicate.isTagged(failure, "McpConnectionError")
117141 ? failure.httpStatus
118142 : undefined;
143+ const reauthorizationRequired = Predicate.isTagged(
144+ failure,
145+ "McpOAuthReauthorizationRequired",
146+ );
119147 const timedOut =
120148 Predicate.isTagged(failure, "McpConnectionError") &&
121149 failure.failureKind === "timeout";
@@ -123,6 +151,7 @@ export const discoverTools = ( · 1 unmodified line
123151 stage: "connect",
124152 message: `Failed connecting to MCP server: ${failure.message}`,
125153 ...(httpStatus !== undefined ? { httpStatus } : {}),
154+ ...(reauthorizationRequired ? { reauthorizationRequired: true } : {}),
126155 ...(timedOut ? { timedOut } : {}),
127156 });
128157 }),
packages/plugins/mcp/src/sdk/errors.ts+6−1
@@ -40,8 +40,13 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 39 unmodified lines
4040 {
4141 stage: Schema.Literals(["connect", "list_tools"]),
4242 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. */
4445 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),
4550 /** Discovery hit its deadline (`discoverTools` timeout). Structural, so
4651 * the health check can report `probe_timeout` — a slow-but-alive server —
4752 * 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
103103 }),
104104 );
105105
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+
106232 // `tools/call` responders. Both embed a "do-not-leak" sentinel the assertions
107233 // confirm never reaches the caller-facing failure.
108234 const httpStatusCallTool =
@@ -335,6 +461,128 @@ describe("joinToolPath", () => { · 226 unmodified lines
335461 // ---------------------------------------------------------------------------
336462
337463 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+
338586 it.effect("creates executor with mcp plugin", () =>
339587 Effect.gen(function* () {
340588 const executor = yield* createExecutor(
@@ -747,6 +995,39 @@ describe("mcpPlugin", () => { · 406 unmodified lines
747995 );
748996 }
749997
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+
7501031 it.effect(
7511032 "classifies a scope-insufficient 403 as oauth_scope_insufficient, not connection_rejected",
7521033 () =>
packages/plugins/mcp/src/sdk/plugin.ts+17−1
@@ -659,6 +659,7 @@ const buildConnectorInput = ( · 658 unmodified lines
659659 queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
660660 headers: Object.keys(headers).length > 0 ? headers : undefined,
661661 authProvider,
662+ ...(authProvider === undefined ? {} : { staticOAuthBearer: true }),
662663 httpClientLayer,
663664 });
664665 };
@@ -1343,10 +1344,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 678 unmodified lines
13431344 }),
13441345 );
13451346 if (Result.isFailure(discovered)) {
1347+ const reauthorizationRequired = discovered.failure.reauthorizationRequired === true;
13461348 return {
13471349 tools: [] as readonly ToolDef[],
13481350 incomplete: true,
13491351 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+ : {}),
13501361 };
13511362 }
13521363 return { tools: discovered.success.tools.map(toToolDef) };
@@ -1355,7 +1366,12 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 2 unmodified lines
13551366 attributes: { "mcp.connection.name": String(connection.name) },
13561367 }),
13571368 ) 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+ },
13591375 StorageFailure
13601376 >,
13611377
packages/plugins/mcp/src/testing/server.ts+52−10
@@ -19,6 +19,12 @@ export type McpTestServer = { · 18 unmodified lines
1919 readonly forgetSessions: Effect.Effect<void>;
2020 /** Rejects the next request carrying an MCP session id with this status. */
2121 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>;
2228 };
2329
2430 export type McpTestRequest = {
@@ -92,6 +98,29 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 67 unmodified lines
9298 const path = options.path ?? "/";
9399 let sessions = 0;
94100 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+ };
95124
96125 const handleMcpRequest = (
97126 request: http.IncomingMessage,
@@ -141,16 +170,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 43 unmodified lines
141170 if (options.auth) {
142171 const accepted = yield* options.auth.validateAuthorization(authorization);
143172 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);
154174 return;
155175 }
156176 }
@@ -169,6 +189,24 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 12 unmodified lines
169189 }
170190
171191 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+ }
172210 yield* Effect.tryPromise({
173211 try: () => existingTransport.handleRequest(request, response),
174212 catch: (cause) => new McpTestServerError({ cause }),
@@ -268,6 +306,10 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 93 unmodified lines
268306 Effect.sync(() => {
269307 nextSessionRequestStatus = status;
270308 }),
309+ rejectSessionMethod: (method: string, status: number) =>
310+ Effect.sync(() => {
311+ sessionMethodRejection = { method, status };
312+ }),
271313 close: Effect.gen(function* () {
272314 for (const transport of allTransports) {
273315 yield* Effect.tryPromise({