🛞 fix: Re-Exchange Rejected OBO Tokens (#15577)

* 🔑 fix: Re-Exchange Rejected OBO Tokens on MCP Auth Failure

An OBO MCP connection registered the non-OAuth fallback handler, so a 401/403
from the downstream server became `oauthFailed` without ever running another
token exchange. Every reconnect attempt replayed the credential the server had
just rejected.

Unlike interactive OAuth, an OBO bearer is minted from the user's live upstream
session, so a rejection is recoverable with no user-facing step — but only while
that session is reachable. `handleOboEvents` re-runs the exchange for a
connection whose creating request is still in flight, and retires the connection
otherwise, since `upstreamTokenProvider` closes over a finished request past that
point and the next borrower rebuilds against a live provider.

Three parts:

- `handleOboEvents` replaces the fallback for OBO connections: it re-exchanges
  once per attempt, updates the transport bearer, and emits `oauthHandled` so the
  connect retry proceeds.
- The refreshed token also replaces the runtime `authorization` header. Runtime
  headers outrank the transport's in `buildFetchInit`, so without this the stale
  bearer a previous tool call installed would keep winning on the retry.
- `resolveOboToken` gains `forceRefresh`, which bypasses the OBO token cache. A
  revoked or scope-invalidated token is still inside its cached lifetime, so a
  cached read would hand back the same rejected bearer.

A connection that cannot refresh now stops reconnecting rather than replaying the
rejected bearer through every backoff step and spending the circuit breaker's
cycle budget.

* 🔁 fix: Re-Exchange Rejected OBO Tokens on Tool Calls Too

Addresses both codex findings on c77be03.

A tool-call 401 never reached the connection-level handler: `oauthRequired` is
emitted only by `connectClient`, and `callTool`'s own recovery attaches solely
when `isOAuthServer(currentOptions) || connection.usesOAuth()` — both false for
an OBO-only config. It rethrew untouched, and the resolver kept serving the
rejected bearer from cache on every later call until it expired. This is the
dominant path for OBO traffic, so the cache-invalidation half of the previous
commit was not actually reachable in practice.

The per-call token resolution moves into `applyOboAuthorization(forceRefresh)`,
which the auth-error path re-runs with the cache bypassed before retrying once.
Recovery belongs here rather than at the connection level: the request is still
live, so `upstreamTokenProvider` can mint a replacement.

Also propagate the re-exchange failure. `connectClient` rejects its handling
promise with the mapped error and then rethrows the server's original 401, so a
diagnosis like an unrefreshable sign-in session was lost. `createConnection` now
surfaces it in place of the 401 — the same substitution the initial OBO
resolution already makes — and only for a genuine exchange failure, not for the
control-flow deferrals.

* 🔗 fix: Persist the Re-Exchanged OBO Token on the Connection

Addresses the round-2 codex finding on 09ccb34.

The tool-call recovery updated only the runtime request headers, leaving
`connection.oauthTokens` holding the rejected credential. That is not merely
untidy: a legacy SSE connection's event stream is served by
`eventSourceInit.fetch`, which bypasses `createFetchFunction` entirely and never
consults `getRequestHeaders()`. It sends the headers `constructTransport`
captured from `oauthTokens`, so the next transport rebuild re-baked the dead
bearer, 401'd, and — through the cached-connection handler added earlier in this
PR — retired a connection that had already recovered.

`applyOboAuthorization` now stores the resolved token via `setOAuthTokens`, the
same thing the connection-level refresh path does. Storing it on every
resolution rather than only on the retry also ends the pre-existing drift where
a connection's baked bearer aged out while tool calls used newer ones.
This commit is contained in:
Danny Avila 2026-09-04 00:06:44 -04:00 • committed by GitHub
parent 78b320cbbc
commit 1754297038
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 519 additions and 37 deletions

View file

@ -77,6 +77,8 @@ export class MCPConnectionFactory {
protected readonly oboTrustChecker?: OboTrustChecker;
protected readonly upstreamTokenProvider?: UpstreamTokenProvider;
protected readonly oboIdentityContext?: AuthIdentityContext;
/** Why the OBO re-exchange failed, when that is more actionable than the server's 401. */
private oboRefreshError?: Error;
private connectionReady = false;
/**
* Snapshot of the tenant context at factory construction time. Captured eagerly
@ -451,8 +453,13 @@ export class MCPConnectionFactory {
}
}
/** Resolves OBO tokens when the server config specifies obo, returns null otherwise */
protected async getOboTokens(): Promise<MCPOAuthTokens | null> {
/**
* Resolves OBO tokens when the server config specifies obo, returns null otherwise.
*
* @param forceRefresh Bypasses the OBO token cache; used when the downstream server
* has rejected the credential we currently hold.
*/
protected async getOboTokens(forceRefresh = false): Promise<MCPOAuthTokens | null> {
const oboConfig = this.serverConfig.obo;
if (!oboConfig || !this.oboTokenResolver || !this.user) {
return null;
@ -488,6 +495,7 @@ export class MCPConnectionFactory {
this.oboTokenResolver,
this.upstreamTokenProvider,
this.oboIdentityContext,
forceRefresh,
);
}
@ -543,6 +551,8 @@ export class MCPConnectionFactory {
let cleanupOAuthHandlers: (() => void) | null = null;
if (this.useOAuth && !this.usesObo) {
cleanupOAuthHandlers = this.handleOAuthEvents(connection);
} else if (this.usesObo) {
cleanupOAuthHandlers = this.handleOboEvents(connection);
} else {
const nonOAuthHandler = () => {
logger.info(
@ -575,6 +585,9 @@ export class MCPConnectionFactory {
} catch {
logger.warn(`${this.logPrefix} Failed to clean up rejected MCP connection`);
}
if (this.oboRefreshError) {
throw this.oboRefreshError;
}
throw error;
}
}
@ -1109,6 +1122,89 @@ export class MCPConnectionFactory {
return expiresAt > Date.now() ? expiresAt : undefined;
}
/**
* Sets up the authentication handler for an OBO connection.
*
* An OBO server rejecting our bearer is recoverable in a way the non-OAuth
* fallback cannot express: the downstream credential is minted from the user's
* live upstream session, so a fresh exchange produces a working token without
* any interactive step. What it needs is that live session, which only exists
* while the request that created this connection is still running. Past that
* point `upstreamTokenProvider` closes over a finished request, so a cached
* connection stops reconnecting instead and its next borrower rebuilds it
* against a live provider.
*/
protected handleOboEvents(connection: MCPConnection): () => void {
let refreshAttempted = false;
const oboHandler = async (): Promise<void> => {
if (this.connectionReady) {
logger.info(
`${this.logPrefix} Cached OBO connection was rejected; deferring to a live request for re-exchange`,
);
this.abandonOboConnection(connection, new Error('OBO re-exchange requires a live request'));
return;
}
if (refreshAttempted) {
logger.warn(`${this.logPrefix} Refreshed OBO token was rejected as well`);
this.abandonOboConnection(connection, new Error('Refreshed OBO token was rejected'));
return;
}
refreshAttempted = true;
logger.info(`${this.logPrefix} OBO token rejected by server; re-running token exchange`);
try {
const tokens = await this.getOboTokens(true);
if (!tokens?.access_token) {
throw new Error(`OBO token exchange returned no token for "${this.serverName}".`);
}
connection.setOAuthTokens(tokens);
connection.setAuthorizationHeader(tokens.access_token);
logger.info(`${this.logPrefix} OBO token re-exchanged; retrying connection`);
connection.emit('oauthHandled');
} catch (error) {
logger.error(`${this.logPrefix} OBO token re-exchange failed`, error);
/**
* `connectClient` rejects its handling promise with this error and then
* rethrows the server's original 401, so a diagnosis like an unrefreshable
* sign-in session would be lost. `createConnection` reads it back and
* surfaces it in place of the generic 401 — the same substitution the
* initial OBO resolution already makes.
*/
this.oboRefreshError = this.toOboRefreshError(error);
this.abandonOboConnection(connection, this.oboRefreshError);
}
};
connection.on('oauthRequired', oboHandler);
return () => {
connection.removeListener('oauthRequired', oboHandler);
};
}
private toOboRefreshError(error: unknown): Error {
if (error instanceof OboTokenResolutionError) {
return this.createOboConnectionError(error);
}
if (error instanceof Error) {
return error;
}
return new Error(`OBO token re-exchange failed for "${this.serverName}".`);
}
/**
* Ends recovery for an OBO connection holding a credential nothing can replace.
* Reconnect attempts would replay the rejected bearer through every backoff step
* and spend the circuit breaker's cycle budget, so the connection is retired and
* left for its next borrower to dispose and rebuild.
*/
private abandonOboConnection(connection: MCPConnection, error: Error): void {
connection.stopReconnecting();
connection.emit('oauthFailed', error);
}
/** Sets up OAuth event handlers for the connection */
protected handleOAuthEvents(
connection: MCPConnection,

View file

@ -1000,9 +1000,19 @@ Please follow these instructions when using tools from the respective MCP server
const resolvedHeaders: Record<string, string> =
'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {};
/** Resolve the current OBO token for this tool call; the resolver may serve cached tokens. */
const oboConfig = rawConfig.obo;
if (oboConfig && oboTokenResolver && user) {
const usesObo = Boolean(oboConfig && oboTokenResolver && user);
/**
* Resolves the downstream token for this call and installs it as the request
* bearer. `forceRefresh` bypasses the resolver's cache, which is what a
* rejected credential needs: a revoked or scope-invalidated token is still
* inside its cached lifetime, so a cached read returns the same dead bearer.
*/
const applyOboAuthorization = async (forceRefresh: boolean): Promise<void> => {
if (!oboConfig || !oboTokenResolver || !user) {
return;
}
if (!upstreamTokenProvider) {
throw new McpError(
ErrorCode.InternalError,
@ -1035,6 +1045,7 @@ Please follow these instructions when using tools from the respective MCP server
oboTokenResolver,
upstreamTokenProvider,
oboIdentityContext,
forceRefresh,
);
} catch (error) {
if (error instanceof OboTokenResolutionError) {
@ -1053,7 +1064,18 @@ Please follow these instructions when using tools from the respective MCP server
);
}
resolvedHeaders['Authorization'] = `Bearer ${oboTokens.access_token}`;
}
/**
* Runtime request headers do not reach a legacy SSE connection's event
* stream — `eventSourceInit.fetch` bypasses `createFetchFunction` and sends
* the headers `constructTransport` captured from `oauthTokens`. Without
* this the next transport rebuild re-bakes the rejected bearer, 401s, and
* retires a connection that had already recovered.
*/
connection!.setOAuthTokens(oboTokens);
};
/** Resolve the current OBO token for this tool call; the resolver may serve cached tokens. */
await applyOboAuthorization(false);
if (
userId &&
user &&
@ -1150,41 +1172,58 @@ Please follow these instructions when using tools from the respective MCP server
try {
result = await requestTool();
} catch (error) {
const requestOAuthHandler = attachSharedOAuthHandler;
if (!requestOAuthHandler || !userId) {
throw error;
}
if (!connection.isOAuthAuthenticationError(error)) {
throw error;
}
try {
await waitForRecoveryWithoutLease(() =>
this.recoverOAuthConnection(
connection!,
error,
serverName,
userId,
requestOAuthHandler,
oauthStart,
oauthEnd,
flowManager,
options?.signal,
!recoveryTakeoverConsumed,
),
/**
* An OBO server rejecting the bearer mid-session is recoverable here and
* nowhere else: the downstream token is minted from the upstream session
* this request still holds, and `attachSharedOAuthHandler` is never set for
* an OBO-only config, so the OAuth recovery below would rethrow untouched.
* Without this the rejected token is re-served from cache on every later
* call until it expires.
*/
if (usesObo && connection.isOAuthAuthenticationError(error)) {
logger.info(
`${logPrefix}[${toolName}] OBO token rejected by server; re-exchanging and retrying once`,
);
} catch (recoveryError) {
if (recoveryError instanceof OAuthRecoveryTakeoverRequired) {
throw recoveryError;
await applyOboAuthorization(true);
connection.setRequestHeaders(resolvedHeaders);
result = await requestTool();
} else {
const requestOAuthHandler = attachSharedOAuthHandler;
if (!requestOAuthHandler || !userId) {
throw error;
}
if (options?.signal?.aborted) {
throw recoveryError;
if (!connection.isOAuthAuthenticationError(error)) {
throw error;
}
logger.warn(`${logPrefix}[${toolName}] Runtime OAuth recovery failed`, recoveryError);
throw error;
try {
await waitForRecoveryWithoutLease(() =>
this.recoverOAuthConnection(
connection!,
error,
serverName,
userId,
requestOAuthHandler,
oauthStart,
oauthEnd,
flowManager,
options?.signal,
!recoveryTakeoverConsumed,
),
);
} catch (recoveryError) {
if (recoveryError instanceof OAuthRecoveryTakeoverRequired) {
throw recoveryError;
}
if (options?.signal?.aborted) {
throw recoveryError;
}
logger.warn(`${logPrefix}[${toolName}] Runtime OAuth recovery failed`, recoveryError);
throw error;
}
result = await requestTool();
}
result = await requestTool();
}
const hasPersistentUserConnections =
!!userId && (this.userConnections.get(userId)?.size ?? 0) > 0;

View file

@ -136,6 +136,8 @@ describe('MCPConnectionFactory', () => {
connect: jest.fn(),
isConnected: jest.fn(),
setOAuthTokens: jest.fn(),
setAuthorizationHeader: jest.fn(),
stopReconnecting: jest.fn(),
on: jest.fn().mockReturnValue(mockConnectionInstance),
once: jest.fn().mockReturnValue(mockConnectionInstance),
off: jest.fn().mockReturnValue(mockConnectionInstance),
@ -4859,6 +4861,7 @@ describe('MCPConnectionFactory', () => {
oboTokenResolver,
upstreamTokenProvider,
undefined,
false,
);
});
@ -4890,4 +4893,173 @@ describe('MCPConnectionFactory', () => {
expect(resolveOboToken).not.toHaveBeenCalled();
});
});
describe('OBO authentication failures', () => {
const oboServerConfig = {
type: 'sse' as const,
url: 'https://obo.example.com',
requiresOAuth: false,
obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' },
} as unknown as t.MCPOptions;
const connectionTokens = {
access_token: 'rejected-obo-token',
token_type: 'Bearer' as const,
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
};
const refreshedTokens = {
access_token: 'refreshed-obo-token',
token_type: 'Bearer' as const,
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
};
let resolveOboToken: jest.Mock;
/** The `oauthRequired` listener the factory attached for this connection. */
const getAuthHandler = (): (() => Promise<void>) => {
const onCall = (mockConnectionInstance.on as jest.Mock).mock.calls.find(
([event]: [string]) => event === 'oauthRequired',
);
return onCall![1] as () => Promise<void>;
};
const createOboConnection = () =>
MCPConnectionFactory.create(
{ serverName: 'obo-srv', serverConfig: oboServerConfig },
{
useOAuth: true,
user: mockUser,
flowManager: mockFlowManager,
tokenMethods: {
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
oboTokenResolver: jest.fn(),
upstreamTokenProvider: jest.fn(),
},
);
beforeEach(() => {
mockProcessMCPEnv.mockReturnValue(oboServerConfig);
mockConnectionInstance.connect.mockResolvedValue(undefined);
mockConnectionInstance.isConnected.mockResolvedValue(true);
({ resolveOboToken } = jest.requireMock('~/mcp/oauth') as {
resolveOboToken: jest.Mock;
});
resolveOboToken.mockReset();
resolveOboToken.mockResolvedValue(connectionTokens);
});
it('re-exchanges a rejected token and lets the connect retry proceed', async () => {
resolveOboToken
.mockResolvedValueOnce(connectionTokens)
.mockResolvedValueOnce(refreshedTokens);
/** The real handler runs inside `connectClient`, so fire it from within `connect()`. */
mockConnectionInstance.connect.mockImplementationOnce(async () => {
await getAuthHandler()();
});
await createOboConnection();
expect(resolveOboToken).toHaveBeenCalledTimes(2);
/** The rejected credential is still inside its cached lifetime, so the cache is bypassed. */
expect(resolveOboToken).toHaveBeenLastCalledWith(
mockUser,
oboServerConfig.obo,
expect.any(Function),
expect.any(Function),
undefined,
true,
);
expect(mockConnectionInstance.setOAuthTokens).toHaveBeenCalledWith(refreshedTokens);
/** Runtime headers outrank the transport's, so the stale bearer has to be replaced too. */
expect(mockConnectionInstance.setAuthorizationHeader).toHaveBeenCalledWith(
'refreshed-obo-token',
);
expect(mockConnectionInstance.emit).toHaveBeenCalledWith('oauthHandled');
expect(mockConnectionInstance.emit).not.toHaveBeenCalledWith(
'oauthFailed',
expect.anything(),
);
expect(mockConnectionInstance.stopReconnecting).not.toHaveBeenCalled();
});
it('retires the connection when the re-exchange fails', async () => {
resolveOboToken
.mockResolvedValueOnce(connectionTokens)
.mockRejectedValueOnce(new Error('IdP rejected the exchange'));
mockConnectionInstance.connect.mockImplementationOnce(async () => {
await getAuthHandler()();
});
await createOboConnection();
expect(mockConnectionInstance.setOAuthTokens).not.toHaveBeenCalled();
expect(mockConnectionInstance.stopReconnecting).toHaveBeenCalled();
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
'oauthFailed',
expect.objectContaining({ message: 'IdP rejected the exchange' }),
);
});
it('surfaces why the re-exchange failed instead of the generic 401', async () => {
const authError = new Error('HTTP 401 Unauthorized');
resolveOboToken
.mockResolvedValueOnce(connectionTokens)
.mockRejectedValueOnce(new Error('Your sign-in session expired. Please sign in again.'));
/**
* `connectClient` rethrows the server's original 401 after the handler fails,
* so without propagation the caller would only ever see `authError`.
*/
mockConnectionInstance.connect.mockImplementationOnce(async () => {
await getAuthHandler()();
throw authError;
});
mockConnectionInstance.isConnected.mockResolvedValue(false);
await expect(createOboConnection()).rejects.toThrow(
'Your sign-in session expired. Please sign in again.',
);
});
it('re-exchanges at most once per connection attempt', async () => {
resolveOboToken
.mockResolvedValueOnce(connectionTokens)
.mockResolvedValueOnce(refreshedTokens);
mockConnectionInstance.connect.mockImplementationOnce(async () => {
const handler = getAuthHandler();
await handler();
await handler();
});
await createOboConnection();
expect(resolveOboToken).toHaveBeenCalledTimes(2);
expect(mockConnectionInstance.stopReconnecting).toHaveBeenCalled();
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
'oauthFailed',
expect.objectContaining({ message: 'Refreshed OBO token was rejected' }),
);
});
it('defers to a live request instead of re-exchanging on a cached connection', async () => {
await createOboConnection();
expect(resolveOboToken).toHaveBeenCalledTimes(1);
await getAuthHandler()();
/** The creating request is over, so `upstreamTokenProvider` no longer has a live session. */
expect(resolveOboToken).toHaveBeenCalledTimes(1);
expect(mockConnectionInstance.setOAuthTokens).not.toHaveBeenCalled();
expect(mockConnectionInstance.stopReconnecting).toHaveBeenCalled();
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
'oauthFailed',
expect.objectContaining({ message: 'OBO re-exchange requires a live request' }),
);
});
});
});

View file

@ -2659,6 +2659,7 @@ describe('MCPManager', () => {
const mockConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
setOAuthTokens: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
@ -2695,6 +2696,7 @@ describe('MCPManager', () => {
const sharedAppConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
setOAuthTokens: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
@ -2707,6 +2709,7 @@ describe('MCPManager', () => {
const userConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
setOAuthTokens: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
@ -2804,6 +2807,7 @@ describe('MCPManager', () => {
mockOboTokenResolver,
mockUpstreamTokenProvider,
undefined,
false,
);
expect(appConnections.get).not.toHaveBeenCalled();
expect(getUserConnectionSpy).toHaveBeenCalled();
@ -2934,6 +2938,123 @@ describe('MCPManager', () => {
});
expect(mockResolveOboToken).not.toHaveBeenCalled();
});
it('re-exchanges past the token cache when the server rejects the bearer mid-call', async () => {
const authError = new Error('HTTP 401 Unauthorized');
const rejectingConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
setOAuthTokens: jest.fn(),
isOAuthAuthenticationError: jest.fn().mockReturnValue(true),
timeout: 30000,
client: {
request: jest
.fn()
.mockRejectedValueOnce(authError)
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'Tool result' }],
isError: false,
}),
},
} as unknown as MCPConnection;
mockResolveOboToken
.mockResolvedValueOnce({
access_token: 'rejected-obo-token',
token_type: 'Bearer',
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
})
.mockResolvedValueOnce({
access_token: 'reminted-obo-token',
token_type: 'Bearer',
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
});
mockAppConnections({ get: jest.fn().mockResolvedValue(rejectingConnection) });
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
jest.spyOn(manager, 'getUserConnection').mockResolvedValue(rejectingConnection);
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
oboTokenResolver: mockOboTokenResolver,
upstreamTokenProvider: mockUpstreamTokenProvider,
});
/** The rejected token is still inside its cached lifetime, so the cache is bypassed. */
expect(mockResolveOboToken).toHaveBeenNthCalledWith(
2,
mockUser,
serverConfig.obo,
mockOboTokenResolver,
mockUpstreamTokenProvider,
undefined,
true,
);
expect(rejectingConnection.setRequestHeaders as jest.Mock).toHaveBeenLastCalledWith(
expect.objectContaining({ Authorization: 'Bearer reminted-obo-token' }),
);
/**
* A legacy SSE event stream reads its bearer from `oauthTokens` at transport
* construction and never sees runtime request headers, so leaving the rejected
* credential there would 401 the next rebuild and retire a recovered connection.
*/
expect(rejectingConnection.setOAuthTokens as jest.Mock).toHaveBeenLastCalledWith(
expect.objectContaining({ access_token: 'reminted-obo-token' }),
);
expect((rejectingConnection.client.request as jest.Mock).mock.calls).toHaveLength(2);
});
it('does not retry a non-auth failure', async () => {
const toolError = new Error('tool blew up');
const failingConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
setOAuthTokens: jest.fn(),
isOAuthAuthenticationError: jest.fn().mockReturnValue(false),
timeout: 30000,
client: { request: jest.fn().mockRejectedValue(toolError) },
} as unknown as MCPConnection;
mockResolveOboToken.mockResolvedValue({
access_token: 'fresh-obo-token',
token_type: 'Bearer',
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
});
mockAppConnections({ get: jest.fn().mockResolvedValue(failingConnection) });
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
jest.spyOn(manager, 'getUserConnection').mockResolvedValue(failingConnection);
await expect(
manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
oboTokenResolver: mockOboTokenResolver,
upstreamTokenProvider: mockUpstreamTokenProvider,
}),
).rejects.toThrow('tool blew up');
expect(mockResolveOboToken).toHaveBeenCalledTimes(1);
expect((failingConnection.client.request as jest.Mock).mock.calls).toHaveLength(1);
});
});
describe('getConnection', () => {

View file

@ -1158,6 +1158,30 @@ export class MCPConnection extends EventEmitter {
return this.requestHeaders;
}
/**
* Replaces only the runtime bearer, leaving the rest of the per-request headers
* a tool call installed intact. Runtime headers outrank the ones baked into the
* transport (see `buildFetchInit`), so a credential refreshed mid-connection has
* to land here as well or the stale `authorization` from the last tool call would
* keep winning on the retry.
*/
setAuthorizationHeader(accessToken: string): void {
this.requestHeaders = {
...(this.requestHeaders ?? {}),
authorization: `Bearer ${accessToken}`,
};
}
/**
* Halts reconnect attempts for a credential this connection cannot refresh on its
* own. Retrying would replay the rejected bearer through every backoff step and
* spend the circuit breaker's cycle budget; the connection is instead left for its
* next borrower to dispose and rebuild with a live credential.
*/
stopReconnecting(): void {
this.shouldStopReconnecting = true;
}
constructor(params: MCPConnectionParams) {
super();
this.options = params.serverConfig;

View file

@ -94,6 +94,30 @@ describe('resolveOboToken', () => {
expect(result.access_token).toBe('exchanged-mcp-token');
});
it('bypasses the resolver cache when forceRefresh is set', async () => {
/**
* A downstream 401 can arrive while the credential is still inside its cached
* lifetime (revoked, or its scopes invalidated), so a cached read would hand
* back the same rejected bearer.
*/
await resolveOboToken(
mockUser as IUser,
oboConfig,
mockResolver,
liveProvider,
undefined,
true,
);
expect(mockResolver).toHaveBeenCalledWith(
mockUser,
'live-access-token',
'api://mcp-server-id/Mcp.Tools.ReadWrite',
false,
undefined,
);
});
it('throws missing_upstream_token when federated fallback token is invalid', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({
accessToken: 'federated-access-token',

View file

@ -191,6 +191,11 @@ function buildUpstreamTokenInfo(
* itself carries that verified upstream bearer — still works. Browser requests
* whose Express session was cleared reject in the provider instead of reaching
* this fallback with a stale strategy-time snapshot.
*
* @param forceRefresh Bypasses the resolver's token cache. Set it when the downstream
* server has rejected the current credential: a revoked or scope-invalidated token is
* still inside its cached lifetime, so a cached read would hand back the same rejected
* bearer instead of minting a replacement.
*/
export async function resolveOboToken(
user: IUser,
@ -198,6 +203,7 @@ export async function resolveOboToken(
oboTokenResolver: OboTokenResolver,
upstreamTokenProvider: UpstreamTokenProvider,
identityContext?: AuthIdentityContext,
forceRefresh = false,
): Promise<MCPOAuthTokens> {
let liveTokens: OIDCTokens | null;
try {
@ -240,7 +246,7 @@ export async function resolveOboToken(
user,
tokenInfo.accessToken,
oboConfig.scopes,
true,
!forceRefresh,
identityContext,
);