feat: support manual entry of OAuth client information
This commit is contained in:
parent
b39528d02b
commit
b8120d9f85
6 changed files with 242 additions and 24 deletions
|
|
@ -116,6 +116,18 @@ const App = () => {
|
|||
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<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
|
|
@ -184,6 +196,9 @@ const App = () => {
|
|||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
oauthClientId,
|
||||
oauthScope,
|
||||
oauthResource,
|
||||
config,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
|
|
@ -227,6 +242,18 @@ const App = () => {
|
|||
localStorage.setItem("lastHeaderName", headerName);
|
||||
}, [headerName]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastOauthClientId", oauthClientId);
|
||||
}, [oauthClientId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastOauthScope", oauthScope);
|
||||
}, [oauthScope]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastOauthResource", oauthResource);
|
||||
}, [oauthResource]);
|
||||
|
||||
useEffect(() => {
|
||||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||
}, [config]);
|
||||
|
|
@ -650,6 +677,12 @@ const App = () => {
|
|||
setBearerToken={setBearerToken}
|
||||
headerName={headerName}
|
||||
setHeaderName={setHeaderName}
|
||||
oauthClientId={oauthClientId}
|
||||
setOauthClientId={setOauthClientId}
|
||||
oauthScope={oauthScope}
|
||||
setOauthScope={setOauthScope}
|
||||
oauthResource={oauthResource}
|
||||
setOauthResource={setOauthResource}
|
||||
onConnect={connectMcpServer}
|
||||
onDisconnect={disconnectMcpServer}
|
||||
stdErrNotifications={stdErrNotifications}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,12 @@ interface SidebarProps {
|
|||
setBearerToken: (token: string) => void;
|
||||
headerName?: string;
|
||||
setHeaderName?: (name: string) => void;
|
||||
oauthClientId: string;
|
||||
setOauthClientId: (id: string) => void;
|
||||
oauthScope: string;
|
||||
setOauthScope: (scope: string) => void;
|
||||
oauthResource: string;
|
||||
setOauthResource: (resource: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
stdErrNotifications: StdErrNotification[];
|
||||
|
|
@ -83,6 +89,12 @@ const Sidebar = ({
|
|||
setBearerToken,
|
||||
headerName,
|
||||
setHeaderName,
|
||||
oauthClientId,
|
||||
setOauthClientId,
|
||||
oauthScope,
|
||||
setOauthScope,
|
||||
oauthResource,
|
||||
setOauthResource,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
stdErrNotifications,
|
||||
|
|
@ -98,6 +110,7 @@ const Sidebar = ({
|
|||
const [showBearerToken, setShowBearerToken] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||
const [showOauthConfig, setShowOauthConfig] = useState(false);
|
||||
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||
const [copiedServerFile, setCopiedServerFile] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
|
@ -353,6 +366,60 @@ const Sidebar = ({
|
|||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ describe("Sidebar Environment Variables", () => {
|
|||
setArgs: jest.fn(),
|
||||
sseUrl: "",
|
||||
setSseUrl: jest.fn(),
|
||||
oauthClientId: "",
|
||||
setOauthClientId: jest.fn(),
|
||||
oauthScope: "",
|
||||
setOauthScope: jest.fn(),
|
||||
oauthResource: "",
|
||||
setOauthResource: jest.fn(),
|
||||
env: {},
|
||||
setEnv: jest.fn(),
|
||||
bearerToken: "",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,67 @@ import {
|
|||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
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 {
|
||||
constructor(public serverUrl: string) {
|
||||
constructor(
|
||||
protected serverUrl: string,
|
||||
protected resource?: string,
|
||||
) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
|
|
@ -31,24 +90,29 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
async clientInformation() {
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
this.serverUrl,
|
||||
);
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
// Try to get the preregistered client information from session storage first
|
||||
const preregisteredClientInformation = await getClientInformationFromSessionStorage({
|
||||
serverUrl: this.serverUrl,
|
||||
isPreregistered: true,
|
||||
});
|
||||
|
||||
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) {
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
||||
// Save the dynamically registered client information to session storage
|
||||
saveClientInformationToSessionStorage({
|
||||
serverUrl: this.serverUrl,
|
||||
clientInformation,
|
||||
isPreregistered: false,
|
||||
});
|
||||
}
|
||||
|
||||
async tokens() {
|
||||
|
|
@ -67,6 +131,18 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -92,9 +168,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
clear() {
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
|
||||
);
|
||||
clearClientInformationFromSessionStorage({
|
||||
serverUrl: this.serverUrl,
|
||||
isPreregistered: false,
|
||||
});
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const SESSION_KEYS = {
|
|||
SERVER_URL: "mcp_server_url",
|
||||
TOKENS: "mcp_tokens",
|
||||
CLIENT_INFORMATION: "mcp_client_information",
|
||||
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
|
||||
SERVER_METADATA: "mcp_server_metadata",
|
||||
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ import {
|
|||
Progress,
|
||||
} from "@modelcontextprotocol/sdk/types.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 { z } from "zod";
|
||||
import { ConnectionStatus } from "../constants";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { InspectorOAuthClientProvider } from "../auth";
|
||||
import { clearClientInformationFromSessionStorage, InspectorOAuthClientProvider, saveClientInformationToSessionStorage } from "../auth";
|
||||
import packageJson from "../../../package.json";
|
||||
import {
|
||||
getMCPProxyAddress,
|
||||
|
|
@ -56,6 +56,9 @@ interface UseConnectionOptions {
|
|||
env: Record<string, string>;
|
||||
bearerToken?: string;
|
||||
headerName?: string;
|
||||
oauthClientId?: string;
|
||||
oauthScope?: string;
|
||||
oauthResource?: string;
|
||||
config: InspectorConfig;
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onStdErrNotification?: (notification: Notification) => void;
|
||||
|
|
@ -73,6 +76,9 @@ export function useConnection({
|
|||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
oauthClientId,
|
||||
oauthScope,
|
||||
oauthResource,
|
||||
config,
|
||||
onNotification,
|
||||
onStdErrNotification,
|
||||
|
|
@ -93,6 +99,22 @@ export function useConnection({
|
|||
>([]);
|
||||
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) => {
|
||||
setRequestHistory((prev) => [
|
||||
...prev,
|
||||
|
|
@ -277,9 +299,15 @@ export function useConnection({
|
|||
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
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";
|
||||
}
|
||||
|
||||
|
|
@ -315,7 +343,10 @@ export function useConnection({
|
|||
const headers: HeadersInit = {};
|
||||
|
||||
// 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
|
||||
const token =
|
||||
|
|
@ -535,7 +566,10 @@ export function useConnection({
|
|||
clientTransport as StreamableHTTPClientTransport
|
||||
).terminateSession();
|
||||
await mcpClient?.close();
|
||||
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
const authProvider = new InspectorOAuthClientProvider(
|
||||
sseUrl,
|
||||
oauthResource,
|
||||
);
|
||||
authProvider.clear();
|
||||
setMcpClient(null);
|
||||
setClientTransport(null);
|
||||
|
|
|
|||
Loading…
Reference in a new issue