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
153 }),153 }),
154 ),154 ),
155 );155 );
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 );
156});197});
packages/plugins/mcp/src/sdk/connection-pool.ts+7−0
@@ -27,6 +27,13 @@ const isDeadConnectionFailure = (error: unknown): boolean => { · 26 unmodified lines
27 return (27 return (
28 error.transportFailure === true ||28 error.transportFailure === true ||
29 error.status === 400 ||29 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 ||
30 error.status === 404 ||37 error.status === 404 ||
31 error.status === 40838 error.status === 408
32 );39 );