Skip to content
Merged
22 changes: 22 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ 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 [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
Expand Down Expand Up @@ -187,6 +195,8 @@ const App = () => {
env,
bearerToken,
headerName,
oauthClientId,
oauthScope,
config,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
Expand Down Expand Up @@ -230,6 +240,14 @@ const App = () => {
localStorage.setItem("lastHeaderName", headerName);
}, [headerName]);

useEffect(() => {
localStorage.setItem("lastOauthClientId", oauthClientId);
}, [oauthClientId]);

useEffect(() => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);

useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
Expand Down Expand Up @@ -658,6 +676,10 @@ const App = () => {
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
Expand Down
143 changes: 97 additions & 46 deletions client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ interface SidebarProps {
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthScope: string;
setOauthScope: (scope: string) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
Expand Down Expand Up @@ -83,6 +87,10 @@ const Sidebar = ({
setBearerToken,
headerName,
setHeaderName,
oauthClientId,
setOauthClientId,
oauthScope,
setOauthScope,
onConnect,
onDisconnect,
stdErrNotifications,
Expand All @@ -95,7 +103,7 @@ const Sidebar = ({
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [showAuthConfig, setShowAuthConfig] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
Expand Down Expand Up @@ -308,51 +316,6 @@ const Sidebar = ({
/>
)}
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
data-testid="auth-button"
aria-expanded={showBearerToken}
>
{showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input
id="bearer-token-input"
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
</div>
)}
</div>
</>
)}

Expand Down Expand Up @@ -521,6 +484,94 @@ const Sidebar = ({
</Tooltip>
</div>

<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowAuthConfig(!showAuthConfig)}
className="flex items-center w-full"
data-testid="auth-button"
aria-expanded={showAuthConfig}
>
{showAuthConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showAuthConfig && (
<>
{/* Bearer Token Section */}
<div className="space-y-2 p-3 rounded border">
<h4 className="text-sm font-semibold flex items-center">
API Token Authentication
</h4>
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input
id="bearer-token-input"
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
</div>
</div>
{transportType !== "stdio" && (
// OAuth Configuration
<div className="space-y-2 p-3 rounded border">
<h4 className="text-sm font-semibold flex items-center">
OAuth 2.0 Flow
</h4>
<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
</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"
/>
</div>
</div>
)}
</>
)}
</div>
{/* Configuration */}
<div className="space-y-2">
<Button
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ describe("Sidebar Environment Variables", () => {
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
oauthClientId: "",
setOauthClientId: jest.fn(),
oauthScope: "",
setOauthScope: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
Expand Down
99 changes: 81 additions & 18 deletions client/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,64 @@ 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) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
Expand All @@ -31,24 +87,30 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}

async clientInformation() {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
// Try to get the preregistered client information from session storage first
const preregisteredClientInformation =
await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: true,
});

// If no preregistered client information is found, get the dynamically registered client information
return (
preregisteredClientInformation ??
(await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
}))
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}

return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
}

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() {
Expand Down Expand Up @@ -92,9 +154,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),
);
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions client/src/lib/hooks/__tests__/useConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ jest.mock("../../auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
})),
clearClientInformationFromSessionStorage: jest.fn(),
saveClientInformationToSessionStorage: jest.fn(),
}));

describe("useConnection", () => {
Expand Down
Loading