Skip to content

Commit 06bf742

Browse files
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 --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 22d06aa commit 06bf742

12 files changed

Lines changed: 1010 additions & 41 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
);

0 commit comments

Comments
 (0)