Skip to content

Commit 317fec5

Browse files
author
Kripa Dev
committed
merge: resolve streamableHttp.ts conflict, keep OAuth implementation
2 parents a408ca7 + 45c5a0c commit 317fec5

3 files changed

Lines changed: 213 additions & 1 deletion

File tree

packages/client/src/client/sse.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export class SSEClientTransport implements Transport {
7878
private _fetchWithInit: FetchLike;
7979
private _protocolVersion?: string;
8080

81+
private static readonly _INTERACTIVE_AUTH_STATE_PREFIX = 'mcp:oauth:interactive:sse:';
82+
8183
onclose?: () => void;
8284
onerror?: (error: Error) => void;
8385
onmessage?: (message: JSONRPCMessage) => void;
@@ -96,6 +98,64 @@ export class SSEClientTransport implements Transport {
9698
}
9799
this._fetch = opts?.fetch;
98100
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
101+
102+
this._loadInteractiveAuthState();
103+
}
104+
105+
private _interactiveAuthStateKey(): string {
106+
return `${SSEClientTransport._INTERACTIVE_AUTH_STATE_PREFIX}${this._url.toString()}`;
107+
}
108+
109+
private _saveInteractiveAuthState(): void {
110+
if (typeof sessionStorage === 'undefined') {
111+
return;
112+
}
113+
114+
const state = {
115+
resourceMetadataUrl: this._resourceMetadataUrl?.toString(),
116+
scope: this._scope
117+
};
118+
119+
try {
120+
sessionStorage.setItem(this._interactiveAuthStateKey(), JSON.stringify(state));
121+
} catch {
122+
// Ignore storage failures (e.g. quota exceeded)
123+
}
124+
}
125+
126+
private _loadInteractiveAuthState(): void {
127+
if (typeof sessionStorage === 'undefined') {
128+
return;
129+
}
130+
131+
try {
132+
const raw = sessionStorage.getItem(this._interactiveAuthStateKey());
133+
if (!raw) {
134+
return;
135+
}
136+
137+
const parsed = JSON.parse(raw) as { resourceMetadataUrl?: string; scope?: string };
138+
if (parsed.resourceMetadataUrl) {
139+
this._resourceMetadataUrl = new URL(parsed.resourceMetadataUrl);
140+
}
141+
if (parsed.scope) {
142+
this._scope = parsed.scope;
143+
}
144+
} catch {
145+
// Ignore malformed persisted state
146+
}
147+
}
148+
149+
private _clearInteractiveAuthState(): void {
150+
if (typeof sessionStorage === 'undefined') {
151+
return;
152+
}
153+
154+
try {
155+
sessionStorage.removeItem(this._interactiveAuthStateKey());
156+
} catch {
157+
// Ignore storage failures
158+
}
99159
}
100160

101161
private _last401Response?: Response;
@@ -137,6 +197,7 @@ export class SSEClientTransport implements Transport {
137197
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
138198
this._resourceMetadataUrl = resourceMetadataUrl;
139199
this._scope = scope;
200+
this._saveInteractiveAuthState();
140201
}
141202
}
142203

@@ -237,6 +298,8 @@ export class SSEClientTransport implements Transport {
237298
if (result !== 'AUTHORIZED') {
238299
throw new UnauthorizedError('Failed to authorize');
239300
}
301+
302+
this._clearInteractiveAuthState();
240303
}
241304

242305
async close(): Promise<void> {
@@ -272,6 +335,7 @@ export class SSEClientTransport implements Transport {
272335
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
273336
this._resourceMetadataUrl = resourceMetadataUrl;
274337
this._scope = scope;
338+
this._saveInteractiveAuthState();
275339
}
276340

277341
if (this._authProvider.onUnauthorized && !isAuthRetry) {

packages/client/test/client/sse.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ describe('SSEClientTransport', () => {
3636
let sendServerMessage: ((message: string) => void) | null = null;
3737

3838
beforeEach(async () => {
39+
const sessionStorageStore = new Map<string, string>();
40+
Object.defineProperty(globalThis, 'sessionStorage', {
41+
configurable: true,
42+
value: {
43+
getItem: (key: string) => sessionStorageStore.get(key) ?? null,
44+
setItem: (key: string, value: string) => {
45+
sessionStorageStore.set(key, value);
46+
},
47+
removeItem: (key: string) => {
48+
sessionStorageStore.delete(key);
49+
}
50+
}
51+
});
52+
3953
// Reset state
4054
lastServerRequest = null as unknown as IncomingMessage;
4155
sendServerMessage = null;
@@ -111,6 +125,7 @@ describe('SSEClientTransport', () => {
111125
await authServer.close();
112126

113127
vi.clearAllMocks();
128+
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
114129
});
115130

116131
describe('connection handling', () => {
@@ -1527,6 +1542,50 @@ describe('SSEClientTransport', () => {
15271542
// Global fetch should never have been called
15281543
expect(globalFetchSpy).not.toHaveBeenCalled();
15291544
});
1545+
1546+
it('persists interactive auth metadata across transport recreation before finishAuth', async () => {
1547+
const authProviderWithCode = createMockAuthProvider({
1548+
clientRegistered: true,
1549+
authorizationCode: 'test-auth-code'
1550+
});
1551+
1552+
const unauthorizedResponse = new Response(null, {
1553+
status: 401,
1554+
headers: {
1555+
'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource", scope="calendar.read"`
1556+
}
1557+
});
1558+
1559+
const firstAuthProvider = {
1560+
token: vi.fn(async () => undefined)
1561+
};
1562+
1563+
const firstTransport = new SSEClientTransport(resourceBaseUrl, {
1564+
authProvider: firstAuthProvider,
1565+
fetch: vi.fn().mockResolvedValue(unauthorizedResponse)
1566+
});
1567+
1568+
// Skip EventSource startup; directly exercise POST 401 path where metadata is captured.
1569+
(firstTransport as unknown as { _endpoint: URL })._endpoint = new URL(resourceBaseUrl.href);
1570+
await expect(firstTransport.send({ jsonrpc: '2.0', method: 'ping', params: {}, id: '1' })).rejects.toThrow(UnauthorizedError);
1571+
await firstTransport.close();
1572+
1573+
const secondTransport = new SSEClientTransport(resourceBaseUrl, {
1574+
authProvider: authProviderWithCode,
1575+
fetch: customFetch
1576+
});
1577+
1578+
await secondTransport.finishAuth('test-auth-code');
1579+
1580+
expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({
1581+
access_token: 'new-access-token',
1582+
token_type: 'Bearer',
1583+
expires_in: 3600,
1584+
refresh_token: 'new-refresh-token'
1585+
});
1586+
1587+
await secondTransport.close();
1588+
});
15301589
});
15311590

15321591
describe('minimal AuthProvider (non-OAuth)', () => {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'
22
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
33
import type { Mock, Mocked } from 'vitest';
44

5-
import type { OAuthClientProvider } from '../../src/client/auth.js';
5+
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth.js';
66
import { UnauthorizedError } from '../../src/client/auth.js';
77
import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js';
88
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
@@ -12,6 +12,20 @@ describe('StreamableHTTPClientTransport', () => {
1212
let mockAuthProvider: Mocked<OAuthClientProvider>;
1313

1414
beforeEach(() => {
15+
const sessionStorageStore = new Map<string, string>();
16+
Object.defineProperty(globalThis, 'sessionStorage', {
17+
configurable: true,
18+
value: {
19+
getItem: (key: string) => sessionStorageStore.get(key) ?? null,
20+
setItem: (key: string, value: string) => {
21+
sessionStorageStore.set(key, value);
22+
},
23+
removeItem: (key: string) => {
24+
sessionStorageStore.delete(key);
25+
}
26+
}
27+
});
28+
1529
mockAuthProvider = {
1630
get redirectUrl() {
1731
return 'http://localhost/callback';
@@ -34,6 +48,7 @@ describe('StreamableHTTPClientTransport', () => {
3448
afterEach(async () => {
3549
await transport.close().catch(() => {});
3650
vi.clearAllMocks();
51+
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
3752
});
3853

3954
it('should send JSON-RPC messages via POST', async () => {
@@ -1606,6 +1621,80 @@ describe('StreamableHTTPClientTransport', () => {
16061621
// Global fetch should never have been called
16071622
expect(globalThis.fetch).not.toHaveBeenCalled();
16081623
});
1624+
1625+
it('persists interactive auth metadata across transport recreation before finishAuth', async () => {
1626+
const customFetch = vi
1627+
.fn()
1628+
// First transport send -> 401 with auth metadata
1629+
.mockResolvedValueOnce(
1630+
new Response(null, {
1631+
status: 401,
1632+
headers: {
1633+
'WWW-Authenticate':
1634+
'Bearer resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource", scope="calendar.read"'
1635+
}
1636+
})
1637+
)
1638+
// Second transport finishAuth -> resource metadata discovery
1639+
.mockResolvedValueOnce(
1640+
Response.json({
1641+
authorization_servers: ['http://localhost:1234'],
1642+
resource: 'http://localhost:1234/mcp'
1643+
})
1644+
)
1645+
// auth server metadata discovery
1646+
.mockResolvedValueOnce(
1647+
Response.json({
1648+
issuer: 'http://localhost:1234',
1649+
authorization_endpoint: 'http://localhost:1234/authorize',
1650+
token_endpoint: 'http://localhost:1234/token',
1651+
response_types_supported: ['code'],
1652+
code_challenge_methods_supported: ['S256']
1653+
})
1654+
)
1655+
// authorization code exchange
1656+
.mockResolvedValueOnce(
1657+
Response.json({
1658+
access_token: 'new-access-token',
1659+
refresh_token: 'new-refresh-token',
1660+
token_type: 'Bearer',
1661+
expires_in: 3600
1662+
})
1663+
);
1664+
1665+
const firstAuthProvider: AuthProvider = {
1666+
token: vi.fn(async () => undefined)
1667+
};
1668+
1669+
const firstTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1670+
authProvider: firstAuthProvider,
1671+
fetch: customFetch
1672+
});
1673+
1674+
await firstTransport.start();
1675+
1676+
await expect(firstTransport.send({ jsonrpc: '2.0', method: 'ping', params: {}, id: '1' } as JSONRPCMessage)).rejects.toThrow(
1677+
UnauthorizedError
1678+
);
1679+
1680+
await firstTransport.close();
1681+
1682+
const secondTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1683+
authProvider: mockAuthProvider,
1684+
fetch: customFetch
1685+
});
1686+
1687+
await secondTransport.finishAuth('auth-code-after-redirect');
1688+
1689+
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
1690+
access_token: 'new-access-token',
1691+
token_type: 'Bearer',
1692+
expires_in: 3600,
1693+
refresh_token: 'new-refresh-token'
1694+
});
1695+
1696+
await secondTransport.close();
1697+
});
16091698
});
16101699

16111700
describe('SSE retry field handling', () => {

0 commit comments

Comments
 (0)