Merge branch 'main' into fix-required
This commit is contained in:
commit
6f80feb0b2
17 changed files with 902 additions and 218 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"test:watch": "jest --config jest.config.cjs --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"ajv": "^6.12.6",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||
import { AuthDebuggerState } from "./lib/auth-types";
|
||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
||||
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||
import React, {
|
||||
Suspense,
|
||||
|
|
@ -121,19 +122,8 @@ const App = () => {
|
|||
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
||||
|
||||
// Auth debugger state
|
||||
const [authState, setAuthState] = useState<AuthDebuggerState>({
|
||||
isInitiatingAuth: false,
|
||||
oauthTokens: null,
|
||||
loading: true,
|
||||
oauthStep: "metadata_discovery",
|
||||
oauthMetadata: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
authorizationCode: "",
|
||||
latestError: null,
|
||||
statusMessage: null,
|
||||
validationError: null,
|
||||
});
|
||||
const [authState, setAuthState] =
|
||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
||||
|
||||
// Helper function to update specific auth state properties
|
||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||
|
|
@ -243,27 +233,81 @@ const App = () => {
|
|||
|
||||
// Update OAuth debug state during debug callback
|
||||
const onOAuthDebugConnect = useCallback(
|
||||
({
|
||||
async ({
|
||||
authorizationCode,
|
||||
errorMsg,
|
||||
restoredState,
|
||||
}: {
|
||||
authorizationCode?: string;
|
||||
errorMsg?: string;
|
||||
restoredState?: AuthDebuggerState;
|
||||
}) => {
|
||||
setIsAuthDebuggerVisible(true);
|
||||
if (authorizationCode) {
|
||||
|
||||
if (errorMsg) {
|
||||
updateAuthState({
|
||||
latestError: new Error(errorMsg),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredState && authorizationCode) {
|
||||
// Restore the previous auth state and continue the OAuth flow
|
||||
let currentState: AuthDebuggerState = {
|
||||
...restoredState,
|
||||
authorizationCode,
|
||||
oauthStep: "token_request",
|
||||
isInitiatingAuth: true,
|
||||
statusMessage: null,
|
||||
latestError: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Create a new state machine instance to continue the flow
|
||||
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
});
|
||||
|
||||
// Continue stepping through the OAuth flow from where we left off
|
||||
while (
|
||||
currentState.oauthStep !== "complete" &&
|
||||
currentState.oauthStep !== "authorization_code"
|
||||
) {
|
||||
await stateMachine.executeStep(currentState);
|
||||
}
|
||||
|
||||
if (currentState.oauthStep === "complete") {
|
||||
// After the flow completes or reaches a user-input step, update the app state
|
||||
updateAuthState({
|
||||
...currentState,
|
||||
statusMessage: {
|
||||
type: "success",
|
||||
message: "Authentication completed successfully",
|
||||
},
|
||||
isInitiatingAuth: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("OAuth continuation error:", error);
|
||||
updateAuthState({
|
||||
latestError:
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
isInitiatingAuth: false,
|
||||
});
|
||||
}
|
||||
} else if (authorizationCode) {
|
||||
// Fallback to the original behavior if no state was restored
|
||||
updateAuthState({
|
||||
authorizationCode,
|
||||
oauthStep: "token_request",
|
||||
});
|
||||
}
|
||||
if (errorMsg) {
|
||||
updateAuthState({
|
||||
latestError: new Error(errorMsg),
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
[sseUrl],
|
||||
);
|
||||
|
||||
// Load OAuth tokens when sseUrl changes
|
||||
|
|
@ -285,8 +329,6 @@ const App = () => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading OAuth tokens:", error);
|
||||
} finally {
|
||||
updateAuthState({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AuthDebuggerState } from "../lib/auth-types";
|
||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
|
||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
|
||||
export interface AuthDebuggerProps {
|
||||
serverUrl: string;
|
||||
|
|
@ -59,6 +60,27 @@ const AuthDebugger = ({
|
|||
authState,
|
||||
updateAuthState,
|
||||
}: AuthDebuggerProps) => {
|
||||
// Check for existing tokens on mount
|
||||
useEffect(() => {
|
||||
if (serverUrl && !authState.oauthTokens) {
|
||||
const checkTokens = async () => {
|
||||
try {
|
||||
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
|
||||
const existingTokens = await provider.tokens();
|
||||
if (existingTokens) {
|
||||
updateAuthState({
|
||||
oauthTokens: existingTokens,
|
||||
oauthStep: "complete",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load existing OAuth tokens:", error);
|
||||
}
|
||||
};
|
||||
checkTokens();
|
||||
}
|
||||
}, [serverUrl, updateAuthState, authState.oauthTokens]);
|
||||
|
||||
const startOAuthFlow = useCallback(() => {
|
||||
if (!serverUrl) {
|
||||
updateAuthState({
|
||||
|
|
@ -141,6 +163,11 @@ const AuthDebugger = ({
|
|||
currentState.oauthStep === "authorization_code" &&
|
||||
currentState.authorizationUrl
|
||||
) {
|
||||
// Store the current auth state before redirecting
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
||||
JSON.stringify(currentState),
|
||||
);
|
||||
// Open the authorization URL automatically
|
||||
window.location.href = currentState.authorizationUrl;
|
||||
break;
|
||||
|
|
@ -178,13 +205,7 @@ const AuthDebugger = ({
|
|||
);
|
||||
serverAuthProvider.clear();
|
||||
updateAuthState({
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationCode: "",
|
||||
validationError: null,
|
||||
oauthMetadata: null,
|
||||
...EMPTY_DEBUGGER_STATE,
|
||||
statusMessage: {
|
||||
type: "success",
|
||||
message: "OAuth tokens cleared successfully",
|
||||
|
|
@ -224,52 +245,48 @@ const AuthDebugger = ({
|
|||
<StatusMessage message={authState.statusMessage} />
|
||||
)}
|
||||
|
||||
{authState.loading ? (
|
||||
<p>Loading authentication status...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{authState.oauthTokens && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Access Token:</p>
|
||||
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
|
||||
{authState.oauthTokens.access_token.substring(0, 25)}...
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{authState.oauthTokens && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Access Token:</p>
|
||||
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
|
||||
{authState.oauthTokens.access_token.substring(0, 25)}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startOAuthFlow}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.oauthTokens
|
||||
? "Guided Token Refresh"
|
||||
: "Guided OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleQuickOAuth}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.isInitiatingAuth
|
||||
? "Initiating..."
|
||||
: authState.oauthTokens
|
||||
? "Quick Refresh"
|
||||
: "Quick OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleClearOAuth}>
|
||||
Clear OAuth State
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose "Guided" for step-by-step instructions or "Quick" for
|
||||
the standard automatic flow.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startOAuthFlow}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.oauthTokens
|
||||
? "Guided Token Refresh"
|
||||
: "Guided OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleQuickOAuth}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.isInitiatingAuth
|
||||
? "Initiating..."
|
||||
: authState.oauthTokens
|
||||
? "Quick Refresh"
|
||||
: "Quick OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleClearOAuth}>
|
||||
Clear OAuth State
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose "Guided" for step-by-step instructions or "Quick" for
|
||||
the standard automatic flow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OAuthFlowProgress
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ import {
|
|||
generateOAuthErrorDescription,
|
||||
parseOAuthCallbackParams,
|
||||
} from "@/utils/oauthUtils.ts";
|
||||
import { AuthDebuggerState } from "@/lib/auth-types";
|
||||
|
||||
interface OAuthCallbackProps {
|
||||
onConnect: ({
|
||||
authorizationCode,
|
||||
errorMsg,
|
||||
restoredState,
|
||||
}: {
|
||||
authorizationCode?: string;
|
||||
errorMsg?: string;
|
||||
restoredState?: AuthDebuggerState;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +38,21 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
|
|||
|
||||
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||
|
||||
// Try to restore the auth state
|
||||
const storedState = sessionStorage.getItem(
|
||||
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
||||
);
|
||||
let restoredState = null;
|
||||
if (storedState) {
|
||||
try {
|
||||
restoredState = JSON.parse(storedState);
|
||||
// Clean up the stored state
|
||||
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stored auth state:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ServerURL isn't set, this can happen if we've opened the
|
||||
// authentication request in a new tab, so we don't have the same
|
||||
// session storage
|
||||
|
|
@ -50,8 +68,8 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
|
|||
}
|
||||
|
||||
// Instead of storing in sessionStorage, pass the code directly
|
||||
// to the auth state manager through onConnect
|
||||
onConnect({ authorizationCode: params.code });
|
||||
// to the auth state manager through onConnect, along with restored state
|
||||
onConnect({ authorizationCode: params.code, restoredState });
|
||||
};
|
||||
|
||||
handleCallback().finally(() => {
|
||||
|
|
|
|||
|
|
@ -128,15 +128,86 @@ export const OAuthFlowProgress = ({
|
|||
label="Metadata Discovery"
|
||||
{...getStepProps("metadata_discovery")}
|
||||
>
|
||||
{provider.getServerMetadata() && (
|
||||
{authState.oauthMetadata && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Retrieved OAuth Metadata from {serverUrl}
|
||||
/.well-known/oauth-authorization-server
|
||||
OAuth Metadata Sources
|
||||
{!authState.resourceMetadata && " ℹ️"}
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(provider.getServerMetadata(), null, 2)}
|
||||
</pre>
|
||||
|
||||
{authState.resourceMetadata && (
|
||||
<div className="mt-2">
|
||||
<p className="font-medium">Resource Metadata:</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From{" "}
|
||||
{
|
||||
new URL(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
serverUrl,
|
||||
).href
|
||||
}
|
||||
</p>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(authState.resourceMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authState.resourceMetadataError && (
|
||||
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
|
||||
<p className="text-sm font-medium text-blue-700">
|
||||
ℹ️ No resource metadata available from{" "}
|
||||
<a
|
||||
href={
|
||||
new URL(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
serverUrl,
|
||||
).href
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
{
|
||||
new URL(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
serverUrl,
|
||||
).href
|
||||
}
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Resource metadata was added in the{" "}
|
||||
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
|
||||
2025-DRAFT-v2 specification update
|
||||
</a>
|
||||
<br />
|
||||
{authState.resourceMetadataError.message}
|
||||
{authState.resourceMetadataError instanceof TypeError &&
|
||||
" (This could indicate the endpoint doesn't exist or does not have CORS configured)"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authState.oauthMetadata && (
|
||||
<div className="mt-2">
|
||||
<p className="font-medium">Authorization Server Metadata:</p>
|
||||
{authState.authServerUrl && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From{" "}
|
||||
{
|
||||
new URL(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
authState.authServerUrl,
|
||||
).href
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(authState.oauthMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import ListPane from "./ListPane";
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||
import JsonView from "./JsonView";
|
||||
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
|
||||
|
||||
const ResourcesTab = ({
|
||||
resources,
|
||||
|
|
@ -79,10 +80,7 @@ const ResourcesTab = ({
|
|||
template: string,
|
||||
values: Record<string, string>,
|
||||
): string => {
|
||||
return template.replace(
|
||||
/{([^}]+)}/g,
|
||||
(_, key) => values[key] || `{${key}}`,
|
||||
);
|
||||
return new UriTemplate(template).expand(values);
|
||||
};
|
||||
|
||||
const handleTemplateValueChange = async (key: string, value: string) => {
|
||||
|
|
@ -237,28 +235,27 @@ const ResourcesTab = ({
|
|||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedTemplate.description}
|
||||
</p>
|
||||
{selectedTemplate.uriTemplate
|
||||
.match(/{([^}]+)}/g)
|
||||
?.map((param) => {
|
||||
const key = param.slice(1, -1);
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label htmlFor={key}>{key}</Label>
|
||||
<Combobox
|
||||
id={key}
|
||||
placeholder={`Enter ${key}`}
|
||||
value={templateValues[key] || ""}
|
||||
onChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
onInputChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
options={completions[key] || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{new UriTemplate(
|
||||
selectedTemplate.uriTemplate,
|
||||
).variableNames?.map((key) => {
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label htmlFor={key}>{key}</Label>
|
||||
<Combobox
|
||||
id={key}
|
||||
placeholder={`Enter ${key}`}
|
||||
value={templateValues[key] || ""}
|
||||
onChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
onInputChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
options={completions[key] || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={handleReadTemplateResource}
|
||||
disabled={Object.keys(templateValues).length === 0}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|||
registerClient: jest.fn(),
|
||||
startAuthorization: jest.fn(),
|
||||
exchangeAuthorization: jest.fn(),
|
||||
discoverOAuthProtectedResourceMetadata: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the functions to get their types
|
||||
|
|
@ -49,8 +50,10 @@ import {
|
|||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
auth,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
||||
|
||||
// Type the mocked functions properly
|
||||
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
|
||||
|
|
@ -66,6 +69,10 @@ const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
|
|||
typeof exchangeAuthorization
|
||||
>;
|
||||
const mockAuth = auth as jest.MockedFunction<typeof auth>;
|
||||
const mockDiscoverOAuthProtectedResourceMetadata =
|
||||
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
|
||||
typeof discoverOAuthProtectedResourceMetadata
|
||||
>;
|
||||
|
||||
const sessionStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
|
|
@ -84,19 +91,7 @@ Object.defineProperty(window, "location", {
|
|||
});
|
||||
|
||||
describe("AuthDebugger", () => {
|
||||
const defaultAuthState = {
|
||||
isInitiatingAuth: false,
|
||||
oauthTokens: null,
|
||||
loading: false,
|
||||
oauthStep: "metadata_discovery" as const,
|
||||
oauthMetadata: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
authorizationCode: "",
|
||||
latestError: null,
|
||||
statusMessage: null,
|
||||
validationError: null,
|
||||
};
|
||||
const defaultAuthState = EMPTY_DEBUGGER_STATE;
|
||||
|
||||
const defaultProps = {
|
||||
serverUrl: "https://example.com",
|
||||
|
|
@ -109,8 +104,14 @@ describe("AuthDebugger", () => {
|
|||
jest.clearAllMocks();
|
||||
sessionStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
// Supress
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
|
||||
new Error("No protected resource metadata found"),
|
||||
);
|
||||
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
|
||||
const authUrl = new URL("https://oauth.example.com/authorize");
|
||||
|
||||
|
|
@ -126,6 +127,10 @@ describe("AuthDebugger", () => {
|
|||
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
|
||||
const mergedProps = {
|
||||
...defaultProps,
|
||||
|
|
@ -204,7 +209,7 @@ describe("AuthDebugger", () => {
|
|||
|
||||
// Should first discover and save OAuth metadata
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
new URL("https://example.com"),
|
||||
);
|
||||
|
||||
// Check that updateAuthState was called with the right info message
|
||||
|
|
@ -316,6 +321,11 @@ describe("AuthDebugger", () => {
|
|||
});
|
||||
|
||||
expect(updateAuthState).toHaveBeenCalledWith({
|
||||
authServerUrl: null,
|
||||
authorizationUrl: null,
|
||||
isInitiatingAuth: false,
|
||||
resourceMetadata: null,
|
||||
resourceMetadataError: null,
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
|
|
@ -357,7 +367,7 @@ describe("AuthDebugger", () => {
|
|||
});
|
||||
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
new URL("https://example.com"),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -432,4 +442,186 @@ describe("AuthDebugger", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth State Persistence", () => {
|
||||
it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
|
||||
// Mock window.location.href setter
|
||||
const originalLocation = window.location;
|
||||
const locationMock = {
|
||||
...originalLocation,
|
||||
href: "",
|
||||
origin: "http://localhost:3000",
|
||||
};
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: locationMock,
|
||||
});
|
||||
|
||||
// Setup mocks for OAuth flow
|
||||
mockStartAuthorization.mockResolvedValue({
|
||||
authorizationUrl: new URL(
|
||||
"https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug",
|
||||
),
|
||||
codeVerifier: "test_verifier",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: { ...defaultAuthState },
|
||||
});
|
||||
});
|
||||
|
||||
// Click Quick OAuth Flow
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Quick OAuth Flow"));
|
||||
});
|
||||
|
||||
// Wait for the flow to reach the authorization step
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
||||
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
||||
expect.stringContaining('"oauthStep":"authorization_code"'),
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the stored state includes all the accumulated data
|
||||
const storedStateCall = (
|
||||
sessionStorage.setItem as jest.Mock
|
||||
).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE);
|
||||
|
||||
expect(storedStateCall).toBeDefined();
|
||||
const storedState = JSON.parse(storedStateCall![1] as string);
|
||||
|
||||
expect(storedState).toMatchObject({
|
||||
oauthStep: "authorization_code",
|
||||
authorizationUrl: expect.stringMatching(
|
||||
/^https:\/\/oauth\.example\.com\/authorize/,
|
||||
),
|
||||
oauthMetadata: expect.objectContaining({
|
||||
token_endpoint: "https://oauth.example.com/token",
|
||||
}),
|
||||
oauthClientInfo: expect.objectContaining({
|
||||
client_id: "test_client_id",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Protected Resource Metadata", () => {
|
||||
it("should successfully fetch and display protected resource metadata", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
const mockResourceMetadata = {
|
||||
resource: "https://example.com/api",
|
||||
authorization_servers: ["https://custom-auth.example.com"],
|
||||
bearer_methods_supported: ["header", "body"],
|
||||
resource_documentation: "https://example.com/api/docs",
|
||||
resource_policy_uri: "https://example.com/api/policy",
|
||||
};
|
||||
|
||||
// Mock successful metadata discovery
|
||||
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
|
||||
mockResourceMetadata,
|
||||
);
|
||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: { ...defaultAuthState },
|
||||
});
|
||||
});
|
||||
|
||||
// Click Guided OAuth Flow to start the process
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||
});
|
||||
|
||||
// Verify that the flow started with metadata discovery
|
||||
expect(updateAuthState).toHaveBeenCalledWith({
|
||||
oauthStep: "metadata_discovery",
|
||||
authorizationUrl: null,
|
||||
statusMessage: null,
|
||||
latestError: null,
|
||||
});
|
||||
|
||||
// Click Continue to trigger metadata discovery
|
||||
const continueButton = await screen.findByText("Continue");
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton);
|
||||
});
|
||||
|
||||
// Wait for the metadata to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the state was updated with the resource metadata
|
||||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceMetadata: mockResourceMetadata,
|
||||
authServerUrl: new URL("https://custom-auth.example.com"),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle protected resource metadata fetch failure gracefully", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
const mockError = new Error("Failed to fetch resource metadata");
|
||||
|
||||
// Mock failed metadata discovery
|
||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
|
||||
// But OAuth metadata should still work with the original URL
|
||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: { ...defaultAuthState },
|
||||
});
|
||||
});
|
||||
|
||||
// Click Guided OAuth Flow
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||
});
|
||||
|
||||
// Click Continue to trigger metadata discovery
|
||||
const continueButton = await screen.findByText("Continue");
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton);
|
||||
});
|
||||
|
||||
// Wait for the metadata fetch to fail
|
||||
await waitFor(() => {
|
||||
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the flow continues despite the error
|
||||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceMetadataError: mockError,
|
||||
// Should use the original server URL as fallback
|
||||
authServerUrl: new URL("https://example.com"),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Verify that regular OAuth metadata discovery was still called
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
new URL("https://example.com"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
308
client/src/components/__tests__/ResourcesTab.test.tsx
Normal file
308
client/src/components/__tests__/ResourcesTab.test.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import ResourcesTab from "../ResourcesTab";
|
||||
import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
// Mock the hooks and components
|
||||
jest.mock("@/lib/hooks/useCompletionState", () => ({
|
||||
useCompletionState: () => ({
|
||||
completions: {},
|
||||
clearCompletions: jest.fn(),
|
||||
requestCompletions: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../JsonView", () => {
|
||||
return function MockJsonView({ data }: { data: string }) {
|
||||
return <div data-testid="json-view">{data}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("@/components/ui/combobox", () => ({
|
||||
Combobox: ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<input
|
||||
id={id}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
data-testid={`combobox-${id}`}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/label", () => ({
|
||||
Label: ({
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<label htmlFor={htmlFor} data-testid={`label-${htmlFor}`}>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-testid="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ResourcesTab - Template Query Parameters", () => {
|
||||
const mockListResources = jest.fn();
|
||||
const mockClearResources = jest.fn();
|
||||
const mockListResourceTemplates = jest.fn();
|
||||
const mockClearResourceTemplates = jest.fn();
|
||||
const mockReadResource = jest.fn();
|
||||
const mockSetSelectedResource = jest.fn();
|
||||
const mockHandleCompletion = jest.fn();
|
||||
const mockSubscribeToResource = jest.fn();
|
||||
const mockUnsubscribeFromResource = jest.fn();
|
||||
|
||||
const mockResourceTemplate: ResourceTemplate = {
|
||||
name: "Users API",
|
||||
uriTemplate: "test://users{?name,limit,offset}",
|
||||
description: "Fetch users with optional filtering and pagination",
|
||||
};
|
||||
|
||||
const mockResource: Resource = {
|
||||
uri: "test://users?name=john&limit=10&offset=0",
|
||||
name: "Users Resource",
|
||||
description: "Expanded users resource",
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
resources: [],
|
||||
resourceTemplates: [mockResourceTemplate],
|
||||
listResources: mockListResources,
|
||||
clearResources: mockClearResources,
|
||||
listResourceTemplates: mockListResourceTemplates,
|
||||
clearResourceTemplates: mockClearResourceTemplates,
|
||||
readResource: mockReadResource,
|
||||
selectedResource: null,
|
||||
setSelectedResource: mockSetSelectedResource,
|
||||
handleCompletion: mockHandleCompletion,
|
||||
completionsSupported: true,
|
||||
resourceContent: "",
|
||||
nextCursor: undefined,
|
||||
nextTemplateCursor: undefined,
|
||||
error: null,
|
||||
resourceSubscriptionsSupported: false,
|
||||
resourceSubscriptions: new Set<string>(),
|
||||
subscribeToResource: mockSubscribeToResource,
|
||||
unsubscribeFromResource: mockUnsubscribeFromResource,
|
||||
};
|
||||
|
||||
const renderResourcesTab = (props = {}) =>
|
||||
render(
|
||||
<Tabs defaultValue="resources">
|
||||
<ResourcesTab {...defaultProps} {...props} />
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should parse and display template variables from URI template", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
// Check that input fields are rendered for each template variable
|
||||
expect(screen.getByTestId("combobox-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("combobox-limit")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("combobox-offset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display template description when template is selected", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
expect(
|
||||
screen.getByText("Fetch users with optional filtering and pagination"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle template value changes", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
// Find and fill template value inputs
|
||||
const nameInput = screen.getByTestId("combobox-name");
|
||||
const limitInput = screen.getByTestId("combobox-limit");
|
||||
const offsetInput = screen.getByTestId("combobox-offset");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "john" } });
|
||||
fireEvent.change(limitInput, { target: { value: "10" } });
|
||||
fireEvent.change(offsetInput, { target: { value: "0" } });
|
||||
|
||||
expect(nameInput).toHaveValue("john");
|
||||
expect(limitInput).toHaveValue("10");
|
||||
expect(offsetInput).toHaveValue("0");
|
||||
});
|
||||
|
||||
it("should expand template and read resource when Read Resource button is clicked", async () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
// Fill template values
|
||||
const nameInput = screen.getByTestId("combobox-name");
|
||||
const limitInput = screen.getByTestId("combobox-limit");
|
||||
const offsetInput = screen.getByTestId("combobox-offset");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "john" } });
|
||||
fireEvent.change(limitInput, { target: { value: "10" } });
|
||||
fireEvent.change(offsetInput, { target: { value: "0" } });
|
||||
|
||||
// Click Read Resource button
|
||||
const readResourceButton = screen.getByText("Read Resource");
|
||||
expect(readResourceButton).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(readResourceButton);
|
||||
|
||||
// Verify that readResource was called with the expanded URI
|
||||
expect(mockReadResource).toHaveBeenCalledWith(
|
||||
"test://users?name=john&limit=10&offset=0",
|
||||
);
|
||||
|
||||
// Verify that setSelectedResource was called with the expanded resource
|
||||
expect(mockSetSelectedResource).toHaveBeenCalledWith({
|
||||
uri: "test://users?name=john&limit=10&offset=0",
|
||||
name: "test://users?name=john&limit=10&offset=0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable Read Resource button when no template values are provided", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
// Read Resource button should be disabled when no values are provided
|
||||
const readResourceButton = screen.getByText("Read Resource");
|
||||
expect(readResourceButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should handle partial template values correctly", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
// Fill only some template values
|
||||
const nameInput = screen.getByTestId("combobox-name");
|
||||
fireEvent.change(nameInput, { target: { value: "john" } });
|
||||
|
||||
// Read Resource button should be enabled with partial values
|
||||
const readResourceButton = screen.getByText("Read Resource");
|
||||
expect(readResourceButton).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(readResourceButton);
|
||||
|
||||
// Should expand with only the provided values
|
||||
expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john");
|
||||
});
|
||||
|
||||
it("should handle special characters in template values", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
// Click on the resource template to select it
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
|
||||
// Fill template values with special characters
|
||||
const nameInput = screen.getByTestId("combobox-name");
|
||||
fireEvent.change(nameInput, { target: { value: "john doe" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Read Resource"));
|
||||
|
||||
// Should properly encode special characters
|
||||
expect(mockReadResource).toHaveBeenCalledWith(
|
||||
"test://users?name=john%20doe",
|
||||
);
|
||||
});
|
||||
|
||||
it("should clear template values when switching between templates", () => {
|
||||
const anotherTemplate: ResourceTemplate = {
|
||||
name: "Posts API",
|
||||
uriTemplate: "test://posts{?author,category}",
|
||||
description: "Fetch posts by author and category",
|
||||
};
|
||||
|
||||
renderResourcesTab({
|
||||
resourceTemplates: [mockResourceTemplate, anotherTemplate],
|
||||
});
|
||||
|
||||
// Select first template and fill values
|
||||
fireEvent.click(screen.getByText("Users API"));
|
||||
const nameInput = screen.getByTestId("combobox-name");
|
||||
fireEvent.change(nameInput, { target: { value: "john" } });
|
||||
|
||||
// Switch to second template
|
||||
fireEvent.click(screen.getByText("Posts API"));
|
||||
|
||||
// Should show new template fields and clear previous values
|
||||
expect(screen.getByTestId("combobox-author")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("combobox-category")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("combobox-name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display resource content when a resource is selected", () => {
|
||||
const resourceContent = '{"users": [{"id": 1, "name": "John"}]}';
|
||||
|
||||
renderResourcesTab({
|
||||
selectedResource: mockResource,
|
||||
resourceContent: resourceContent,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("json-view")).toBeInTheDocument();
|
||||
expect(screen.getByText(resourceContent)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show alert when no resource or template is selected", () => {
|
||||
renderResourcesTab();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Select a resource or template from the list to view its contents",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
OAuthClientInformationFull,
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// OAuth flow steps
|
||||
|
|
@ -26,8 +27,10 @@ export interface StatusMessage {
|
|||
export interface AuthDebuggerState {
|
||||
isInitiatingAuth: boolean;
|
||||
oauthTokens: OAuthTokens | null;
|
||||
loading: boolean;
|
||||
oauthStep: OAuthStep;
|
||||
resourceMetadata: OAuthProtectedResourceMetadata | null;
|
||||
resourceMetadataError: Error | null;
|
||||
authServerUrl: URL | null;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
authorizationUrl: string | null;
|
||||
|
|
@ -36,3 +39,19 @@ export interface AuthDebuggerState {
|
|||
statusMessage: StatusMessage | null;
|
||||
validationError: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = {
|
||||
isInitiatingAuth: false,
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
oauthMetadata: null,
|
||||
resourceMetadata: null,
|
||||
resourceMetadataError: null,
|
||||
authServerUrl: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
authorizationCode: "",
|
||||
latestError: null,
|
||||
statusMessage: null,
|
||||
validationError: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const SESSION_KEYS = {
|
|||
TOKENS: "mcp_tokens",
|
||||
CLIENT_INFORMATION: "mcp_client_information",
|
||||
SERVER_METADATA: "mcp_server_metadata",
|
||||
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
|
||||
} as const;
|
||||
|
||||
// Generate server-specific session storage keys
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
} from "@/utils/configUtils";
|
||||
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||
import { InspectorConfig } from "../configurationTypes";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
|
||||
interface UseConnectionOptions {
|
||||
transportType: "stdio" | "sse" | "streamable-http";
|
||||
|
|
@ -83,6 +84,9 @@ export function useConnection({
|
|||
const [serverCapabilities, setServerCapabilities] =
|
||||
useState<ServerCapabilities | null>(null);
|
||||
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
||||
const [clientTransport, setClientTransport] = useState<Transport | null>(
|
||||
null,
|
||||
);
|
||||
const [requestHistory, setRequestHistory] = useState<
|
||||
{ request: string; response?: string }[]
|
||||
>([]);
|
||||
|
|
@ -384,14 +388,6 @@ export function useConnection({
|
|||
transportType,
|
||||
);
|
||||
|
||||
const clientTransport =
|
||||
transportType === "streamable-http"
|
||||
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
||||
sessionId: undefined,
|
||||
...transportOptions,
|
||||
})
|
||||
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
|
||||
|
||||
if (onNotification) {
|
||||
[
|
||||
CancelledNotificationSchema,
|
||||
|
|
@ -421,7 +417,20 @@ export function useConnection({
|
|||
|
||||
let capabilities;
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const transport =
|
||||
transportType === "streamable-http"
|
||||
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
||||
sessionId: undefined,
|
||||
...transportOptions,
|
||||
})
|
||||
: new SSEClientTransport(
|
||||
mcpProxyServerUrl as URL,
|
||||
transportOptions,
|
||||
);
|
||||
|
||||
await client.connect(transport as Transport);
|
||||
|
||||
setClientTransport(transport);
|
||||
|
||||
capabilities = client.getServerCapabilities();
|
||||
const initializeRequest = {
|
||||
|
|
@ -475,10 +484,15 @@ export function useConnection({
|
|||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
if (transportType === "streamable-http")
|
||||
await (
|
||||
clientTransport as StreamableHTTPClientTransport
|
||||
).terminateSession();
|
||||
await mcpClient?.close();
|
||||
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
authProvider.clear();
|
||||
setMcpClient(null);
|
||||
setClientTransport(null);
|
||||
setConnectionStatus("disconnected");
|
||||
setCompletionsSupported(false);
|
||||
setServerCapabilities(null);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import {
|
|||
registerClient,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import {
|
||||
OAuthMetadataSchema,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
export interface StateMachineContext {
|
||||
state: AuthDebuggerState;
|
||||
|
|
@ -18,7 +22,6 @@ export interface StateMachineContext {
|
|||
export interface StateTransition {
|
||||
canTransition: (context: StateMachineContext) => Promise<boolean>;
|
||||
execute: (context: StateMachineContext) => Promise<void>;
|
||||
nextStep: OAuthStep;
|
||||
}
|
||||
|
||||
// State machine transitions
|
||||
|
|
@ -26,18 +29,41 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
metadata_discovery: {
|
||||
canTransition: async () => true,
|
||||
execute: async (context) => {
|
||||
const metadata = await discoverOAuthMetadata(context.serverUrl);
|
||||
let authServerUrl = new URL(context.serverUrl);
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | null = null;
|
||||
let resourceMetadataError: Error | null = null;
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
context.serverUrl,
|
||||
);
|
||||
if (
|
||||
resourceMetadata &&
|
||||
resourceMetadata.authorization_servers?.length
|
||||
) {
|
||||
authServerUrl = new URL(resourceMetadata.authorization_servers[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
resourceMetadataError = e;
|
||||
} else {
|
||||
resourceMetadataError = new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = await discoverOAuthMetadata(authServerUrl);
|
||||
if (!metadata) {
|
||||
throw new Error("Failed to discover OAuth metadata");
|
||||
}
|
||||
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
|
||||
context.provider.saveServerMetadata(parsedMetadata);
|
||||
context.updateState({
|
||||
resourceMetadata,
|
||||
resourceMetadataError,
|
||||
authServerUrl,
|
||||
oauthMetadata: parsedMetadata,
|
||||
oauthStep: "client_registration",
|
||||
});
|
||||
},
|
||||
nextStep: "client_registration",
|
||||
},
|
||||
|
||||
client_registration: {
|
||||
|
|
@ -46,9 +72,13 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
const metadata = context.state.oauthMetadata!;
|
||||
const clientMetadata = context.provider.clientMetadata;
|
||||
|
||||
// Prefer scopes from resource metadata if available
|
||||
const scopesSupported =
|
||||
context.state.resourceMetadata?.scopes_supported ||
|
||||
metadata.scopes_supported;
|
||||
// Add all supported scopes to client registration
|
||||
if (metadata.scopes_supported) {
|
||||
clientMetadata.scope = metadata.scopes_supported.join(" ");
|
||||
if (scopesSupported) {
|
||||
clientMetadata.scope = scopesSupported.join(" ");
|
||||
}
|
||||
|
||||
const fullInformation = await registerClient(context.serverUrl, {
|
||||
|
|
@ -62,7 +92,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
oauthStep: "authorization_redirect",
|
||||
});
|
||||
},
|
||||
nextStep: "authorization_redirect",
|
||||
},
|
||||
|
||||
authorization_redirect: {
|
||||
|
|
@ -93,7 +122,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
oauthStep: "authorization_code",
|
||||
});
|
||||
},
|
||||
nextStep: "authorization_code",
|
||||
},
|
||||
|
||||
authorization_code: {
|
||||
|
|
@ -114,7 +142,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
oauthStep: "token_request",
|
||||
});
|
||||
},
|
||||
nextStep: "token_request",
|
||||
},
|
||||
|
||||
token_request: {
|
||||
|
|
@ -144,7 +171,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
oauthStep: "complete",
|
||||
});
|
||||
},
|
||||
nextStep: "complete",
|
||||
},
|
||||
|
||||
complete: {
|
||||
|
|
@ -152,7 +178,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
execute: async () => {
|
||||
// No-op for complete state
|
||||
},
|
||||
nextStep: "complete",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
80
package-lock.json
generated
80
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"client",
|
||||
|
|
@ -14,10 +14,10 @@
|
|||
"cli"
|
||||
],
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-cli": "^0.13.0",
|
||||
"@modelcontextprotocol/inspector-client": "^0.13.0",
|
||||
"@modelcontextprotocol/inspector-server": "^0.13.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/inspector-cli": "^0.14.0",
|
||||
"@modelcontextprotocol/inspector-client": "^0.14.0",
|
||||
"@modelcontextprotocol/inspector-server": "^0.14.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"open": "^10.1.0",
|
||||
"shell-quote": "^1.8.2",
|
||||
|
|
@ -40,10 +40,10 @@
|
|||
},
|
||||
"cli": {
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
},
|
||||
|
|
@ -63,10 +63,10 @@
|
|||
},
|
||||
"client": {
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
|
|
@ -2005,12 +2005,11 @@
|
|||
"link": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.5.tgz",
|
||||
"integrity": "sha512-gS7Q7IHpKxjVaNLMUZyTtatZ63ca3h418zPPntAhu/MvG5yfz/8HMcDAOpvpQfx3V3dsw9QQxk8RuFNrQhLlgA==",
|
||||
"license": "MIT",
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz",
|
||||
"integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv": "^6.12.6",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
|
|
@ -2026,28 +2025,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -5719,22 +5696,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
|
||||
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
|
|
@ -9064,15 +9025,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
|
|
@ -10968,10 +10920,10 @@
|
|||
},
|
||||
"server": {
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"description": "Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -36,14 +36,15 @@
|
|||
"test-cli": "cd cli && npm run test",
|
||||
"prettier-fix": "prettier --write .",
|
||||
"prettier-check": "prettier --check .",
|
||||
"lint": "prettier --check . && cd client && npm run lint",
|
||||
"prepare": "npm run build",
|
||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-cli": "^0.13.0",
|
||||
"@modelcontextprotocol/inspector-client": "^0.13.0",
|
||||
"@modelcontextprotocol/inspector-server": "^0.13.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/inspector-cli": "^0.14.0",
|
||||
"@modelcontextprotocol/inspector-client": "^0.14.0",
|
||||
"@modelcontextprotocol/inspector-server": "^0.14.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"open": "^10.1.0",
|
||||
"shell-quote": "^1.8.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"description": "Server-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"typescript": "^5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0",
|
||||
|
|
|
|||
|
|
@ -243,6 +243,33 @@ app.post("/mcp", async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.delete("/mcp", async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
console.log(`Received DELETE message for sessionId ${sessionId}`);
|
||||
let serverTransport: Transport | undefined;
|
||||
if (sessionId) {
|
||||
try {
|
||||
serverTransport = serverTransports.get(
|
||||
sessionId,
|
||||
) as StreamableHTTPClientTransport;
|
||||
if (!serverTransport) {
|
||||
res.status(404).end("Transport not found for sessionId " + sessionId);
|
||||
} else {
|
||||
await (
|
||||
serverTransport as StreamableHTTPClientTransport
|
||||
).terminateSession();
|
||||
webAppTransports.delete(sessionId);
|
||||
serverTransports.delete(sessionId);
|
||||
console.log(`Transports removed for sessionId ${sessionId}`);
|
||||
}
|
||||
res.status(200).end();
|
||||
} catch (error) {
|
||||
console.error("Error in /mcp route:", error);
|
||||
res.status(500).json(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/stdio", async (req, res) => {
|
||||
try {
|
||||
console.log("New connection");
|
||||
|
|
|
|||
Loading…
Reference in a new issue