Skip to content

Commit 47f569e

Browse files
Merge branch 'main' into feature/tool-output-type-generics
2 parents 62abfee + 7d29cee commit 47f569e

11 files changed

+419
-15
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ mcpServer.registerTool(
437437
async function main() {
438438
const transport = new StdioServerTransport();
439439
await mcpServer.connect(transport);
440-
console.log("MCP server is running...");
440+
console.error("MCP server is running...");
441441
}
442442

443443
main().catch((error) => {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/sdk",
3-
"version": "1.18.0",
3+
"version": "1.18.2",
44
"description": "Model Context Protocol implementation for TypeScript",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",

src/client/streamableHttp.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,4 +1001,69 @@ describe("StreamableHTTPClientTransport", () => {
10011001
expect(global.fetch).not.toHaveBeenCalled();
10021002
});
10031003
});
1004+
1005+
describe("prevent infinite recursion when server returns 401 after successful auth", () => {
1006+
it("should throw error when server returns 401 after successful auth", async () => {
1007+
const message: JSONRPCMessage = {
1008+
jsonrpc: "2.0",
1009+
method: "test",
1010+
params: {},
1011+
id: "test-id"
1012+
};
1013+
1014+
// Mock provider with refresh token to enable token refresh flow
1015+
mockAuthProvider.tokens.mockResolvedValue({
1016+
access_token: "test-token",
1017+
token_type: "Bearer",
1018+
refresh_token: "refresh-token",
1019+
});
1020+
1021+
const unauthedResponse = {
1022+
ok: false,
1023+
status: 401,
1024+
statusText: "Unauthorized",
1025+
headers: new Headers()
1026+
};
1027+
1028+
(global.fetch as jest.Mock)
1029+
// First request - 401, triggers auth flow
1030+
.mockResolvedValueOnce(unauthedResponse)
1031+
// Resource discovery, path aware
1032+
.mockResolvedValueOnce(unauthedResponse)
1033+
// Resource discovery, root
1034+
.mockResolvedValueOnce(unauthedResponse)
1035+
// OAuth metadata discovery
1036+
.mockResolvedValueOnce({
1037+
ok: true,
1038+
status: 200,
1039+
json: async () => ({
1040+
issuer: "http://localhost:1234",
1041+
authorization_endpoint: "http://localhost:1234/authorize",
1042+
token_endpoint: "http://localhost:1234/token",
1043+
response_types_supported: ["code"],
1044+
code_challenge_methods_supported: ["S256"],
1045+
}),
1046+
})
1047+
// Token refresh succeeds
1048+
.mockResolvedValueOnce({
1049+
ok: true,
1050+
status: 200,
1051+
json: async () => ({
1052+
access_token: "new-access-token",
1053+
token_type: "Bearer",
1054+
expires_in: 3600,
1055+
}),
1056+
})
1057+
// Retry the original request - still 401 (broken server)
1058+
.mockResolvedValueOnce(unauthedResponse);
1059+
1060+
await expect(transport.send(message)).rejects.toThrow("Server returned 401 after successful authentication");
1061+
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
1062+
access_token: "new-access-token",
1063+
token_type: "Bearer",
1064+
expires_in: 3600,
1065+
refresh_token: "refresh-token", // Refresh token is preserved
1066+
});
1067+
});
1068+
});
10041069
});

src/client/streamableHttp.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export class StreamableHTTPClientTransport implements Transport {
131131
private _sessionId?: string;
132132
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
133133
private _protocolVersion?: string;
134+
private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
134135

135136
onclose?: () => void;
136137
onerror?: (error: Error) => void;
@@ -437,6 +438,10 @@ export class StreamableHTTPClientTransport implements Transport {
437438

438439
if (!response.ok) {
439440
if (response.status === 401 && this._authProvider) {
441+
// Prevent infinite recursion when server returns 401 after successful auth
442+
if (this._hasCompletedAuthFlow) {
443+
throw new StreamableHTTPError(401, "Server returned 401 after successful authentication");
444+
}
440445

441446
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
442447

@@ -445,6 +450,8 @@ export class StreamableHTTPClientTransport implements Transport {
445450
throw new UnauthorizedError();
446451
}
447452

453+
// Mark that we completed auth flow
454+
this._hasCompletedAuthFlow = true;
448455
// Purposely _not_ awaited, so we don't call onerror twice
449456
return this.send(message);
450457
}
@@ -455,6 +462,9 @@ export class StreamableHTTPClientTransport implements Transport {
455462
);
456463
}
457464

465+
// Reset auth loop flag on successful response
466+
this._hasCompletedAuthFlow = false;
467+
458468
// If the response is 202 Accepted, there's no body to process
459469
if (response.status === 202) {
460470
// if the accepted notification is initialized, we start the SSE stream

0 commit comments

Comments
 (0)