Merge branch 'main' into fix-required

This commit is contained in:
Ola Hungerford 2025-06-04 18:35:50 -07:00 • committed by GitHub
commit 6f80feb0b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 902 additions and 218 deletions

View file

@ -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"
}

View file

@ -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",

View file

@ -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 });
}
};

View file

@ -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

View file

@ -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(() => {

View file

@ -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>

View file

@ -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}

View file

@ -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"),
);
});
});
});

View 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();
});
});

View file

@ -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,
};

View file

@ -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

View file

@ -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);

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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");