feat: support manual entry of OAuth client information

This commit is contained in:
Xiao Yijun 2025-05-16 16:03:01 +08:00
commit b8120d9f85
No known key found for this signature in database
GPG key ID: 6F648FC1262DB420
6 changed files with 242 additions and 24 deletions

View file

@ -116,6 +116,18 @@ const App = () => {
return localStorage.getItem("lastHeaderName") || ""; return localStorage.getItem("lastHeaderName") || "";
}); });
const [oauthClientId, setOauthClientId] = useState<string>(() => {
return localStorage.getItem("lastOauthClientId") || "";
});
const [oauthScope, setOauthScope] = useState<string>(() => {
return localStorage.getItem("lastOauthScope") || "";
});
const [oauthResource, setOauthResource] = useState<string>(() => {
return localStorage.getItem("lastOauthResource") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState< const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array< Array<
PendingRequest & { PendingRequest & {
@ -184,6 +196,9 @@ const App = () => {
env, env,
bearerToken, bearerToken,
headerName, headerName,
oauthClientId,
oauthScope,
oauthResource,
config, config,
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
@ -227,6 +242,18 @@ const App = () => {
localStorage.setItem("lastHeaderName", headerName); localStorage.setItem("lastHeaderName", headerName);
}, [headerName]); }, [headerName]);
useEffect(() => {
localStorage.setItem("lastOauthClientId", oauthClientId);
}, [oauthClientId]);
useEffect(() => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);
useEffect(() => {
localStorage.setItem("lastOauthResource", oauthResource);
}, [oauthResource]);
useEffect(() => { useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]); }, [config]);
@ -650,6 +677,12 @@ const App = () => {
setBearerToken={setBearerToken} setBearerToken={setBearerToken}
headerName={headerName} headerName={headerName}
setHeaderName={setHeaderName} setHeaderName={setHeaderName}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
oauthResource={oauthResource}
setOauthResource={setOauthResource}
onConnect={connectMcpServer} onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer} onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications} stdErrNotifications={stdErrNotifications}

View file

@ -56,6 +56,12 @@ interface SidebarProps {
setBearerToken: (token: string) => void; setBearerToken: (token: string) => void;
headerName?: string; headerName?: string;
setHeaderName?: (name: string) => void; setHeaderName?: (name: string) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthScope: string;
setOauthScope: (scope: string) => void;
oauthResource: string;
setOauthResource: (resource: string) => void;
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void; onDisconnect: () => void;
stdErrNotifications: StdErrNotification[]; stdErrNotifications: StdErrNotification[];
@ -83,6 +89,12 @@ const Sidebar = ({
setBearerToken, setBearerToken,
headerName, headerName,
setHeaderName, setHeaderName,
oauthClientId,
setOauthClientId,
oauthScope,
setOauthScope,
oauthResource,
setOauthResource,
onConnect, onConnect,
onDisconnect, onDisconnect,
stdErrNotifications, stdErrNotifications,
@ -98,6 +110,7 @@ const Sidebar = ({
const [showBearerToken, setShowBearerToken] = useState(false); const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set()); const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [showOauthConfig, setShowOauthConfig] = useState(false);
const [copiedServerEntry, setCopiedServerEntry] = useState(false); const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false); const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
@ -353,6 +366,60 @@ const Sidebar = ({
</div> </div>
)} )}
</div> </div>
{/* OAuth Configuration */}
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowOauthConfig(!showOauthConfig)}
className="flex items-center w-full"
data-testid="oauth-config-button"
aria-expanded={showOauthConfig}
>
{showOauthConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
OAuth Configuration
</Button>
{showOauthConfig && (
<div className="space-y-2">
<label className="text-sm font-medium">Client ID</label>
<Input
placeholder="Client ID"
onChange={(e) => setOauthClientId(e.target.value)}
value={oauthClientId}
data-testid="oauth-client-id-input"
className="font-mono"
/>
<label className="text-sm font-medium">
Redirect URL (auto-populated)
</label>
<Input
readOnly
placeholder="Redirect URL"
value={window.location.origin + "/oauth/callback"}
className="font-mono"
/>
<label className="text-sm font-medium">Scope</label>
<Input
placeholder="Scope (space-separated)"
onChange={(e) => setOauthScope(e.target.value)}
value={oauthScope}
data-testid="oauth-scope-input"
className="font-mono"
/>
<label className="text-sm font-medium">Resource</label>
<Input
placeholder="Resource"
onChange={(e) => setOauthResource(e.target.value)}
value={oauthResource}
data-testid="oauth-resource-input"
className="font-mono"
/>
</div>
)}
</div>
</> </>
)} )}

View file

@ -42,6 +42,12 @@ describe("Sidebar Environment Variables", () => {
setArgs: jest.fn(), setArgs: jest.fn(),
sseUrl: "", sseUrl: "",
setSseUrl: jest.fn(), setSseUrl: jest.fn(),
oauthClientId: "",
setOauthClientId: jest.fn(),
oauthScope: "",
setOauthScope: jest.fn(),
oauthResource: "",
setOauthResource: jest.fn(),
env: {}, env: {},
setEnv: jest.fn(), setEnv: jest.fn(),
bearerToken: "", bearerToken: "",

View file

@ -9,8 +9,67 @@ import {
} from "@modelcontextprotocol/sdk/shared/auth.js"; } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export const getClientInformationFromSessionStorage = async ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
};
export const saveClientInformationToSessionStorage = ({
serverUrl,
clientInformation,
isPreregistered,
}: {
serverUrl: string;
clientInformation: OAuthClientInformation;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
};
export const clearClientInformationFromSessionStorage = ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.removeItem(key);
};
export class InspectorOAuthClientProvider implements OAuthClientProvider { export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(public serverUrl: string) { constructor(
protected serverUrl: string,
protected resource?: string,
) {
// 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);
} }
@ -31,24 +90,29 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
async clientInformation() { async clientInformation() {
const key = getServerSpecificKey( // Try to get the preregistered client information from session storage first
SESSION_KEYS.CLIENT_INFORMATION, const preregisteredClientInformation = await getClientInformationFromSessionStorage({
this.serverUrl, serverUrl: this.serverUrl,
); isPreregistered: true,
const value = sessionStorage.getItem(key); });
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); // If no preregistered client information is found, get the dynamically registered client information
return (
preregisteredClientInformation ??
(await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
}))
);
} }
saveClientInformation(clientInformation: OAuthClientInformation) { saveClientInformation(clientInformation: OAuthClientInformation) {
const key = getServerSpecificKey( // Save the dynamically registered client information to session storage
SESSION_KEYS.CLIENT_INFORMATION, saveClientInformationToSessionStorage({
this.serverUrl, serverUrl: this.serverUrl,
); clientInformation,
sessionStorage.setItem(key, JSON.stringify(clientInformation)); isPreregistered: false,
});
} }
async tokens() { async tokens() {
@ -67,6 +131,18 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
redirectToAuthorization(authorizationUrl: URL) { redirectToAuthorization(authorizationUrl: URL) {
/**
* Note: This resource parameter is for testing purposes in Inspector.
* Once MCP Client SDK supports resource indicators, this parameter
* will be passed to the SDK's auth method similar to how scope is passed.
*
* See: https://github.com/modelcontextprotocol/typescript-sdk/pull/498
*
* TODO: @xiaoyijun Remove this once MCP Client SDK supports resource indicators.
*/
if (this.resource) {
authorizationUrl.searchParams.set("resource", this.resource);
}
window.location.href = authorizationUrl.href; window.location.href = authorizationUrl.href;
} }
@ -92,9 +168,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
clear() { clear() {
sessionStorage.removeItem( clearClientInformationFromSessionStorage({
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl), serverUrl: this.serverUrl,
); isPreregistered: false,
});
sessionStorage.removeItem( sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
); );

View file

@ -6,6 +6,7 @@ export const SESSION_KEYS = {
SERVER_URL: "mcp_server_url", SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens", TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information", CLIENT_INFORMATION: "mcp_client_information",
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
SERVER_METADATA: "mcp_server_metadata", SERVER_METADATA: "mcp_server_metadata",
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
} as const; } as const;

View file

@ -30,13 +30,13 @@ import {
Progress, Progress,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react"; 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, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { InspectorOAuthClientProvider } from "../auth"; import { clearClientInformationFromSessionStorage, InspectorOAuthClientProvider, saveClientInformationToSessionStorage } from "../auth";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { import {
getMCPProxyAddress, getMCPProxyAddress,
@ -56,6 +56,9 @@ interface UseConnectionOptions {
env: Record<string, string>; env: Record<string, string>;
bearerToken?: string; bearerToken?: string;
headerName?: string; headerName?: string;
oauthClientId?: string;
oauthScope?: string;
oauthResource?: string;
config: InspectorConfig; config: InspectorConfig;
onNotification?: (notification: Notification) => void; onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void;
@ -73,6 +76,9 @@ export function useConnection({
env, env,
bearerToken, bearerToken,
headerName, headerName,
oauthClientId,
oauthScope,
oauthResource,
config, config,
onNotification, onNotification,
onStdErrNotification, onStdErrNotification,
@ -93,6 +99,22 @@ export function useConnection({
>([]); >([]);
const [completionsSupported, setCompletionsSupported] = useState(true); const [completionsSupported, setCompletionsSupported] = useState(true);
useEffect(() => {
if (!oauthClientId) {
clearClientInformationFromSessionStorage({
serverUrl: sseUrl,
isPreregistered: true,
});
return;
}
saveClientInformationToSessionStorage({
serverUrl: sseUrl,
clientInformation: { client_id: oauthClientId },
isPreregistered: true,
});
}, [oauthClientId, sseUrl]);
const pushHistory = (request: object, response?: object) => { const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [ setRequestHistory((prev) => [
...prev, ...prev,
@ -277,9 +299,15 @@ export function useConnection({
const handleAuthError = async (error: unknown) => { const handleAuthError = async (error: unknown) => {
if (is401Error(error)) { if (is401Error(error)) {
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); const serverAuthProvider = new InspectorOAuthClientProvider(
sseUrl,
oauthResource,
);
const result = await auth(serverAuthProvider, { serverUrl: sseUrl }); const result = await auth(serverAuthProvider, {
serverUrl: sseUrl,
scope: oauthScope,
});
return result === "AUTHORIZED"; return result === "AUTHORIZED";
} }
@ -315,7 +343,10 @@ export function useConnection({
const headers: HeadersInit = {}; const headers: HeadersInit = {};
// Create an auth provider with the current server URL // Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); const serverAuthProvider = new InspectorOAuthClientProvider(
sseUrl,
oauthResource,
);
// Use manually provided bearer token if available, otherwise use OAuth tokens // Use manually provided bearer token if available, otherwise use OAuth tokens
const token = const token =
@ -535,7 +566,10 @@ export function useConnection({
clientTransport as StreamableHTTPClientTransport clientTransport as StreamableHTTPClientTransport
).terminateSession(); ).terminateSession();
await mcpClient?.close(); await mcpClient?.close();
const authProvider = new InspectorOAuthClientProvider(sseUrl); const authProvider = new InspectorOAuthClientProvider(
sseUrl,
oauthResource,
);
authProvider.clear(); authProvider.clear();
setMcpClient(null); setMcpClient(null);
setClientTransport(null); setClientTransport(null);