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.
Showing the changes to packages/plugins (2 of 8 files). Show all
Unified · Split
| @@ -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 | }); |
| @@ -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 === 408 | 38 | error.status === 408 |
| 32 | ); | 39 | ); |