History

Refresh OAuth tokens on upstream 401, not just on stored expiry

The proactive expiry check is the only thing that has ever triggered a refresh, and expires_at is only the AS's advertised lifetime. Tokens die earlier than that routinely: server-side revocation, an identity provider idle-timeout policy, or an AS that omits expires_in entirely (null expiry, which shouldRefreshToken returns false for forever). Treat an upstream 401 as authoritative: re-mint once and retry. Excludes 403 (authenticated but not permitted) and connections with no refresh token, and keeps the upstream's own failure when the retry also fails. Also drop pooled MCP sessions on 401 — a session is bound to the bearer it was dialled with, so a retained one would serve the dead credential for the full idle window and the refreshed token could never reach the server. Adds tracing to the refresh path, which had no span, log, or metric.

Rhys Sullivan committed 3 months ago

Commit d8d4dcb1 parent fc1e5898 files · +536 −21Browse files

Showing the changes to packages/plugins (2 of 8 files). Show all

Unified · Split

2 files
  1. packages/plugins/mcp/src/sdk/connection-pool.test.ts +41 −0
  2. packages/plugins/mcp/src/sdk/connection-pool.ts +7 −0
packages/plugins/mcp/src/sdk/connection-pool.test.ts+41−0
@@ -153,4 +153,45 @@ describe("MCP connection pool", () => { · 152 unmodified lines
153153 }),
154154 ),
155155 );
156+
157+ it.effect("drops a pooled session whose bearer the server has stopped accepting", () =>
158+ Effect.scoped(
159+ Effect.gen(function* () {
160+ const server = yield* serveMcpServer(() => makeEchoMcpServer());
161+ const pool = createMcpConnectionPool();
162+
163+ yield* invoke({
164+ endpoint: server.endpoint,
165+ pool,
166+ toolName: "echo",
167+ args: { value: "before" },
168+ });
169+
170+ // A 401 means the bearer this session was dialled with is no longer
171+ // accepted. The session is bound to that token for its lifetime, so
172+ // retaining it would serve the same dead credential for the full idle
173+ // window — and would defeat core's post-401 refresh, whose new token can
174+ // only reach the server over a NEW session.
175+ yield* server.rejectNextSessionRequest(401);
176+ yield* invoke({
177+ endpoint: server.endpoint,
178+ pool,
179+ toolName: "echo",
180+ args: { value: "unauthorized" },
181+ }).pipe(Effect.flip);
182+
183+ const after = yield* invoke({
184+ endpoint: server.endpoint,
185+ pool,
186+ toolName: "echo",
187+ args: { value: "after" },
188+ });
189+
190+ expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
191+ // A second session was dialled rather than the dead one being reused.
192+ expect(server.sessionCount()).toBe(2);
193+ yield* pool.close();
194+ }),
195+ ),
196+ );
156197 });
packages/plugins/mcp/src/sdk/connection-pool.ts+7−0
@@ -27,6 +27,13 @@ const isDeadConnectionFailure = (error: unknown): boolean => { · 26 unmodified lines
2727 return (
2828 error.transportFailure === true ||
2929 error.status === 400 ||
30+ // A 401 means the bearer this session was dialled with is no longer
31+ // accepted. The session is bound to that token for its lifetime, so
32+ // retaining it would hand the same dead credential to every caller for the
33+ // full idle window — and would defeat core's post-401 token refresh, whose
34+ // freshly minted token can only reach the server over a NEW session. Drop
35+ // it so the retry dials with the new credential.
36+ error.status === 401 ||
3037 error.status === 404 ||
3138 error.status === 408
3239 );