Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 31bbaa8

Browse files
committed
fix: preserve valid cached OAuth tokens
1 parent 3ca4b5b commit 31bbaa8

3 files changed

Lines changed: 99 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc)
88
- Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett)
9+
- Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff)
910

1011
## [0.11.1] - 2026-05-14
1112

src/runtime/oauth.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { auth as sdkAuth } from '@modelcontextprotocol/sdk/client/auth.js';
22
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
3+
import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
34
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
45
import type { Logger } from '../logging.js';
56
import type { OAuthSession } from '../oauth.js';
@@ -9,6 +10,7 @@ export const DEFAULT_OAUTH_CODE_TIMEOUT_MS = 300_000;
910
const OAUTH_FLOW_ERROR = Symbol('oauth-flow-error');
1011
const POST_AUTH_CONNECT_ERROR = Symbol('post-auth-connect-error');
1112
const MAX_OAUTH_ERROR_DETAIL_LENGTH = 1_200;
13+
const PROACTIVE_TOKEN_SKEW_SECONDS = 60;
1214

1315
export interface OAuthCapableTransport extends Transport {
1416
close(): Promise<void>;
@@ -109,6 +111,15 @@ function hasErrorMarker(error: unknown, marker: symbol): boolean {
109111
);
110112
}
111113

114+
function hasUsableCachedAccessToken(tokens: OAuthTokens | undefined): boolean {
115+
if (!tokens || typeof tokens.access_token !== 'string' || tokens.access_token.trim().length === 0) {
116+
return false;
117+
}
118+
const stored = tokens as OAuthTokens & { expires_at?: number; expiresAt?: number };
119+
const expiresAt = typeof stored.expires_at === 'number' ? stored.expires_at : stored.expiresAt;
120+
return typeof expiresAt === 'number' && expiresAt > Math.floor(Date.now() / 1000) + PROACTIVE_TOKEN_SKEW_SECONDS;
121+
}
122+
112123
export async function connectWithAuth(
113124
client: Client,
114125
transport: OAuthCapableTransport,
@@ -239,6 +250,10 @@ async function completeProactiveAuthorization(
239250
return;
240251
}
241252
try {
253+
const cachedTokens = await session.provider.tokens?.();
254+
if (hasUsableCachedAccessToken(cachedTokens)) {
255+
return;
256+
}
242257
const result = await sdkAuth(session.provider, {
243258
serverUrl: options.serverUrl,
244259
fetchFn: options.fetchFn,

tests/runtime-oauth-connect.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,67 @@ describe('connectWithAuth', () => {
241241
expect(connectedTransport).toBe(transport);
242242
});
243243

244+
it('skips proactive OAuth when cached tokens are still usable', async () => {
245+
const connect = vi.fn().mockResolvedValueOnce(undefined);
246+
const client = { connect } as unknown as Client;
247+
const { session, waitForAuthorizationCode } = createPendingAuthorizationSession();
248+
const tokens = vi.fn(async () => ({
249+
access_token: 'cached-token',
250+
token_type: 'Bearer',
251+
expires_at: Math.floor(Date.now() / 1000) + 3600,
252+
}));
253+
session.provider.tokens = tokens;
254+
mocks.sdkAuth.mockResolvedValueOnce('REDIRECT');
255+
256+
const transport = new MockTransport();
257+
const logger = createLogger();
258+
259+
const connectedTransport = await connectWithAuth(client, transport, session, logger, {
260+
serverName: 'courtlistener',
261+
maxAttempts: 1,
262+
oauthTimeoutMs: 5000,
263+
serverUrl: 'https://courtlistener.example/mcp',
264+
});
265+
266+
expect(tokens).toHaveBeenCalledTimes(1);
267+
expect(mocks.sdkAuth).not.toHaveBeenCalled();
268+
expect(waitForAuthorizationCode).not.toHaveBeenCalled();
269+
expect(transport.calls).toEqual([]);
270+
expect(session.close).toHaveBeenCalled();
271+
expect(connectedTransport).toBe(transport);
272+
});
273+
274+
it('runs proactive OAuth when cached tokens are expired', async () => {
275+
const connect = vi.fn().mockResolvedValueOnce(undefined);
276+
const client = { connect } as unknown as Client;
277+
const { session, waitForAuthorizationCode } = createPendingAuthorizationSession();
278+
session.provider.tokens = vi.fn(async () => ({
279+
access_token: 'expired-token',
280+
token_type: 'Bearer',
281+
expires_at: Math.floor(Date.now() / 1000) - 60,
282+
}));
283+
mocks.sdkAuth.mockResolvedValueOnce('AUTHORIZED');
284+
285+
const transport = new MockTransport();
286+
const logger = createLogger();
287+
288+
const connectedTransport = await connectWithAuth(client, transport, session, logger, {
289+
serverName: 'courtlistener',
290+
maxAttempts: 1,
291+
oauthTimeoutMs: 5000,
292+
serverUrl: 'https://courtlistener.example/mcp',
293+
});
294+
295+
expect(mocks.sdkAuth).toHaveBeenCalledWith(session.provider, {
296+
serverUrl: 'https://courtlistener.example/mcp',
297+
fetchFn: undefined,
298+
});
299+
expect(waitForAuthorizationCode).not.toHaveBeenCalled();
300+
expect(transport.calls).toEqual([]);
301+
expect(session.close).toHaveBeenCalled();
302+
expect(connectedTransport).toBe(transport);
303+
});
304+
244305
it('marks proactive OAuth failures as OAuth flow errors', async () => {
245306
const connect = vi.fn().mockResolvedValueOnce(undefined);
246307
const client = { connect } as unknown as Client;
@@ -261,6 +322,28 @@ describe('connectWithAuth', () => {
261322
).rejects.toSatisfy((error: unknown) => error === authError && isOAuthFlowError(error));
262323
});
263324

325+
it('marks cached token read failures during proactive OAuth as OAuth flow errors', async () => {
326+
const connect = vi.fn().mockResolvedValueOnce(undefined);
327+
const client = { connect } as unknown as Client;
328+
const { session } = createPendingAuthorizationSession();
329+
const tokenError = new Error('malformed token cache');
330+
session.provider.tokens = vi.fn(async () => {
331+
throw tokenError;
332+
});
333+
334+
const transport = new MockTransport();
335+
const logger = createLogger();
336+
337+
await expect(
338+
connectWithAuth(client, transport, session, logger, {
339+
serverName: 'calendar',
340+
maxAttempts: 1,
341+
oauthTimeoutMs: 5000,
342+
serverUrl: 'https://calendar.example/mcp',
343+
})
344+
).rejects.toSatisfy((error: unknown) => error === tokenError && isOAuthFlowError(error));
345+
});
346+
264347
it('marks finishAuth failures as oauth flow errors', async () => {
265348
const connect = vi.fn().mockRejectedValueOnce(new UnauthorizedError('auth needed'));
266349
const client = { connect } as unknown as Client;

0 commit comments

Comments
 (0)