Skip to content

Commit 7df3f8d

Browse files
authored
Merge branch 'main' into 7-20-losing-resource-bug
2 parents 9871f6d + ade237e commit 7df3f8d

File tree

16 files changed

+1156
-60
lines changed

16 files changed

+1156
-60
lines changed

.husky/pre-commit

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
npx lint-staged
2+
git update-index --again

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER
166166
DANGEROUSLY_OMIT_AUTH=true npm start
167167
```
168168

169+
---
170+
171+
**🚨 WARNING 🚨**
172+
173+
Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks.
174+
175+
Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596)
176+
177+
---
178+
169179
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
170180

171181
```bash

cli/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/inspector-cli",
3-
"version": "0.16.4",
3+
"version": "0.16.5",
44
"description": "CLI for the Model Context Protocol inspector",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -21,7 +21,7 @@
2121
},
2222
"devDependencies": {},
2323
"dependencies": {
24-
"@modelcontextprotocol/sdk": "^1.17.2",
24+
"@modelcontextprotocol/sdk": "^1.17.3",
2525
"commander": "^13.1.0",
2626
"spawn-rx": "^5.1.2"
2727
}

cli/src/transport.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ function createStdioTransport(options: TransportOptions): Transport {
3232
const defaultEnv = getDefaultEnvironment();
3333

3434
const env: Record<string, string> = {
35-
...processEnv,
3635
...defaultEnv,
36+
...processEnv,
3737
};
3838

3939
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(

client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/inspector-client",
3-
"version": "0.16.4",
3+
"version": "0.16.5",
44
"description": "Client-side application for the Model Context Protocol inspector",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -25,7 +25,7 @@
2525
"cleanup:e2e": "node e2e/global-teardown.js"
2626
},
2727
"dependencies": {
28-
"@modelcontextprotocol/sdk": "^1.17.2",
28+
"@modelcontextprotocol/sdk": "^1.17.3",
2929
"@radix-ui/react-checkbox": "^1.1.4",
3030
"@radix-ui/react-dialog": "^1.1.3",
3131
"@radix-ui/react-icons": "^1.3.0",

client/src/components/OAuthDebugCallback.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
4646
if (storedState) {
4747
try {
4848
restoredState = JSON.parse(storedState);
49+
if (restoredState && typeof restoredState.resource === "string") {
50+
restoredState.resource = new URL(restoredState.resource);
51+
}
52+
if (
53+
restoredState &&
54+
typeof restoredState.authorizationUrl === "string"
55+
) {
56+
restoredState.authorizationUrl = new URL(
57+
restoredState.authorizationUrl,
58+
);
59+
}
4960
// Clean up the stored state
5061
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
5162
} catch (e) {

client/src/components/__tests__/AuthDebugger.test.tsx

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const mockOAuthMetadata = {
2525
token_endpoint: "https://oauth.example.com/token",
2626
response_types_supported: ["code"],
2727
grant_types_supported: ["authorization_code"],
28+
scopes_supported: ["read", "write"],
2829
};
2930

3031
const mockOAuthClientInfo = {
@@ -56,6 +57,57 @@ import {
5657
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
5758
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
5859

60+
// Mock local auth module
61+
jest.mock("@/lib/auth", () => ({
62+
DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
63+
tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
64+
clear: jest.fn().mockImplementation(() => {
65+
// Mock the real clear() behavior which removes items from sessionStorage
66+
sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens");
67+
sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info");
68+
sessionStorage.removeItem(
69+
"[https://example.com/mcp] mcp_server_metadata",
70+
);
71+
}),
72+
redirectUrl: "http://localhost:3000/oauth/callback/debug",
73+
clientMetadata: {
74+
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
75+
token_endpoint_auth_method: "none",
76+
grant_types: ["authorization_code", "refresh_token"],
77+
response_types: ["code"],
78+
client_name: "MCP Inspector",
79+
},
80+
clientInformation: jest.fn().mockImplementation(async () => {
81+
const serverUrl = "https://example.com/mcp";
82+
const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`;
83+
const preregisteredData = sessionStorage.getItem(preregisteredKey);
84+
if (preregisteredData) {
85+
return JSON.parse(preregisteredData);
86+
}
87+
const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`;
88+
const dynamicData = sessionStorage.getItem(dynamicKey);
89+
if (dynamicData) {
90+
return JSON.parse(dynamicData);
91+
}
92+
return undefined;
93+
}),
94+
saveClientInformation: jest.fn().mockImplementation((clientInfo) => {
95+
const serverUrl = "https://example.com/mcp";
96+
const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`;
97+
sessionStorage.setItem(key, JSON.stringify(clientInfo));
98+
}),
99+
saveTokens: jest.fn(),
100+
redirectToAuthorization: jest.fn(),
101+
saveCodeVerifier: jest.fn(),
102+
codeVerifier: jest.fn(),
103+
saveServerMetadata: jest.fn(),
104+
getServerMetadata: jest.fn(),
105+
})),
106+
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
107+
}));
108+
109+
import { discoverScopes } from "@/lib/auth";
110+
59111
// Type the mocked functions properly
60112
const mockDiscoverAuthorizationServerMetadata =
61113
discoverAuthorizationServerMetadata as jest.MockedFunction<
@@ -75,6 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
75127
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
76128
typeof discoverOAuthProtectedResourceMetadata
77129
>;
130+
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
131+
typeof discoverScopes
132+
>;
78133

79134
const sessionStorageMock = {
80135
getItem: jest.fn(),
@@ -103,9 +158,15 @@ describe("AuthDebugger", () => {
103158
// Suppress console errors in tests to avoid JSDOM navigation noise
104159
jest.spyOn(console, "error").mockImplementation(() => {});
105160

106-
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
107-
mockOAuthMetadata,
108-
);
161+
// Set default mock behaviors with complete OAuth metadata
162+
mockDiscoverAuthorizationServerMetadata.mockResolvedValue({
163+
issuer: "https://oauth.example.com",
164+
authorization_endpoint: "https://oauth.example.com/authorize",
165+
token_endpoint: "https://oauth.example.com/token",
166+
response_types_supported: ["code"],
167+
grant_types_supported: ["authorization_code"],
168+
scopes_supported: ["read", "write"],
169+
});
109170
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
110171
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
111172
new Error("No protected resource metadata found"),
@@ -427,7 +488,24 @@ describe("AuthDebugger", () => {
427488
});
428489
});
429490

430-
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
491+
it("should include scope in authorization URL when scopes_supported is not present", async () => {
492+
const updateAuthState =
493+
await setupAuthorizationUrlTest(mockOAuthMetadata);
494+
495+
// Wait for the updateAuthState to be called
496+
await waitFor(() => {
497+
expect(updateAuthState).toHaveBeenCalledWith(
498+
expect.objectContaining({
499+
authorizationUrl: expect.stringContaining("scope="),
500+
}),
501+
);
502+
});
503+
});
504+
505+
it("should omit scope from authorization URL when discoverScopes returns undefined", async () => {
506+
// Mock discoverScopes to return undefined (no scopes available)
507+
mockDiscoverScopes.mockResolvedValueOnce(undefined);
508+
431509
const updateAuthState =
432510
await setupAuthorizationUrlTest(mockOAuthMetadata);
433511

client/src/lib/__tests__/auth.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { discoverScopes } from "../auth";
2+
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
3+
4+
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
5+
discoverAuthorizationServerMetadata: jest.fn(),
6+
}));
7+
8+
const mockDiscoverAuth =
9+
discoverAuthorizationServerMetadata as jest.MockedFunction<
10+
typeof discoverAuthorizationServerMetadata
11+
>;
12+
13+
const baseMetadata = {
14+
issuer: "https://test.com",
15+
authorization_endpoint: "https://test.com/authorize",
16+
token_endpoint: "https://test.com/token",
17+
response_types_supported: ["code"],
18+
grant_types_supported: ["authorization_code"],
19+
scopes_supported: ["read", "write"],
20+
};
21+
22+
describe("discoverScopes", () => {
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
});
26+
27+
const testCases = [
28+
{
29+
name: "returns joined scopes from OAuth metadata",
30+
mockResolves: baseMetadata,
31+
serverUrl: "https://example.com",
32+
expected: "read write",
33+
expectedCallUrl: "https://example.com/",
34+
},
35+
{
36+
name: "prefers resource metadata over OAuth metadata",
37+
mockResolves: baseMetadata,
38+
serverUrl: "https://example.com",
39+
resourceMetadata: {
40+
resource: "https://example.com",
41+
scopes_supported: ["admin", "full"],
42+
},
43+
expected: "admin full",
44+
},
45+
{
46+
name: "falls back to OAuth when resource has empty scopes",
47+
mockResolves: baseMetadata,
48+
serverUrl: "https://example.com",
49+
resourceMetadata: {
50+
resource: "https://example.com",
51+
scopes_supported: [],
52+
},
53+
expected: "read write",
54+
},
55+
{
56+
name: "normalizes URL with port and path",
57+
mockResolves: baseMetadata,
58+
serverUrl: "https://example.com:8080/some/path",
59+
expected: "read write",
60+
expectedCallUrl: "https://example.com:8080/",
61+
},
62+
{
63+
name: "normalizes URL with trailing slash",
64+
mockResolves: baseMetadata,
65+
serverUrl: "https://example.com/",
66+
expected: "read write",
67+
expectedCallUrl: "https://example.com/",
68+
},
69+
{
70+
name: "handles single scope",
71+
mockResolves: { ...baseMetadata, scopes_supported: ["admin"] },
72+
serverUrl: "https://example.com",
73+
expected: "admin",
74+
},
75+
{
76+
name: "prefers resource metadata even with fewer scopes",
77+
mockResolves: {
78+
...baseMetadata,
79+
scopes_supported: ["read", "write", "admin", "full"],
80+
},
81+
serverUrl: "https://example.com",
82+
resourceMetadata: {
83+
resource: "https://example.com",
84+
scopes_supported: ["read"],
85+
},
86+
expected: "read",
87+
},
88+
];
89+
90+
const undefinedCases = [
91+
{
92+
name: "returns undefined when OAuth discovery fails",
93+
mockRejects: new Error("Discovery failed"),
94+
serverUrl: "https://example.com",
95+
},
96+
{
97+
name: "returns undefined when OAuth has no scopes",
98+
mockResolves: { ...baseMetadata, scopes_supported: [] },
99+
serverUrl: "https://example.com",
100+
},
101+
{
102+
name: "returns undefined when scopes_supported missing",
103+
mockResolves: (() => {
104+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
105+
const { scopes_supported, ...rest } = baseMetadata;
106+
return rest;
107+
})(),
108+
serverUrl: "https://example.com",
109+
},
110+
{
111+
name: "returns undefined with resource metadata but OAuth fails",
112+
mockRejects: new Error("No OAuth metadata"),
113+
serverUrl: "https://example.com",
114+
resourceMetadata: {
115+
resource: "https://example.com",
116+
scopes_supported: ["read", "write"],
117+
},
118+
},
119+
];
120+
121+
test.each(testCases)(
122+
"$name",
123+
async ({
124+
mockResolves,
125+
serverUrl,
126+
resourceMetadata,
127+
expected,
128+
expectedCallUrl,
129+
}) => {
130+
mockDiscoverAuth.mockResolvedValue(mockResolves);
131+
132+
const result = await discoverScopes(serverUrl, resourceMetadata);
133+
134+
expect(result).toBe(expected);
135+
if (expectedCallUrl) {
136+
expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl));
137+
}
138+
},
139+
);
140+
141+
test.each(undefinedCases)(
142+
"$name",
143+
async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => {
144+
if (mockRejects) {
145+
mockDiscoverAuth.mockRejectedValue(mockRejects);
146+
} else {
147+
mockDiscoverAuth.mockResolvedValue(mockResolves);
148+
}
149+
150+
const result = await discoverScopes(serverUrl, resourceMetadata);
151+
152+
expect(result).toBeUndefined();
153+
},
154+
);
155+
});

0 commit comments

Comments
 (0)