Merge branch 'main' into feature/add-header-support

This commit is contained in:
Ola Hungerford 2025-08-27 20:33:24 -07:00 • committed by GitHub
commit e5d0f34981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2177 additions and 692 deletions

View file

@ -18,10 +18,6 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
) &&
(
github.actor == 'ihrpr' ||
github.actor == 'olaservo'
) )
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -29,6 +25,7 @@ jobs:
pull-requests: read pull-requests: read
issues: read issues: read
id-token: write id-token: write
actions: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -41,24 +38,16 @@ jobs:
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) # Allow Claude to read CI results on PRs
# model: "claude-opus-4-20250514" additional_permissions: |
actions: read
# Optional: Customize the trigger phrase (default: @claude) # Trigger when assigned to an issue
# trigger_phrase: "/claude" assignee_trigger: "claude"
# Optional: Trigger when specific user is assigned to an issue # Allow Claude to run bash
# assignee_trigger: "claude-bot" # This should be safe given the repo is already public
allowed_tools: "Bash"
# Optional: Allow Claude to run specific commands custom_instructions: |
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block.
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
npx lint-staged
git update-index --again

View file

@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:24-slim AS builder FROM node:current-alpine3.22 AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@ -49,4 +49,4 @@ ENV SERVER_PORT=6277
EXPOSE ${CLIENT_PORT} ${SERVER_PORT} EXPOSE ${CLIENT_PORT} ${SERVER_PORT}
# Use ENTRYPOINT with CMD for arguments # Use ENTRYPOINT with CMD for arguments
ENTRYPOINT ["npm", "start"] ENTRYPOINT ["npm", "start"]

View file

@ -29,6 +29,14 @@ npx @modelcontextprotocol/inspector
The server will start up and the UI will be accessible at `http://localhost:6274`. The server will start up and the UI will be accessible at `http://localhost:6274`.
### Docker Container
You can also start it in a Docker container with the following command:
```bash
docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest
```
### From an MCP server repository ### From an MCP server repository
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
@ -166,6 +174,16 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER
DANGEROUSLY_OMIT_AUTH=true npm start DANGEROUSLY_OMIT_AUTH=true npm start
``` ```
---
**🚨 WARNING 🚨**
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.
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)
---
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
```bash ```bash

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-cli", "name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.4", "version": "0.16.5",
"description": "CLI for the Model Context Protocol inspector", "description": "CLI for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@ -21,7 +21,7 @@
}, },
"devDependencies": {}, "devDependencies": {},
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.17.2", "@modelcontextprotocol/sdk": "^1.17.3",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
} }

View file

@ -33,8 +33,8 @@ function createStdioTransport(options: TransportOptions): Transport {
const defaultEnv = getDefaultEnvironment(); const defaultEnv = getDefaultEnvironment();
const env: Record<string, string> = { const env: Record<string, string> = {
...processEnv,
...defaultEnv, ...defaultEnv,
...processEnv,
}; };
const { cmd: actualCommand, args: actualArgs } = findActualExecutable( const { cmd: actualCommand, args: actualArgs } = findActualExecutable(

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.16.4", "version": "0.16.5",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@ -25,7 +25,7 @@
"cleanup:e2e": "node e2e/global-teardown.js" "cleanup:e2e": "node e2e/global-teardown.js"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.17.2", "@modelcontextprotocol/sdk": "^1.17.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",

View file

@ -34,7 +34,6 @@ import {
useDraggablePane, useDraggablePane,
useDraggableSidebar, useDraggableSidebar,
} from "./lib/hooks/useDraggablePane"; } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -106,9 +105,6 @@ const App = () => {
>(getInitialTransportType); >(getInitialTransportType);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug"); const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]); const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
>([]);
const [roots, setRoots] = useState<Root[]>([]); const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({}); const [env, setEnv] = useState<Record<string, string>>({});
@ -224,12 +220,6 @@ const App = () => {
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
}, },
onStdErrNotification: (notification) => {
setStdErrNotifications((prev) => [
...prev,
notification as StdErrNotification,
]);
},
onPendingRequest: (request, resolve, reject) => { onPendingRequest: (request, resolve, reject) => {
setPendingSampleRequests((prev) => [ setPendingSampleRequests((prev) => [
...prev, ...prev,
@ -757,10 +747,6 @@ const App = () => {
setLogLevel(level); setLogLevel(level);
}; };
const clearStdErrNotifications = () => {
setStdErrNotifications([]);
};
const AuthDebuggerWrapper = () => ( const AuthDebuggerWrapper = () => (
<TabsContent value="auth"> <TabsContent value="auth">
<AuthDebugger <AuthDebugger
@ -829,11 +815,9 @@ const App = () => {
setOauthScope={setOauthScope} setOauthScope={setOauthScope}
onConnect={connectMcpServer} onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer} onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel} logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest} sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false} loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/> />
<div <div
onMouseDown={handleSidebarDragStart} onMouseDown={handleSidebarDragStart}

View file

@ -6,6 +6,7 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine"; import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { SESSION_KEYS } from "../lib/constants"; import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
export interface AuthDebuggerProps { export interface AuthDebuggerProps {
serverUrl: string; serverUrl: string;
@ -163,6 +164,23 @@ const AuthDebugger = ({
currentState.oauthStep === "authorization_code" && currentState.oauthStep === "authorization_code" &&
currentState.authorizationUrl currentState.authorizationUrl
) { ) {
// Validate the URL before redirecting
try {
validateRedirectUrl(currentState.authorizationUrl);
} catch (error) {
updateAuthState({
...currentState,
isInitiatingAuth: false,
latestError:
error instanceof Error ? error : new Error(String(error)),
statusMessage: {
type: "error",
message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
},
});
return;
}
// Store the current auth state before redirecting // Store the current auth state before redirecting
sessionStorage.setItem( sessionStorage.setItem(
SESSION_KEYS.AUTH_DEBUGGER_STATE, SESSION_KEYS.AUTH_DEBUGGER_STATE,

View file

@ -46,6 +46,17 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
if (storedState) { if (storedState) {
try { try {
restoredState = JSON.parse(storedState); restoredState = JSON.parse(storedState);
if (restoredState && typeof restoredState.resource === "string") {
restoredState.resource = new URL(restoredState.resource);
}
if (
restoredState &&
typeof restoredState.authorizationUrl === "string"
) {
restoredState.authorizationUrl = new URL(
restoredState.authorizationUrl,
);
}
// Clean up the stored state // Clean up the stored state
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
} catch (e) { } catch (e) {

View file

@ -4,6 +4,8 @@ import { Button } from "./ui/button";
import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { validateRedirectUrl } from "@/utils/urlValidation";
import { useToast } from "@/lib/hooks/useToast";
interface OAuthStepProps { interface OAuthStepProps {
label: string; label: string;
@ -71,6 +73,7 @@ export const OAuthFlowProgress = ({
updateAuthState, updateAuthState,
proceedToNextStep, proceedToNextStep,
}: OAuthFlowProgressProps) => { }: OAuthFlowProgressProps) => {
const { toast } = useToast();
const provider = useMemo( const provider = useMemo(
() => new DebugInspectorOAuthClientProvider(serverUrl), () => new DebugInspectorOAuthClientProvider(serverUrl),
[serverUrl], [serverUrl],
@ -239,16 +242,32 @@ export const OAuthFlowProgress = ({
<p className="text-xs break-all"> <p className="text-xs break-all">
{authState.authorizationUrl} {authState.authorizationUrl}
</p> </p>
<a <button
href={authState.authorizationUrl} onClick={() => {
target="_blank" try {
rel="noopener noreferrer" validateRedirectUrl(authState.authorizationUrl!);
window.open(
authState.authorizationUrl!,
"_blank",
"noopener noreferrer",
);
} catch (error) {
toast({
title: "Invalid URL",
description:
error instanceof Error
? error.message
: "The authorization URL is not valid",
variant: "destructive",
});
}
}}
className="flex items-center text-blue-500 hover:text-blue-700" className="flex items-center text-blue-500 hover:text-blue-700"
aria-label="Open authorization URL in new tab" aria-label="Open authorization URL in new tab"
title="Open authorization URL" title="Open authorization URL"
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </button>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Click the link to authorize in your browser. After Click the link to authorize in your browser. After
@ -354,7 +373,21 @@ export const OAuthFlowProgress = ({
authState.authorizationUrl && ( authState.authorizationUrl && (
<Button <Button
variant="outline" variant="outline"
onClick={() => window.open(authState.authorizationUrl!, "_blank")} onClick={() => {
try {
validateRedirectUrl(authState.authorizationUrl!);
window.open(authState.authorizationUrl!, "_blank");
} catch (error) {
toast({
title: "Invalid URL",
description:
error instanceof Error
? error.message
: "The authorization URL is not valid",
variant: "destructive",
});
}
}}
> >
Open in New Tab Open in New Tab
</Button> </Button>

View file

@ -24,7 +24,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes";
import { import {
LoggingLevel, LoggingLevel,
LoggingLevelSchema, LoggingLevelSchema,
@ -62,8 +61,6 @@ interface SidebarProps {
setOauthScope: (scope: string) => void; setOauthScope: (scope: string) => void;
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void; onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
clearStdErrNotifications: () => void;
logLevel: LoggingLevel; logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void; sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean; loggingSupported: boolean;
@ -93,8 +90,6 @@ const Sidebar = ({
setOauthScope, setOauthScope,
onConnect, onConnect,
onDisconnect, onDisconnect,
stdErrNotifications,
clearStdErrNotifications,
logLevel, logLevel,
sendLogLevelRequest, sendLogLevelRequest,
loggingSupported, loggingSupported,
@ -268,6 +263,7 @@ const Sidebar = ({
placeholder="Command" placeholder="Command"
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
onBlur={(e) => setCommand(e.target.value.trim())}
className="font-mono" className="font-mono"
/> />
</div> </div>
@ -760,36 +756,6 @@ const Sidebar = ({
</Select> </Select>
</div> </div>
)} )}
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<Button
variant="outline"
size="sm"
onClick={clearStdErrNotifications}
className="h-8 px-2"
>
Clear
</Button>
</div>
<div className="mt-2 max-h-80 overflow-y-auto">
{stdErrNotifications.map((notification, index) => (
<div
key={index}
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
>
{notification.params.content}
</div>
))}
</div>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -25,6 +25,7 @@ const mockOAuthMetadata = {
token_endpoint: "https://oauth.example.com/token", token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"], response_types_supported: ["code"],
grant_types_supported: ["authorization_code"], grant_types_supported: ["authorization_code"],
scopes_supported: ["read", "write"],
}; };
const mockOAuthClientInfo = { const mockOAuthClientInfo = {
@ -56,6 +57,57 @@ import {
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
// Mock local auth module
jest.mock("@/lib/auth", () => ({
DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
clear: jest.fn().mockImplementation(() => {
// Mock the real clear() behavior which removes items from sessionStorage
sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens");
sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info");
sessionStorage.removeItem(
"[https://example.com/mcp] mcp_server_metadata",
);
}),
redirectUrl: "http://localhost:3000/oauth/callback/debug",
clientMetadata: {
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector",
},
clientInformation: jest.fn().mockImplementation(async () => {
const serverUrl = "https://example.com/mcp";
const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`;
const preregisteredData = sessionStorage.getItem(preregisteredKey);
if (preregisteredData) {
return JSON.parse(preregisteredData);
}
const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`;
const dynamicData = sessionStorage.getItem(dynamicKey);
if (dynamicData) {
return JSON.parse(dynamicData);
}
return undefined;
}),
saveClientInformation: jest.fn().mockImplementation((clientInfo) => {
const serverUrl = "https://example.com/mcp";
const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`;
sessionStorage.setItem(key, JSON.stringify(clientInfo));
}),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
saveServerMetadata: jest.fn(),
getServerMetadata: jest.fn(),
})),
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
}));
import { discoverScopes } from "@/lib/auth";
// Type the mocked functions properly // Type the mocked functions properly
const mockDiscoverAuthorizationServerMetadata = const mockDiscoverAuthorizationServerMetadata =
discoverAuthorizationServerMetadata as jest.MockedFunction< discoverAuthorizationServerMetadata as jest.MockedFunction<
@ -75,6 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
discoverOAuthProtectedResourceMetadata as jest.MockedFunction< discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
typeof discoverOAuthProtectedResourceMetadata typeof discoverOAuthProtectedResourceMetadata
>; >;
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
typeof discoverScopes
>;
const sessionStorageMock = { const sessionStorageMock = {
getItem: jest.fn(), getItem: jest.fn(),
@ -103,9 +158,15 @@ describe("AuthDebugger", () => {
// Suppress console errors in tests to avoid JSDOM navigation noise // Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {}); jest.spyOn(console, "error").mockImplementation(() => {});
mockDiscoverAuthorizationServerMetadata.mockResolvedValue( // Set default mock behaviors with complete OAuth metadata
mockOAuthMetadata, mockDiscoverAuthorizationServerMetadata.mockResolvedValue({
); issuer: "https://oauth.example.com",
authorization_endpoint: "https://oauth.example.com/authorize",
token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
scopes_supported: ["read", "write"],
});
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
new Error("No protected resource metadata found"), new Error("No protected resource metadata found"),
@ -427,7 +488,24 @@ describe("AuthDebugger", () => {
}); });
}); });
it("should not include scope in authorization URL when scopes_supported is not present", async () => { it("should include scope in authorization URL when scopes_supported is not present", async () => {
const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
}),
);
});
});
it("should omit scope from authorization URL when discoverScopes returns undefined", async () => {
// Mock discoverScopes to return undefined (no scopes available)
mockDiscoverScopes.mockResolvedValueOnce(undefined);
const updateAuthState = const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata); await setupAuthorizationUrlTest(mockOAuthMetadata);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
import { discoverScopes } from "../auth";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverAuthorizationServerMetadata: jest.fn(),
}));
const mockDiscoverAuth =
discoverAuthorizationServerMetadata as jest.MockedFunction<
typeof discoverAuthorizationServerMetadata
>;
const baseMetadata = {
issuer: "https://test.com",
authorization_endpoint: "https://test.com/authorize",
token_endpoint: "https://test.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
scopes_supported: ["read", "write"],
};
describe("discoverScopes", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const testCases = [
{
name: "returns joined scopes from OAuth metadata",
mockResolves: baseMetadata,
serverUrl: "https://example.com",
expected: "read write",
expectedCallUrl: "https://example.com/",
},
{
name: "prefers resource metadata over OAuth metadata",
mockResolves: baseMetadata,
serverUrl: "https://example.com",
resourceMetadata: {
resource: "https://example.com",
scopes_supported: ["admin", "full"],
},
expected: "admin full",
},
{
name: "falls back to OAuth when resource has empty scopes",
mockResolves: baseMetadata,
serverUrl: "https://example.com",
resourceMetadata: {
resource: "https://example.com",
scopes_supported: [],
},
expected: "read write",
},
{
name: "normalizes URL with port and path",
mockResolves: baseMetadata,
serverUrl: "https://example.com:8080/some/path",
expected: "read write",
expectedCallUrl: "https://example.com:8080/",
},
{
name: "normalizes URL with trailing slash",
mockResolves: baseMetadata,
serverUrl: "https://example.com/",
expected: "read write",
expectedCallUrl: "https://example.com/",
},
{
name: "handles single scope",
mockResolves: { ...baseMetadata, scopes_supported: ["admin"] },
serverUrl: "https://example.com",
expected: "admin",
},
{
name: "prefers resource metadata even with fewer scopes",
mockResolves: {
...baseMetadata,
scopes_supported: ["read", "write", "admin", "full"],
},
serverUrl: "https://example.com",
resourceMetadata: {
resource: "https://example.com",
scopes_supported: ["read"],
},
expected: "read",
},
];
const undefinedCases = [
{
name: "returns undefined when OAuth discovery fails",
mockRejects: new Error("Discovery failed"),
serverUrl: "https://example.com",
},
{
name: "returns undefined when OAuth has no scopes",
mockResolves: { ...baseMetadata, scopes_supported: [] },
serverUrl: "https://example.com",
},
{
name: "returns undefined when scopes_supported missing",
mockResolves: (() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { scopes_supported, ...rest } = baseMetadata;
return rest;
})(),
serverUrl: "https://example.com",
},
{
name: "returns undefined with resource metadata but OAuth fails",
mockRejects: new Error("No OAuth metadata"),
serverUrl: "https://example.com",
resourceMetadata: {
resource: "https://example.com",
scopes_supported: ["read", "write"],
},
},
];
test.each(testCases)(
"$name",
async ({
mockResolves,
serverUrl,
resourceMetadata,
expected,
expectedCallUrl,
}) => {
mockDiscoverAuth.mockResolvedValue(mockResolves);
const result = await discoverScopes(serverUrl, resourceMetadata);
expect(result).toBe(expected);
if (expectedCallUrl) {
expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl));
}
},
);
test.each(undefinedCases)(
"$name",
async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => {
if (mockRejects) {
mockDiscoverAuth.mockRejectedValue(mockRejects);
} else {
mockDiscoverAuth.mockResolvedValue(mockResolves);
}
const result = await discoverScopes(serverUrl, resourceMetadata);
expect(result).toBeUndefined();
},
);
});

View file

@ -6,9 +6,45 @@ import {
OAuthTokensSchema, OAuthTokensSchema,
OAuthClientMetadata, OAuthClientMetadata,
OAuthMetadata, OAuthMetadata,
OAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js"; } from "@modelcontextprotocol/sdk/shared/auth.js";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { SESSION_KEYS, getServerSpecificKey } from "./constants";
import { generateOAuthState } from "@/utils/oauthUtils"; import { generateOAuthState } from "@/utils/oauthUtils";
import { validateRedirectUrl } from "@/utils/urlValidation";
/**
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
* @param serverUrl - The MCP server URL
* @param resourceMetadata - Optional resource metadata containing preferred scopes
* @returns Promise resolving to space-separated scope string or undefined
*/
export const discoverScopes = async (
serverUrl: string,
resourceMetadata?: OAuthProtectedResourceMetadata,
): Promise<string | undefined> => {
try {
const metadata = await discoverAuthorizationServerMetadata(
new URL("/", serverUrl),
);
// Prefer resource metadata scopes, but fall back to OAuth metadata if empty
const resourceScopes = resourceMetadata?.scopes_supported;
const oauthScopes = metadata?.scopes_supported;
const scopesSupported =
resourceScopes && resourceScopes.length > 0
? resourceScopes
: oauthScopes;
return scopesSupported && scopesSupported.length > 0
? scopesSupported.join(" ")
: undefined;
} catch (error) {
console.debug("OAuth scope discovery failed:", error);
return undefined;
}
};
export const getClientInformationFromSessionStorage = async ({ export const getClientInformationFromSessionStorage = async ({
serverUrl, serverUrl,
@ -67,10 +103,15 @@ export const clearClientInformationFromSessionStorage = ({
}; };
export class InspectorOAuthClientProvider implements OAuthClientProvider { export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(protected serverUrl: string) { constructor(
protected serverUrl: string,
scope?: string,
) {
this.scope = scope;
// Save the server URL to session storage // Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
} }
scope: string | undefined;
get redirectUrl() { get redirectUrl() {
return window.location.origin + "/oauth/callback"; return window.location.origin + "/oauth/callback";
@ -84,6 +125,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
response_types: ["code"], response_types: ["code"],
client_name: "MCP Inspector", client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector", client_uri: "https://github.com/modelcontextprotocol/inspector",
scope: this.scope ?? "",
}; };
} }
@ -110,10 +152,17 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
saveClientInformation(clientInformation: OAuthClientInformation) { saveClientInformation(clientInformation: OAuthClientInformation) {
// Remove client_secret before storing (not needed after initial OAuth flow)
const safeInfo = Object.fromEntries(
Object.entries(clientInformation).filter(
([key]) => key !== "client_secret",
),
) as OAuthClientInformation;
// Save the dynamically registered client information to session storage // Save the dynamically registered client information to session storage
saveClientInformationToSessionStorage({ saveClientInformationToSessionStorage({
serverUrl: this.serverUrl, serverUrl: this.serverUrl,
clientInformation, clientInformation: safeInfo,
isPreregistered: false, isPreregistered: false,
}); });
} }
@ -134,12 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
redirectToAuthorization(authorizationUrl: URL) { redirectToAuthorization(authorizationUrl: URL) {
if ( // Validate the URL using the shared utility
authorizationUrl.protocol !== "http:" && validateRedirectUrl(authorizationUrl.href);
authorizationUrl.protocol !== "https:"
) {
throw new Error("Authorization URL must be HTTP or HTTPS");
}
window.location.href = authorizationUrl.href; window.location.href = authorizationUrl.href;
} }

View file

@ -3,11 +3,16 @@ import { useConnection } from "../useConnection";
import { z } from "zod"; import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js"; import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants"; import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; import {
SSEClientTransportOptions,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import { import {
ElicitResult, ElicitResult,
ElicitRequest, ElicitRequest,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { discoverScopes } from "../../auth";
// Mock fetch // Mock fetch
global.fetch = jest.fn().mockResolvedValue({ global.fetch = jest.fn().mockResolvedValue({
@ -53,14 +58,27 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: jest.fn().mockImplementation(() => mockClient), Client: jest.fn().mockImplementation(() => mockClient),
})); }));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => {
SSEClientTransport: jest.fn((url, options) => { // Minimal mock class that supports instanceof checks
mockSSETransport.url = url; class SseError extends Error {
mockSSETransport.options = options; code: number;
return mockSSETransport; event: ErrorEvent;
}), constructor(code: number, message: string, event: ErrorEvent) {
SseError: jest.fn(), super(message);
})); this.code = code;
this.event = event;
}
}
return {
SSEClientTransport: jest.fn((url, options) => {
mockSSETransport.url = url;
mockSSETransport.options = options;
return mockSSETransport;
}),
SseError,
};
});
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: jest.fn((url, options) => { StreamableHTTPClientTransport: jest.fn((url, options) => {
@ -85,11 +103,18 @@ jest.mock("@/lib/hooks/useToast", () => ({
jest.mock("../../auth", () => ({ jest.mock("../../auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }), tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
redirectUrl: "http://localhost:3000/oauth/callback",
})), })),
clearClientInformationFromSessionStorage: jest.fn(), clearClientInformationFromSessionStorage: jest.fn(),
saveClientInformationToSessionStorage: jest.fn(), saveClientInformationToSessionStorage: jest.fn(),
discoverScopes: jest.fn(),
})); }));
const mockAuth = auth as jest.MockedFunction<typeof auth>;
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
typeof discoverScopes
>;
describe("useConnection", () => { describe("useConnection", () => {
const defaultProps = { const defaultProps = {
transportType: "sse" as const, transportType: "sse" as const,
@ -714,4 +739,267 @@ describe("useConnection", () => {
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
}); });
}); });
describe("OAuth Error Handling with Scope Discovery", () => {
beforeEach(() => {
jest.clearAllMocks();
mockAuth.mockResolvedValue("AUTHORIZED");
mockDiscoverScopes.mockResolvedValue(undefined);
});
const setup401Error = () => {
const mockErrorEvent = new ErrorEvent("error", {
message: "Mock error event",
});
mockClient.connect.mockRejectedValueOnce(
new SseError(401, "Unauthorized", mockErrorEvent),
);
};
const attemptConnection = async (props = defaultProps) => {
const { result } = renderHook(() => useConnection(props));
await act(async () => {
try {
await result.current.connect();
} catch {
// Expected error from auth handling
}
});
};
const testCases = [
[
"discovers and includes scopes in auth call",
{
discoveredScope: "read write admin",
oauthScope: undefined,
expectScopeCall: true,
expectedAuthScope: "read write admin",
authResult: "AUTHORIZED",
},
],
[
"handles scope discovery failure gracefully",
{
discoveredScope: undefined,
oauthScope: undefined,
expectScopeCall: true,
expectedAuthScope: undefined,
authResult: "AUTHORIZED",
},
],
[
"uses manual oauthScope override instead of discovered scopes",
{
discoveredScope: "discovered:scope",
oauthScope: "manual:scope",
expectScopeCall: false,
expectedAuthScope: "manual:scope",
authResult: "AUTHORIZED",
},
],
[
"triggers scope discovery when oauthScope is whitespace",
{
discoveredScope: "discovered:scope",
oauthScope: " ",
expectScopeCall: true,
expectedAuthScope: "discovered:scope",
authResult: "AUTHORIZED",
},
],
[
"handles auth failure after scope discovery",
{
discoveredScope: "read write",
oauthScope: undefined,
expectScopeCall: true,
expectedAuthScope: "read write",
authResult: "UNAUTHORIZED",
},
],
] as const;
test.each(testCases)(
"should %s",
async (
_,
{
discoveredScope,
oauthScope,
expectScopeCall,
expectedAuthScope,
authResult = "AUTHORIZED",
},
) => {
mockDiscoverScopes.mockResolvedValue(discoveredScope);
mockAuth.mockResolvedValue(authResult as never);
setup401Error();
const props =
oauthScope !== undefined
? { ...defaultProps, oauthScope }
: defaultProps;
await attemptConnection(props);
if (expectScopeCall) {
expect(mockDiscoverScopes).toHaveBeenCalledWith(
defaultProps.sseUrl,
undefined,
);
} else {
expect(mockDiscoverScopes).not.toHaveBeenCalled();
}
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
serverUrl: defaultProps.sseUrl,
scope: expectedAuthScope,
});
},
);
it("should handle slow scope discovery gracefully", async () => {
mockDiscoverScopes.mockImplementation(
() =>
new Promise((resolve) => setTimeout(() => resolve(undefined), 100)),
);
setup401Error();
await attemptConnection();
expect(mockDiscoverScopes).toHaveBeenCalledWith(
defaultProps.sseUrl,
undefined,
);
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
serverUrl: defaultProps.sseUrl,
scope: undefined,
});
});
});
describe("MCP_PROXY_FULL_ADDRESS Configuration", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock transport objects
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});
test("sends proxyFullAddress query parameter for stdio transport when configured", async () => {
const propsWithProxyFullAddress = {
...defaultProps,
transportType: "stdio" as const,
command: "test-command",
args: "test-args",
env: {},
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "https://example.com/inspector/mcp_proxy",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithProxyFullAddress),
);
await act(async () => {
await result.current.connect();
});
// Check that the URL contains the proxyFullAddress parameter
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
"https://example.com/inspector/mcp_proxy",
);
});
test("sends proxyFullAddress query parameter for sse transport when configured", async () => {
const propsWithProxyFullAddress = {
...defaultProps,
transportType: "sse" as const,
sseUrl: "http://localhost:8080",
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "https://example.com/inspector/mcp_proxy",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithProxyFullAddress),
);
await act(async () => {
await result.current.connect();
});
// Check that the URL contains the proxyFullAddress parameter
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
"https://example.com/inspector/mcp_proxy",
);
});
test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => {
const propsWithEmptyProxy = {
...defaultProps,
transportType: "stdio" as const,
command: "test-command",
args: "test-args",
env: {},
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "",
},
},
};
const { result } = renderHook(() => useConnection(propsWithEmptyProxy));
await act(async () => {
await result.current.connect();
});
// Check that the URL does not contain the proxyFullAddress parameter
expect(
mockSSETransport.url?.searchParams.get("proxyFullAddress"),
).toBeNull();
});
test("does not send proxyFullAddress parameter for streamable-http transport", async () => {
const propsWithStreamableHttp = {
...defaultProps,
transportType: "streamable-http" as const,
sseUrl: "http://localhost:8080",
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "https://example.com/inspector/mcp_proxy",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithStreamableHttp),
);
await act(async () => {
await result.current.connect();
});
// Check that streamable-http transport doesn't get proxyFullAddress parameter
expect(
mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"),
).toBeNull();
});
});
}); });

View file

@ -36,12 +36,16 @@ import { useEffect, useState } from "react";
import { useToast } from "@/lib/hooks/useToast"; import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod"; import { z } from "zod";
import { ConnectionStatus } from "../constants"; import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { Notification } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import {
auth,
discoverOAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { import {
clearClientInformationFromSessionStorage, clearClientInformationFromSessionStorage,
InspectorOAuthClientProvider, InspectorOAuthClientProvider,
saveClientInformationToSessionStorage, saveClientInformationToSessionStorage,
discoverScopes,
} from "../auth"; } from "../auth";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { import {
@ -88,7 +92,6 @@ export function useConnection({
oauthScope, oauthScope,
config, config,
onNotification, onNotification,
onStdErrNotification,
onPendingRequest, onPendingRequest,
onElicitationRequest, onElicitationRequest,
getRoots, getRoots,
@ -315,11 +318,27 @@ export function useConnection({
const handleAuthError = async (error: unknown) => { const handleAuthError = async (error: unknown) => {
if (is401Error(error)) { if (is401Error(error)) {
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); let scope = oauthScope?.trim();
if (!scope) {
// Only discover resource metadata when we need to discover scopes
let resourceMetadata;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
new URL("/", sseUrl),
);
} catch {
// Resource metadata is optional, continue without it
}
scope = await discoverScopes(sseUrl, resourceMetadata);
}
const serverAuthProvider = new InspectorOAuthClientProvider(
sseUrl,
scope,
);
const result = await auth(serverAuthProvider, { const result = await auth(serverAuthProvider, {
serverUrl: sseUrl, serverUrl: sseUrl,
scope: oauthScope, scope,
}); });
return result === "AUTHORIZED"; return result === "AUTHORIZED";
} }
@ -390,11 +409,20 @@ export function useConnection({
let mcpProxyServerUrl; let mcpProxyServerUrl;
switch (transportType) { switch (transportType) {
case "stdio": case "stdio": {
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("command", command); mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args); mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS
.value as string;
if (proxyFullAddress) {
mcpProxyServerUrl.searchParams.append(
"proxyFullAddress",
proxyFullAddress,
);
}
transportOptions = { transportOptions = {
authProvider: serverAuthProvider, authProvider: serverAuthProvider,
eventSourceInit: { eventSourceInit: {
@ -412,10 +440,20 @@ export function useConnection({
}, },
}; };
break; break;
}
case "sse": case "sse": {
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl); mcpProxyServerUrl.searchParams.append("url", sseUrl);
const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS
.value as string;
if (proxyFullAddressSSE) {
mcpProxyServerUrl.searchParams.append(
"proxyFullAddress",
proxyFullAddressSSE,
);
}
transportOptions = { transportOptions = {
eventSourceInit: { eventSourceInit: {
fetch: ( fetch: (
@ -432,6 +470,7 @@ export function useConnection({
}, },
}; };
break; break;
}
case "streamable-http": case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
@ -485,13 +524,6 @@ export function useConnection({
}; };
} }
if (onStdErrNotification) {
client.setNotificationHandler(
StdErrNotificationSchema,
onStdErrNotification,
);
}
let capabilities; let capabilities;
try { try {
const transport = const transport =

View file

@ -5,18 +5,8 @@ import {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod"; import { z } from "zod";
export const StdErrNotificationSchema = BaseNotificationSchema.extend({
method: z.literal("notifications/stderr"),
params: z.object({
content: z.string(),
}),
});
export const NotificationSchema = ClientNotificationSchema.or( export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema, ServerNotificationSchema,
) ).or(BaseNotificationSchema);
.or(ServerNotificationSchema)
.or(BaseNotificationSchema);
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>; export type Notification = z.infer<typeof NotificationSchema>;

View file

@ -1,5 +1,5 @@
import { OAuthStep, AuthDebuggerState } from "./auth-types"; import { OAuthStep, AuthDebuggerState } from "./auth-types";
import { DebugInspectorOAuthClientProvider } from "./auth"; import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth";
import { import {
discoverAuthorizationServerMetadata, discoverAuthorizationServerMetadata,
registerClient, registerClient,
@ -113,10 +113,10 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
const metadata = context.state.oauthMetadata!; const metadata = context.state.oauthMetadata!;
const clientInformation = context.state.oauthClientInfo!; const clientInformation = context.state.oauthClientInfo!;
let scope: string | undefined = undefined; const scope = await discoverScopes(
if (metadata.scopes_supported) { context.serverUrl,
scope = metadata.scopes_supported.join(" "); context.state.resourceMetadata ?? undefined,
} );
const { authorizationUrl, codeVerifier } = await startAuthorization( const { authorizationUrl, codeVerifier } = await startAuthorization(
context.serverUrl, context.serverUrl,
@ -177,7 +177,11 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
authorizationCode: context.state.authorizationCode, authorizationCode: context.state.authorizationCode,
codeVerifier, codeVerifier,
redirectUri: context.provider.redirectUrl, redirectUri: context.provider.redirectUrl,
resource: context.state.resource ?? undefined, resource: context.state.resource
? context.state.resource instanceof URL
? context.state.resource
: new URL(context.state.resource)
: undefined,
}); });
context.provider.saveTokens(tokens); context.provider.saveTokens(tokens);

Some files were not shown because too many files have changed in this diff Show more