Skip to content

Commit df62bb3

Browse files
authored
Refresh connection health after OAuth reconnect (#1545)
* e2e: repro stale health verdict after OAuth reconnect * Refresh connection health after OAuth reconnect * Add changeset * Prevent server crash from unobserved MCP fetch rejection; merge health verdicts by freshness * Lint suppression for the adapter catch * Retrigger CI
1 parent 4fae851 commit df62bb3

5 files changed

Lines changed: 307 additions & 39 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**Fix: reconnecting an OAuth connection now refreshes its health status in place — no page reload needed**
6+
7+
Completing a reconnect previously left the stale "Expired" verdict on the connection row (and the integrations-list summary) until a hard refresh. Re-minting now clears the persisted verdict, and the UI re-probes as soon as the refreshed connection arrives.

‎e2e/selfhost/mcp-oauth-reconnect-health.test.ts‎

Lines changed: 219 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
// Selfhost repros for two MCP OAuth bugs seen with a DCR connection whose
2-
// refresh token is rejected by the provider as `invalid_grant`.
1+
// Selfhost repros for MCP OAuth bugs seen with a DCR connection whose
2+
// refresh token is rejected by the provider as `invalid_grant`, including the
3+
// reconnect journey: completing Reconnect must refresh the health verdict on
4+
// the page without a hard reload.
35
import { randomBytes } from "node:crypto";
6+
import { createServer } from "node:http";
47

58
import { Effect } from "effect";
69
import { expect } from "@effect/vitest";
@@ -48,31 +51,136 @@ const requiredRedirect = (response: Response, from: string): string => {
4851
return new URL(location, from).toString();
4952
};
5053

54+
/** The test server's login page is plain text with Basic-auth POST — nothing a
55+
* browser can click. Complete it out of band and hand back the callback URL. */
56+
const submitProviderLogin = async (loginUrl: string): Promise<string> => {
57+
const credentials = Buffer.from("alice:password").toString("base64");
58+
const response = await fetch(loginUrl, {
59+
method: "POST",
60+
redirect: "manual",
61+
headers: { authorization: `Basic ${credentials}` },
62+
});
63+
const location = response.headers.get("location");
64+
if (response.status !== 302 || !location) {
65+
throw new Error(`provider login did not redirect (${response.status})`);
66+
}
67+
return new URL(location, loginUrl).toString();
68+
};
69+
5170
const completeAuthorization = (authorizationUrl: string) =>
5271
Effect.promise(async () => {
5372
const login = await fetch(authorizationUrl, { redirect: "manual" });
5473
const loginUrl = requiredRedirect(login, authorizationUrl);
55-
const credentials = Buffer.from("alice:password").toString("base64");
56-
const callback = await fetch(loginUrl, {
57-
method: "POST",
58-
headers: { authorization: `Basic ${credentials}` },
59-
redirect: "manual",
60-
});
61-
const callbackUrl = requiredRedirect(callback, loginUrl);
74+
const callbackUrl = await submitProviderLogin(loginUrl);
6275
const parsed = new URL(callbackUrl);
6376
const code = parsed.searchParams.get("code");
6477
if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`);
6578
return { code };
6679
});
6780

68-
const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>
81+
/** AS whose refresh grants are dead forever — every token it mints is already
82+
* expired and refresh is rejected as `invalid_grant`. */
83+
const serveDeadGrantOAuthServer = () =>
84+
serveOAuthTestServer({
85+
scopes: ["channels:history", "users:read"],
86+
supportRefresh: false,
87+
tokenExpiresInSeconds: 0,
88+
invalidRefreshTokenDescription: "Grant not found",
89+
});
90+
91+
interface GrantRevocationGate {
92+
/** Token endpoint to register with executor instead of the real one. */
93+
readonly tokenUrl: string;
94+
/** The provider comes back: minted tokens get their real lifetime and
95+
* refresh grants are honored again. */
96+
readonly restore: () => void;
97+
readonly refreshRejections: () => number;
98+
readonly close: () => void;
99+
}
100+
101+
/** Token-endpoint proxy in front of the test AS. While "revoked" it behaves
102+
* like a provider whose grants are dead — authorization_code exchanges still
103+
* succeed but mint already-expired tokens, and refresh grants are rejected
104+
* with `invalid_grant` — and after `restore()` it is a plain passthrough. The
105+
* test server itself cannot flip behavior after construction, and this repro
106+
* needs "expired now, healthy after a fresh reconnect". */
107+
const serveGrantRevocationGate = (upstreamTokenUrl: string) =>
108+
Effect.acquireRelease(
109+
Effect.callback<GrantRevocationGate>((resume) => {
110+
let revoked = true;
111+
let refreshRejections = 0;
112+
const server = createServer((request, response) => {
113+
const chunks: Buffer[] = [];
114+
request.on("data", (chunk: Buffer) => chunks.push(chunk));
115+
request.on("end", () => {
116+
const body = Buffer.concat(chunks).toString("utf8");
117+
if (revoked && new URLSearchParams(body).get("grant_type") === "refresh_token") {
118+
refreshRejections += 1;
119+
response.writeHead(400, { "content-type": "application/json" });
120+
response.end(
121+
JSON.stringify({ error: "invalid_grant", error_description: "Grant not found" }),
122+
);
123+
return;
124+
}
125+
fetch(upstreamTokenUrl, {
126+
method: "POST",
127+
headers: {
128+
"content-type":
129+
request.headers["content-type"] ?? "application/x-www-form-urlencoded",
130+
...(request.headers.authorization
131+
? { authorization: request.headers.authorization }
132+
: {}),
133+
},
134+
body,
135+
})
136+
.then(async (upstream) => {
137+
const text = await upstream.text();
138+
if (revoked && upstream.ok) {
139+
const parsed = JSON.parse(text) as Record<string, unknown>;
140+
parsed["expires_in"] = 0;
141+
response.writeHead(upstream.status, { "content-type": "application/json" });
142+
response.end(JSON.stringify(parsed));
143+
return;
144+
}
145+
response.writeHead(upstream.status, {
146+
"content-type": upstream.headers.get("content-type") ?? "application/json",
147+
});
148+
response.end(text);
149+
})
150+
.catch(() => {
151+
response.writeHead(502, { "content-type": "application/json" });
152+
response.end(JSON.stringify({ error: "bad_gateway" }));
153+
});
154+
});
155+
});
156+
server.listen(0, "127.0.0.1", () => {
157+
const address = server.address();
158+
const port = typeof address === "object" && address !== null ? address.port : 0;
159+
resume(
160+
Effect.succeed({
161+
tokenUrl: `http://127.0.0.1:${port}/token`,
162+
restore: () => {
163+
revoked = false;
164+
},
165+
refreshRejections: () => refreshRejections,
166+
close: () => {
167+
server.close();
168+
server.closeAllConnections();
169+
},
170+
}),
171+
);
172+
});
173+
}),
174+
(gate) => Effect.sync(gate.close),
175+
);
176+
177+
const seedDcrMcpOAuthConnection = (
178+
client: Client,
179+
prefix: string,
180+
oauth: OAuthTestServerShape,
181+
options?: { readonly tokenUrl?: string },
182+
) =>
69183
Effect.gen(function* () {
70-
const oauth = yield* serveOAuthTestServer({
71-
scopes: ["channels:history", "users:read"],
72-
supportRefresh: false,
73-
tokenExpiresInSeconds: 0,
74-
invalidRefreshTokenDescription: "Grant not found",
75-
});
76184
const slug = IntegrationSlug.make(freshSlug(prefix));
77185
const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`));
78186

@@ -101,7 +209,7 @@ const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>
101209
issuer: probe.issuer ?? null,
102210
registrationEndpoint: probe.registrationEndpoint,
103211
authorizationUrl: probe.authorizationUrl,
104-
tokenUrl: probe.tokenUrl,
212+
tokenUrl: options?.tokenUrl ?? probe.tokenUrl,
105213
resource: probe.resource ?? oauth.mcpResourceUrl,
106214
scopes: probe.scopesSupported ?? [],
107215
tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
@@ -140,6 +248,12 @@ const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>
140248
return { oauth, slug };
141249
});
142250

251+
const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>
252+
Effect.gen(function* () {
253+
const oauth = yield* serveDeadGrantOAuthServer();
254+
return yield* seedDcrMcpOAuthConnection(client, prefix, oauth);
255+
});
256+
143257
const logTokenRequests = (label: string, oauth: OAuthTestServerShape) =>
144258
Effect.gen(function* () {
145259
const requests = yield* oauth.requests;
@@ -224,6 +338,94 @@ scenario(
224338
),
225339
);
226340

341+
// The reconnect journey from the bug report: a connection reads Expired, the
342+
// user completes Reconnect through the OAuth popup, and the page must show the
343+
// recovered health WITHOUT a hard refresh. The gate makes the provider's
344+
// grants dead during seeding (already-expired tokens, refresh rejected) and
345+
// healthy again before the reconnect, so the only thing standing between the
346+
// user and a green dot is the UI updating itself.
347+
scenario(
348+
"MCP OAuth · completed reconnect refreshes the health verdict without a page reload",
349+
{
350+
timeout: 240_000,
351+
},
352+
Effect.scoped(
353+
Effect.gen(function* () {
354+
const target = yield* Target;
355+
const browser = yield* Browser;
356+
const { client: makeApiClient } = yield* Api;
357+
const identity = yield* target.newIdentity();
358+
const client = yield* makeApiClient(api, identity);
359+
360+
const oauth = yield* serveOAuthTestServer({
361+
scopes: ["channels:history", "users:read"],
362+
});
363+
const gate = yield* serveGrantRevocationGate(`${oauth.issuerUrl}/token`);
364+
const { slug } = yield* seedDcrMcpOAuthConnection(client, "mcp-reconnect-live", oauth, {
365+
tokenUrl: gate.tokenUrl,
366+
});
367+
368+
// Persist the expired verdict exactly as the user's "Check now" would.
369+
const seededHealth = yield* client.connections.checkHealth({
370+
params: { owner: "org", integration: slug, name },
371+
query: {},
372+
});
373+
expect(seededHealth.status, "the dead grant seeds an expired verdict").toBe("expired");
374+
expect(gate.refreshRejections(), "the expiry came from a rejected refresh").toBeGreaterThan(
375+
0,
376+
);
377+
378+
yield* browser.session(identity, async ({ page, step }) => {
379+
const connections = connectionsSection(page);
380+
const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
381+
382+
await step("Open the integration: the connection reads Expired", async () => {
383+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
384+
await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
385+
await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 });
386+
});
387+
388+
await step("Reconnect and complete the OAuth flow in the popup", async () => {
389+
// The provider comes back before the user reconnects — fresh grants
390+
// are fully healthy from here on.
391+
gate.restore();
392+
393+
const popupPromise = page.waitForEvent("popup", { timeout: 30_000 });
394+
await menuTrigger.click();
395+
await page.getByRole("menuitem", { name: "Reconnect" }).click();
396+
const popup = await popupPromise;
397+
398+
// The test AS login page is plain text driven by Basic-auth POST, so
399+
// complete it out of band and drive the popup to the callback — the
400+
// same journey a user's click-through consent takes.
401+
await popup.waitForURL(/\/login\?/, { timeout: 30_000 });
402+
const callbackUrl = await submitProviderLogin(popup.url());
403+
await popup.goto(callbackUrl);
404+
await page.getByText("Reconnected", { exact: true }).waitFor({ timeout: 30_000 });
405+
});
406+
407+
await step("The backend already sees the connection as healthy", async () => {
408+
// Evidence that only the UI is stale: the same health endpoint the
409+
// page would call classifies the re-minted grant as healthy.
410+
const response = await page.request.post(healthPath(slug));
411+
const body = (await response.json()) as { readonly status?: string };
412+
console.info(`[BUG repro] post-reconnect health: ${response.status()} ${body.status}`);
413+
expect(response.status(), "post-reconnect health check succeeds").toBe(200);
414+
expect(body.status, "the re-minted grant is healthy").toBe("healthy");
415+
});
416+
417+
await step("BUG: the row must flip to Healthy without a hard page refresh", async () => {
418+
await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 });
419+
await connections.getByText("Expired", { exact: true }).waitFor({
420+
state: "hidden",
421+
timeout: 5_000,
422+
});
423+
});
424+
});
425+
}),
426+
),
427+
);
428+
227429
scenario(
228430
"MCP OAuth · DCR reconnect keeps the dialog open and reaches OAuth start",
229431
{

‎packages/core/sdk/src/executor.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2962,6 +2962,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
29622962
input.missingOAuthScopes && input.missingOAuthScopes.length > 0
29632963
? { missingOAuthScopes: input.missingOAuthScopes }
29642964
: null,
2965+
// A re-mint replaces the grant, so any persisted verdict describes
2966+
// a credential that no longer exists. Clear it rather than let a
2967+
// pre-reconnect "expired" outlive the reconnect; the next health
2968+
// check writes the verdict for the new grant.
2969+
last_health: null,
29652970
updated_at: now,
29662971
};
29672972
if (existing) {

‎packages/plugins/mcp/src/sdk/connection.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ const fetchFromHttpClientLayer = (
172172
}
173173
return response;
174174
});
175+
// Mark the request promise observed (a no-op handler on the ORIGINAL
176+
// promise; callers still see the rejection). The MCP SDK fires some
177+
// requests without a rejection handler — a cancellation notification
178+
// after a request timeout, an SSE dial raced against an abort — and when
179+
// the upstream is already gone that rejection is unhandled, which kills
180+
// the whole Bun server process, not just this call. Browsers never crash
181+
// on an unobserved fetch rejection; this adapter must match.
182+
// oxlint-disable-next-line executor/no-promise-catch -- boundary: Fetch-compatible adapter must observe rejections the SDK abandons
183+
promise.catch(() => undefined);
175184
if (!init?.signal) return promise;
176185
// oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics
177186
if (init.signal.aborted) return Promise.reject(abortError(init.signal));

0 commit comments

Comments
 (0)