From bff09e04095b4b4ca9dc59eff79b617e99faf682 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Tue, 27 May 2025 17:28:04 -0700 Subject: [PATCH 01/34] Resizable left sidebar Make left sidebar resizable so long URLs can be viewed in their entirety. Fixes https://github.com/modelcontextprotocol/inspector/issues/457 Vibe-coded with Cursor --- client/src/App.tsx | 67 ++++++++++++++---------- client/src/components/Sidebar.tsx | 2 +- client/src/lib/hooks/useDraggablePane.ts | 49 +++++++++++++++++ 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 68065e24..b57107d3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -28,7 +28,7 @@ import React, { useState, } from "react"; import { useConnection } from "./lib/hooks/useConnection"; -import { useDraggablePane } from "./lib/hooks/useDraggablePane"; +import { useDraggablePane, useDraggableSidebar } from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -163,6 +163,7 @@ const App = () => { const progressTokenRef = useRef(0); const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + const { width: sidebarWidth, isDragging: isSidebarDragging, handleDragStart: handleSidebarDragStart } = useDraggableSidebar(320); const { connectionStatus, @@ -562,32 +563,44 @@ const App = () => { return (
- +
+ + {/* Drag handle for resizing sidebar */} +
+
{mcpClient ? ( diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 516f48d5..2ae3f6b0 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -214,7 +214,7 @@ const Sidebar = ({ }, [generateMCPServerFile, toast, reportError]); return ( -
+

diff --git a/client/src/lib/hooks/useDraggablePane.ts b/client/src/lib/hooks/useDraggablePane.ts index 5c0e3573..77e19b8d 100644 --- a/client/src/lib/hooks/useDraggablePane.ts +++ b/client/src/lib/hooks/useDraggablePane.ts @@ -51,3 +51,52 @@ export function useDraggablePane(initialHeight: number) { handleDragStart, }; } + +export function useDraggableSidebar(initialWidth: number) { + const [width, setWidth] = useState(initialWidth); + const [isDragging, setIsDragging] = useState(false); + const dragStartX = useRef(0); + const dragStartWidth = useRef(0); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + setIsDragging(true); + dragStartX.current = e.clientX; + dragStartWidth.current = width; + document.body.style.userSelect = "none"; + }, + [width], + ); + + const handleDragMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + const deltaX = e.clientX - dragStartX.current; + const newWidth = Math.max(200, Math.min(600, dragStartWidth.current + deltaX)); + setWidth(newWidth); + }, + [isDragging], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + return () => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + }; + } + }, [isDragging, handleDragMove, handleDragEnd]); + + return { + width, + isDragging, + handleDragStart, + }; +} From 0a3b994540273a3b5a2c1afef2430bb4516d0fec Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Tue, 27 May 2025 17:33:51 -0700 Subject: [PATCH 02/34] npm run prettier-fix --- client/src/App.tsx | 29 ++++++++++++++++++++---- client/src/lib/hooks/useDraggablePane.ts | 5 +++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index b57107d3..685bf50f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -28,7 +28,10 @@ import React, { useState, } from "react"; import { useConnection } from "./lib/hooks/useConnection"; -import { useDraggablePane, useDraggableSidebar } from "./lib/hooks/useDraggablePane"; +import { + useDraggablePane, + useDraggableSidebar, +} from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -163,7 +166,11 @@ const App = () => { const progressTokenRef = useRef(0); const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); - const { width: sidebarWidth, isDragging: isSidebarDragging, handleDragStart: handleSidebarDragStart } = useDraggableSidebar(320); + const { + width: sidebarWidth, + isDragging: isSidebarDragging, + handleDragStart: handleSidebarDragStart, + } = useDraggableSidebar(320); const { connectionStatus, @@ -564,7 +571,12 @@ const App = () => { return (
{ {/* Drag handle for resizing sidebar */}
diff --git a/client/src/lib/hooks/useDraggablePane.ts b/client/src/lib/hooks/useDraggablePane.ts index 77e19b8d..4ee5af54 100644 --- a/client/src/lib/hooks/useDraggablePane.ts +++ b/client/src/lib/hooks/useDraggablePane.ts @@ -72,7 +72,10 @@ export function useDraggableSidebar(initialWidth: number) { (e: MouseEvent) => { if (!isDragging) return; const deltaX = e.clientX - dragStartX.current; - const newWidth = Math.max(200, Math.min(600, dragStartWidth.current + deltaX)); + const newWidth = Math.max( + 200, + Math.min(600, dragStartWidth.current + deltaX), + ); setWidth(newWidth); }, [isDragging], From 0e5e94393a3bb7700d0abc8013988dc039cacda7 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Tue, 27 May 2025 18:02:07 -0700 Subject: [PATCH 03/34] Add tip to README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d7c34b97..baf9b322 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) +> **Tip:** +> You can resize the left sidebar by dragging its right edge. This allows you to adjust the space for server configuration, environment variables, and other controls to your preference. + ## Running the Inspector ### Requirements From 46d3d844f8884f2099a5e3fb078b5bf337ab2a9f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 19:20:40 +0100 Subject: [PATCH 04/34] reset authorization url --- client/src/components/AuthDebugger.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index d0b0432d..98d58e23 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -183,6 +183,7 @@ const AuthDebugger = ({ latestError: null, oauthClientInfo: null, authorizationCode: "", + authorizationUrl: "", validationError: null, oauthMetadata: null, statusMessage: { From befadc0042ac31ad649dca02f31097cb387541cd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:53:21 +0100 Subject: [PATCH 05/34] very wip draft auth spec support --- client/src/App.tsx | 3 +- client/src/components/AuthDebugger.tsx | 2 +- client/src/components/OAuthFlowProgress.tsx | 25 +++++++++++++++++ client/src/lib/auth-types.ts | 3 ++ client/src/lib/oauth-state-machine.ts | 31 +++++++++++++++------ 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index cb411452..a30dcc9c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -125,8 +125,9 @@ const App = () => { isInitiatingAuth: false, oauthTokens: null, loading: true, - oauthStep: "metadata_discovery", + oauthStep: "resource_metadata_discovery", oauthMetadata: null, + resourceMetadata: null, oauthClientInfo: null, authorizationUrl: null, authorizationCode: "", diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 98d58e23..083fc39b 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -179,7 +179,7 @@ const AuthDebugger = ({ serverAuthProvider.clear(); updateAuthState({ oauthTokens: null, - oauthStep: "metadata_discovery", + oauthStep: "resource_metadata_discovery", latestError: null, oauthClientInfo: null, authorizationCode: "", diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 396af142..8167afa6 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -79,6 +79,16 @@ export const OAuthFlowProgress = ({ null, ); + const steps: Array = [ + "resource_metadata_discovery", + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", + ]; + const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); useEffect(() => { @@ -124,6 +134,21 @@ export const OAuthFlowProgress = ({

+ + {authState.resourceMetadata && ( +
+ + Retrieved OAuth Resource Metadata from {(new URL('/.well-known/oauth-protected-resource', serverUrl)).href} + +
+                {JSON.stringify(authState.resourceMetadata, null, 2)}
+              
+
+ )} +
Promise; execute: (context: StateMachineContext) => Promise; - nextStep: OAuthStep; } // State machine transitions export const oauthTransitions: Record = { - metadata_discovery: { + resource_metadata_discovery: { canTransition: async () => true, execute: async (context) => { - const metadata = await discoverOAuthMetadata(context.serverUrl); + // TODO: use sdk + const url = new URL("/.well-known/oauth-protected-resource", context.serverUrl); + const response = await fetch(url); + + const resourceMetadata = await response.json(); + context.updateState({ + resourceMetadata: resourceMetadata, + oauthStep: "metadata_discovery", + }); + }, + }, + + metadata_discovery: { + canTransition: async (context) => !!context.state.resourceMetadata, + execute: async (context) => { + // TODO: use sdk + let authServerUrl = context.serverUrl; + if (context.state.resourceMetadata?.authorization_servers?.[0]) { + authServerUrl = context.state.resourceMetadata.authorization_servers[0]; + } + const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } @@ -37,7 +56,6 @@ export const oauthTransitions: Record = { oauthStep: "client_registration", }); }, - nextStep: "client_registration", }, client_registration: { @@ -62,7 +80,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_redirect", }); }, - nextStep: "authorization_redirect", }, authorization_redirect: { @@ -93,7 +110,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_code", }); }, - nextStep: "authorization_code", }, authorization_code: { @@ -114,7 +130,6 @@ export const oauthTransitions: Record = { oauthStep: "token_request", }); }, - nextStep: "token_request", }, token_request: { @@ -144,7 +159,6 @@ export const oauthTransitions: Record = { oauthStep: "complete", }); }, - nextStep: "complete", }, complete: { @@ -152,7 +166,6 @@ export const oauthTransitions: Record = { execute: async () => { // No-op for complete state }, - nextStep: "complete", }, }; From 00893afc0be1577748cac65fc3f95cae2713051c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 May 2025 18:36:48 +0100 Subject: [PATCH 06/34] wip new metadata --- client/src/App.tsx | 2 +- client/src/components/AuthDebugger.tsx | 2 +- client/src/components/OAuthFlowProgress.tsx | 20 ++----------- client/src/lib/auth-types.ts | 1 - client/src/lib/oauth-state-machine.ts | 32 +++++++++------------ 5 files changed, 19 insertions(+), 38 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index a30dcc9c..4708f08e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -125,7 +125,7 @@ const App = () => { isInitiatingAuth: false, oauthTokens: null, loading: true, - oauthStep: "resource_metadata_discovery", + oauthStep: "metadata_discovery", oauthMetadata: null, resourceMetadata: null, oauthClientInfo: null, diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 083fc39b..98d58e23 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -179,7 +179,7 @@ const AuthDebugger = ({ serverAuthProvider.clear(); updateAuthState({ oauthTokens: null, - oauthStep: "resource_metadata_discovery", + oauthStep: "metadata_discovery", latestError: null, oauthClientInfo: null, authorizationCode: "", diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 8167afa6..9f079dde 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -57,6 +57,7 @@ interface OAuthFlowProgressProps { } const steps: Array = [ + "resource_metadata_discovery", "metadata_discovery", "client_registration", "authorization_redirect", @@ -79,16 +80,6 @@ export const OAuthFlowProgress = ({ null, ); - const steps: Array = [ - "resource_metadata_discovery", - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", - ]; - const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); useEffect(() => { @@ -135,8 +126,8 @@ export const OAuthFlowProgress = ({
{authState.resourceMetadata && (
@@ -148,11 +139,6 @@ export const OAuthFlowProgress = ({
)} -
- {provider.getServerMetadata() && (
diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 87e8f8bc..2a9171f0 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -7,7 +7,6 @@ import { // OAuth flow steps export type OAuthStep = - | "resource_metadata_discovery" | "metadata_discovery" | "client_registration" | "authorization_redirect" diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index bef728d7..a48b32e1 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -20,31 +20,26 @@ export interface StateTransition { execute: (context: StateMachineContext) => Promise; } +const fetchProtectedResourceMetadata = async (serverUrl: string): Promise => { + // TODO: use sdk + const url = new URL("/.well-known/oauth-protected-resource", serverUrl); + const response = await fetch(url); + const resourceMetadata = await response.json(); + + return resourceMetadata; +} + // State machine transitions export const oauthTransitions: Record = { - resource_metadata_discovery: { + metadata_discovery: { canTransition: async () => true, execute: async (context) => { - // TODO: use sdk - const url = new URL("/.well-known/oauth-protected-resource", context.serverUrl); - const response = await fetch(url); - const resourceMetadata = await response.json(); - context.updateState({ - resourceMetadata: resourceMetadata, - oauthStep: "metadata_discovery", - }); - }, - }, + try { - metadata_discovery: { - canTransition: async (context) => !!context.state.resourceMetadata, - execute: async (context) => { - // TODO: use sdk - let authServerUrl = context.serverUrl; - if (context.state.resourceMetadata?.authorization_servers?.[0]) { - authServerUrl = context.state.resourceMetadata.authorization_servers[0]; } + const resourceMetadata = fetchProtectedResourceMetadata(serverUrl) + const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); @@ -52,6 +47,7 @@ export const oauthTransitions: Record = { const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); context.provider.saveServerMetadata(parsedMetadata); context.updateState({ + resourceMetadata, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); From c6ddcf4c90951319f9b6bce72c1d3b3ca72c9d9b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 May 2025 18:40:27 +0100 Subject: [PATCH 07/34] fix --- client/src/lib/oauth-state-machine.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index a48b32e1..ca473b49 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -34,11 +34,15 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - - try { - - } - const resourceMetadata = fetchProtectedResourceMetadata(serverUrl) + const authServerUrl = context.serverUrl; + // try { + // const resourceMetadata = await fetchProtectedResourceMetadata(context.serverUrl); + // if (resourceMetadata && resourceMetadata) { + // authServerUrl = resourceMetadata + // } + // } catch (_error) { + // // pass + // } const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { From b2fa99668efae6b1b0e82d3df854b6546755c765 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 10:19:39 +0100 Subject: [PATCH 08/34] bump typescript sdk --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d46b3ef8..a5e09c6a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", From 73fde03aa4e7d9f5b91bcc5c92d4fd9872b0158d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 10:56:09 +0100 Subject: [PATCH 09/34] working metadata fetch --- client/src/App.tsx | 17 +----- client/src/components/AuthDebugger.tsx | 11 +--- client/src/components/OAuthFlowProgress.tsx | 14 ++++- .../__tests__/AuthDebugger.test.tsx | 15 +---- client/src/lib/auth-types.ts | 21 ++++++- client/src/lib/oauth-state-machine.ts | 39 +++++++------ package-lock.json | 58 ++----------------- 7 files changed, 63 insertions(+), 112 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 4708f08e..55a4541e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,7 @@ 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 { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, @@ -121,20 +121,7 @@ const App = () => { const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state - const [authState, setAuthState] = useState({ - isInitiatingAuth: false, - oauthTokens: null, - loading: true, - oauthStep: "metadata_discovery", - oauthMetadata: null, - resourceMetadata: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, - }); + const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 98d58e23..5565a77a 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } 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"; @@ -178,14 +178,7 @@ const AuthDebugger = ({ ); serverAuthProvider.clear(); updateAuthState({ - oauthTokens: null, - oauthStep: "metadata_discovery", - latestError: null, - oauthClientInfo: null, - authorizationCode: "", - authorizationUrl: "", - validationError: null, - oauthMetadata: null, + ...EMPTY_DEBUGGER_STATE, statusMessage: { type: "success", message: "OAuth tokens cleared successfully", diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 9f079dde..6bd50bcb 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -57,7 +57,6 @@ interface OAuthFlowProgressProps { } const steps: Array = [ - "resource_metadata_discovery", "metadata_discovery", "client_registration", "authorization_redirect", @@ -139,6 +138,19 @@ export const OAuthFlowProgress = ({ )} + {authState.resourceMetadataError && ( +
+

+ Failed to retrieve resource metadata, falling back to /.well-known/oauth-authorization-server: +

+

+ {authState.resourceMetadataError.message} + {authState.resourceMetadataError instanceof TypeError + ? " (This could indicate the endpoint doesn't exist or does not have CORS configured)" + : authState.resourceMetadataError['status'] && ` (${authState.resourceMetadataError['status']})`} +

+
+ )} {provider.getServerMetadata() && (
diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 7c539661..42a289ff 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -51,6 +51,7 @@ import { auth, } 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< @@ -84,19 +85,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", diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 2a9171f0..40262601 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -3,6 +3,7 @@ import { OAuthClientInformationFull, OAuthClientInformation, OAuthTokens, + OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; // OAuth flow steps @@ -28,8 +29,8 @@ export interface AuthDebuggerState { oauthTokens: OAuthTokens | null; loading: boolean; oauthStep: OAuthStep; - // TODO: use sdk type - resourceMetadata: object | null; + resourceMetadata: OAuthProtectedResourceMetadata | null; + resourceMetadataError: Error | { status: number; statusText: string; message: string } | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; authorizationUrl: string | null; @@ -38,3 +39,19 @@ export interface AuthDebuggerState { statusMessage: StatusMessage | null; validationError: string | null; } + +export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { + isInitiatingAuth: false, + oauthTokens: null, + loading: true, + oauthStep: "metadata_discovery", + oauthMetadata: null, + resourceMetadata: null, + resourceMetadataError: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index ca473b49..5b2925ac 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -5,8 +5,9 @@ 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; @@ -20,29 +21,28 @@ export interface StateTransition { execute: (context: StateMachineContext) => Promise; } -const fetchProtectedResourceMetadata = async (serverUrl: string): Promise => { - // TODO: use sdk - const url = new URL("/.well-known/oauth-protected-resource", serverUrl); - const response = await fetch(url); - const resourceMetadata = await response.json(); - - return resourceMetadata; -} - // State machine transitions export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - const authServerUrl = context.serverUrl; - // try { - // const resourceMetadata = await fetchProtectedResourceMetadata(context.serverUrl); - // if (resourceMetadata && resourceMetadata) { - // authServerUrl = resourceMetadata - // } - // } catch (_error) { - // // pass - // } + let authServerUrl = 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 = resourceMetadata.authorization_servers[0]; + } + } catch (e) { + console.info(`Failed to find protected resource metadata: ${e}`); + console.log(e); + if (e instanceof Error) { + resourceMetadataError = e; + } else { + resourceMetadataError = new Error(String(e)); + } + } const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { @@ -52,6 +52,7 @@ export const oauthTransitions: Record = { context.provider.saveServerMetadata(parsedMetadata); context.updateState({ resourceMetadata, + resourceMetadataError, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); diff --git a/package-lock.json b/package-lock.json index 2920ca59..6e5d73f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -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.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.0.tgz", + "integrity": "sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==", "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", From 82858bed30fd5f93192f65c9dc7ea6e6deadbfdf Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 11:17:21 +0100 Subject: [PATCH 10/34] show proper authserverurl --- client/src/components/OAuthFlowProgress.tsx | 72 +++++++++++++-------- client/src/lib/auth-types.ts | 2 + client/src/lib/oauth-state-machine.ts | 5 +- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 6bd50bcb..2e74bf68 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -128,38 +128,54 @@ export const OAuthFlowProgress = ({ label="Metadata Discovery" {...getStepProps("metadata_discovery")} > - {authState.resourceMetadata && ( -
- - Retrieved OAuth Resource Metadata from {(new URL('/.well-known/oauth-protected-resource', serverUrl)).href} - -
-                {JSON.stringify(authState.resourceMetadata, null, 2)}
-              
-
- )} - {authState.resourceMetadataError && ( -
-

- Failed to retrieve resource metadata, falling back to /.well-known/oauth-authorization-server: -

-

- {authState.resourceMetadataError.message} - {authState.resourceMetadataError instanceof TypeError - ? " (This could indicate the endpoint doesn't exist or does not have CORS configured)" - : authState.resourceMetadataError['status'] && ` (${authState.resourceMetadataError['status']})`} -

-
- )} {provider.getServerMetadata() && (
- Retrieved OAuth Metadata from {serverUrl} - /.well-known/oauth-authorization-server + OAuth Metadata Sources + {!authState.resourceMetadata && " ℹ️"} -
-                {JSON.stringify(provider.getServerMetadata(), null, 2)}
-              
+ + {authState.resourceMetadata && ( +
+

Resource Metadata:

+

+ From {new URL('/.well-known/oauth-protected-resource', serverUrl).href} +

+
+                    {JSON.stringify(authState.resourceMetadata, null, 2)}
+                  
+
+ )} + + {authState.resourceMetadataError && ( +
+

+ ℹ️ No resource metadata available from {' '} + + {new URL('/.well-known/oauth-protected-resource', serverUrl).href} + +

+

+ Resource metadata was added in the 2025-DRAFT-v2 specification update +
+ {authState.resourceMetadataError.message} + {authState.resourceMetadataError instanceof TypeError + && " (This could indicate the endpoint doesn't exist or does not have CORS configured)"} +

+
+ )} + + {provider.getServerMetadata() && ( +
+

Authorization Server Metadata:

+ {authState.authServerUrl &&

+ From {new URL('/.well-known/oauth-authorization-server', authState.authServerUrl).href} +

} +
+                    {JSON.stringify(provider.getServerMetadata(), null, 2)}
+                  
+
+ )}
)} diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 40262601..2541e822 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -31,6 +31,7 @@ export interface AuthDebuggerState { oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; resourceMetadataError: Error | { status: number; statusText: string; message: string } | null; + authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; authorizationUrl: string | null; @@ -48,6 +49,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { oauthMetadata: null, resourceMetadata: null, resourceMetadataError: null, + authServerUrl: null, oauthClientInfo: null, authorizationUrl: null, authorizationCode: "", diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 5b2925ac..939e5d6c 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -26,13 +26,13 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - let authServerUrl = 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 = resourceMetadata.authorization_servers[0]; + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { console.info(`Failed to find protected resource metadata: ${e}`); @@ -53,6 +53,7 @@ export const oauthTransitions: Record = { context.updateState({ resourceMetadata, resourceMetadataError, + authServerUrl, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); From 0dbf75a66427f1f430095d567595e373fcaf185b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 11:50:09 +0100 Subject: [PATCH 11/34] store state before redirect --- client/src/App.tsx | 31 +++++++++++++++----- client/src/components/AuthDebugger.tsx | 6 ++++ client/src/components/OAuthDebugCallback.tsx | 19 ++++++++++-- client/src/lib/constants.ts | 1 + 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 55a4541e..8be23a5b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -234,21 +234,36 @@ const App = () => { ({ authorizationCode, errorMsg, + restoredState, }: { authorizationCode?: string; errorMsg?: string; + restoredState?: AuthDebuggerState; }) => { setIsAuthDebuggerVisible(true); - if (authorizationCode) { + + if (restoredState) { + // Restore the previous auth state updateAuthState({ - authorizationCode, - oauthStep: "token_request", - }); - } - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), + ...restoredState, + // Update with the new authorization code if provided + authorizationCode: authorizationCode || restoredState.authorizationCode, + oauthStep: authorizationCode ? "token_request" : restoredState.oauthStep, + latestError: errorMsg ? new Error(errorMsg) : restoredState.latestError, }); + } else { + // Fallback to the original behavior if no state was restored + if (authorizationCode) { + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); + } + if (errorMsg) { + updateAuthState({ + latestError: new Error(errorMsg), + }); + } } }, [], diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 5565a77a..0ef8e70b 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -5,6 +5,7 @@ import { AlertCircle } from "lucide-react"; 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; @@ -141,6 +142,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; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 88d931c0..af9944ac 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -9,9 +9,11 @@ interface OAuthCallbackProps { onConnect: ({ authorizationCode, errorMsg, + restoredState, }: { authorizationCode?: string; errorMsg?: string; + restoredState?: any; }) => void; } @@ -34,6 +36,19 @@ 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 @@ -50,8 +65,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(() => { diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 4c3e27aa..3acbda91 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -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 From 20e961ce5c2286de5eee0503a85cc8e0005547ff Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 12:09:52 +0100 Subject: [PATCH 12/34] resumption working --- client/src/App.tsx | 78 +++++++++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8be23a5b..f3f1180e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import { import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; +import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, @@ -231,7 +232,7 @@ const App = () => { // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( - ({ + async ({ authorizationCode, errorMsg, restoredState, @@ -241,29 +242,64 @@ const App = () => { restoredState?: AuthDebuggerState; }) => { setIsAuthDebuggerVisible(true); - - if (restoredState) { - // Restore the previous auth state + + if (errorMsg) { updateAuthState({ - ...restoredState, - // Update with the new authorization code if provided - authorizationCode: authorizationCode || restoredState.authorizationCode, - oauthStep: authorizationCode ? "token_request" : restoredState.oauthStep, - latestError: errorMsg ? new Error(errorMsg) : restoredState.latestError, + latestError: new Error(errorMsg), }); - } else { + 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 - if (authorizationCode) { - updateAuthState({ - authorizationCode, - oauthStep: "token_request", - }); - } - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), - }); - } + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); } }, [], From 35eab580efa9010c88ff43dd91b483e61bcc43a1 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 12:26:53 +0100 Subject: [PATCH 13/34] switch to metadata from state --- client/src/components/AuthDebugger.tsx | 19 ++++++++++++++++++- client/src/components/OAuthFlowProgress.tsx | 6 +++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 0ef8e70b..a90b1061 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,4 +1,4 @@ -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"; @@ -60,6 +60,23 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { + // Initialize loading state + useEffect(() => { + if (authState.loading && serverUrl) { + // Check if we have existing tokens + const checkTokens = async () => { + const provider = new DebugInspectorOAuthClientProvider(serverUrl); + const existingTokens = await provider.tokens(); + + updateAuthState({ + loading: false, + oauthTokens: existingTokens || null, + }); + }; + + checkTokens(); + } + }, [serverUrl, authState.loading, updateAuthState]); const startOAuthFlow = useCallback(() => { if (!serverUrl) { updateAuthState({ diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 2e74bf68..f86ceec8 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -128,7 +128,7 @@ export const OAuthFlowProgress = ({ label="Metadata Discovery" {...getStepProps("metadata_discovery")} > - {provider.getServerMetadata() && ( + {authState.oauthMetadata && (
OAuth Metadata Sources @@ -165,14 +165,14 @@ export const OAuthFlowProgress = ({ )} - {provider.getServerMetadata() && ( + {authState.oauthMetadata && (

Authorization Server Metadata:

{authState.authServerUrl &&

From {new URL('/.well-known/oauth-authorization-server', authState.authServerUrl).href}

}
-                    {JSON.stringify(provider.getServerMetadata(), null, 2)}
+                    {JSON.stringify(authState.oauthMetadata, null, 2)}
                   
)} From cc49358f5ab7c037fd7349031512b4caac345795 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 13:42:54 +0100 Subject: [PATCH 14/34] add test + prettier --- client/src/App.tsx | 11 +++- client/src/components/AuthDebugger.tsx | 6 +- client/src/components/OAuthDebugCallback.tsx | 6 +- client/src/components/OAuthFlowProgress.tsx | 52 ++++++++++++--- .../__tests__/AuthDebugger.test.tsx | 66 +++++++++++++++++++ client/src/lib/auth-types.ts | 7 +- client/src/lib/oauth-state-machine.ts | 14 +++- 7 files changed, 139 insertions(+), 23 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index f3f1180e..74cdca19 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -122,7 +122,8 @@ const App = () => { const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state - const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); + const [authState, setAuthState] = + useState(EMPTY_DEBUGGER_STATE); // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { @@ -268,7 +269,10 @@ const App = () => { }); // Continue stepping through the OAuth flow from where we left off - while (currentState.oauthStep !== "complete" && currentState.oauthStep !== "authorization_code") { + while ( + currentState.oauthStep !== "complete" && + currentState.oauthStep !== "authorization_code" + ) { await stateMachine.executeStep(currentState); } @@ -286,7 +290,8 @@ const App = () => { } catch (error) { console.error("OAuth continuation error:", error); updateAuthState({ - latestError: error instanceof Error ? error : new Error(String(error)), + 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)}`, diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index a90b1061..b88758d4 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -67,13 +67,13 @@ const AuthDebugger = ({ const checkTokens = async () => { const provider = new DebugInspectorOAuthClientProvider(serverUrl); const existingTokens = await provider.tokens(); - + updateAuthState({ loading: false, oauthTokens: existingTokens || null, }); }; - + checkTokens(); } }, [serverUrl, authState.loading, updateAuthState]); @@ -162,7 +162,7 @@ const AuthDebugger = ({ // Store the current auth state before redirecting sessionStorage.setItem( SESSION_KEYS.AUTH_DEBUGGER_STATE, - JSON.stringify(currentState) + JSON.stringify(currentState), ); // Open the authorization URL automatically window.location.href = currentState.authorizationUrl; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index af9944ac..17705abb 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -36,9 +36,11 @@ 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); + const storedState = sessionStorage.getItem( + SESSION_KEYS.AUTH_DEBUGGER_STATE, + ); let restoredState = null; if (storedState) { try { diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index f86ceec8..b9b67c6c 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -139,7 +139,13 @@ export const OAuthFlowProgress = ({

Resource Metadata:

- From {new URL('/.well-known/oauth-protected-resource', serverUrl).href} + From{" "} + { + new URL( + "/.well-known/oauth-protected-resource", + serverUrl, + ).href + }

                     {JSON.stringify(authState.resourceMetadata, null, 2)}
@@ -150,17 +156,35 @@ export const OAuthFlowProgress = ({
               {authState.resourceMetadataError && (
                 

- ℹ️ No resource metadata available from {' '} - - {new URL('/.well-known/oauth-protected-resource', serverUrl).href} + ℹ️ No resource metadata available from{" "} + + { + new URL( + "/.well-known/oauth-protected-resource", + serverUrl, + ).href + }

- Resource metadata was added in the 2025-DRAFT-v2 specification update + Resource metadata was added in the{" "} + + 2025-DRAFT-v2 specification update +
{authState.resourceMetadataError.message} - {authState.resourceMetadataError instanceof TypeError - && " (This could indicate the endpoint doesn't exist or does not have CORS configured)"} + {authState.resourceMetadataError instanceof TypeError && + " (This could indicate the endpoint doesn't exist or does not have CORS configured)"}

)} @@ -168,9 +192,17 @@ export const OAuthFlowProgress = ({ {authState.oauthMetadata && (

Authorization Server Metadata:

- {authState.authServerUrl &&

- From {new URL('/.well-known/oauth-authorization-server', authState.authServerUrl).href} -

} + {authState.authServerUrl && ( +

+ From{" "} + { + new URL( + "/.well-known/oauth-authorization-server", + authState.authServerUrl, + ).href + } +

+ )}
                     {JSON.stringify(authState.oauthMetadata, null, 2)}
                   
diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 42a289ff..fe5a94f9 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -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,6 +50,7 @@ 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"; @@ -67,6 +69,10 @@ const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< typeof exchangeAuthorization >; const mockAuth = auth as jest.MockedFunction; +const mockDiscoverOAuthProtectedResourceMetadata = + discoverOAuthProtectedResourceMetadata as jest.MockedFunction< + typeof discoverOAuthProtectedResourceMetadata + >; const sessionStorageMock = { getItem: jest.fn(), @@ -100,6 +106,7 @@ describe("AuthDebugger", () => { mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); + mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(null); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -421,4 +428,63 @@ 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 + delete (window as any).location; + window.location = { href: "" } as any; + + // 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, loading: false }, + }); + }); + + // 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]); + + 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", + }), + }); + }); + }); }); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 2541e822..e69dcd12 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -30,7 +30,10 @@ export interface AuthDebuggerState { loading: boolean; oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: Error | { status: number; statusText: string; message: string } | null; + resourceMetadataError: + | Error + | { status: number; statusText: string; message: string } + | null; authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; @@ -56,4 +59,4 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { latestError: null, statusMessage: null, validationError: null, -} +}; diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 939e5d6c..d05369c4 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -7,7 +7,10 @@ import { exchangeAuthorization, discoverOAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadataSchema, OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthMetadataSchema, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; export interface StateMachineContext { state: AuthDebuggerState; @@ -30,8 +33,13 @@ export const oauthTransitions: Record = { let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(context.serverUrl); - if (resourceMetadata && resourceMetadata.authorization_servers?.length) { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + context.serverUrl, + ); + if ( + resourceMetadata && + resourceMetadata.authorization_servers?.length + ) { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { From 03bb303bf9f1c07fba565c7771c5ad5d0c1ae70e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 14:10:32 +0100 Subject: [PATCH 15/34] rm loading, prm test --- client/src/App.tsx | 2 - client/src/components/AuthDebugger.tsx | 26 ++-- .../__tests__/AuthDebugger.test.tsx | 123 +++++++++++++++++- client/src/lib/auth-types.ts | 2 - 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 74cdca19..a332f9aa 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -329,8 +329,6 @@ const App = () => { } } catch (error) { console.error("Error loading OAuth tokens:", error); - } finally { - updateAuthState({ loading: false }); } }; diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index b88758d4..9757af95 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -60,23 +60,23 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { - // Initialize loading state + // Check for existing tokens on mount useEffect(() => { - if (authState.loading && serverUrl) { - // Check if we have existing tokens + if (serverUrl && !authState.oauthTokens) { const checkTokens = async () => { const provider = new DebugInspectorOAuthClientProvider(serverUrl); const existingTokens = await provider.tokens(); - - updateAuthState({ - loading: false, - oauthTokens: existingTokens || null, - }); + if (existingTokens) { + updateAuthState({ + oauthTokens: existingTokens, + oauthStep: "complete", + }); + } }; - checkTokens(); } - }, [serverUrl, authState.loading, updateAuthState]); + }, [serverUrl]); // Only run when serverUrl changes + const startOAuthFlow = useCallback(() => { if (!serverUrl) { updateAuthState({ @@ -241,10 +241,7 @@ const AuthDebugger = ({ )} - {authState.loading ? ( -

Loading authentication status...

- ) : ( -
+
{authState.oauthTokens && (

Access Token:

@@ -286,7 +283,6 @@ const AuthDebugger = ({ the standard automatic flow.

- )}
{ mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); - mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(null); + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( + new Error("No protected resource metadata found") + ); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -448,7 +450,7 @@ describe("AuthDebugger", () => { await act(async () => { renderAuthDebugger({ updateAuthState, - authState: { ...defaultAuthState, loading: false }, + authState: { ...defaultAuthState }, }); }); @@ -471,7 +473,7 @@ describe("AuthDebugger", () => { ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); expect(storedStateCall).toBeDefined(); - const storedState = JSON.parse(storedStateCall![1]); + const storedState = JSON.parse(storedStateCall![1] as string); expect(storedState).toMatchObject({ oauthStep: "authorization_code", @@ -487,4 +489,119 @@ describe("AuthDebugger", () => { }); }); }); + + 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") + ); + }); + }); }); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index e69dcd12..daf1bc15 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -27,7 +27,6 @@ export interface StatusMessage { export interface AuthDebuggerState { isInitiatingAuth: boolean; oauthTokens: OAuthTokens | null; - loading: boolean; oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; resourceMetadataError: @@ -47,7 +46,6 @@ export interface AuthDebuggerState { export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { isInitiatingAuth: false, oauthTokens: null, - loading: true, oauthStep: "metadata_discovery", oauthMetadata: null, resourceMetadata: null, From 11bc04364bfc012b2170be97ce530af1c118e547 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 14:11:01 +0100 Subject: [PATCH 16/34] whitespace --- client/src/components/AuthDebugger.tsx | 74 +++++++++---------- .../__tests__/AuthDebugger.test.tsx | 14 ++-- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 9757af95..48c9cb00 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -242,47 +242,47 @@ const AuthDebugger = ({ )}
- {authState.oauthTokens && ( -
-

Access Token:

-
- {authState.oauthTokens.access_token.substring(0, 25)}... -
+ {authState.oauthTokens && ( +
+

Access Token:

+
+ {authState.oauthTokens.access_token.substring(0, 25)}...
- )} - -
- - - - -
+ )} -

- Choose "Guided" for step-by-step instructions or "Quick" for - the standard automatic flow. -

+
+ + + + +
+ +

+ Choose "Guided" for step-by-step instructions or "Quick" for + the standard automatic flow. +

+
{ mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( - new Error("No protected resource metadata found") + new Error("No protected resource metadata found"), ); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -503,7 +503,7 @@ describe("AuthDebugger", () => { // Mock successful metadata discovery mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( - mockResourceMetadata + mockResourceMetadata, ); mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); @@ -536,7 +536,7 @@ describe("AuthDebugger", () => { // Wait for the metadata to be fetched await waitFor(() => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com" + "https://example.com", ); }); @@ -547,7 +547,7 @@ describe("AuthDebugger", () => { resourceMetadata: mockResourceMetadata, authServerUrl: new URL("https://custom-auth.example.com"), oauthStep: "client_registration", - }) + }), ); }); }); @@ -582,7 +582,7 @@ describe("AuthDebugger", () => { // Wait for the metadata fetch to fail await waitFor(() => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com" + "https://example.com", ); }); @@ -594,13 +594,13 @@ describe("AuthDebugger", () => { // 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") + new URL("https://example.com"), ); }); }); From e74f80db8591c288c394d7912b57e18bf93e4b78 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 14:26:29 +0100 Subject: [PATCH 17/34] fix tests --- client/src/components/AuthDebugger.tsx | 20 +++++++++++-------- .../__tests__/AuthDebugger.test.tsx | 9 +++++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 48c9cb00..ec09963e 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -64,18 +64,22 @@ const AuthDebugger = ({ useEffect(() => { if (serverUrl && !authState.oauthTokens) { const checkTokens = async () => { - const provider = new DebugInspectorOAuthClientProvider(serverUrl); - const existingTokens = await provider.tokens(); - if (existingTokens) { - updateAuthState({ - oauthTokens: existingTokens, - oauthStep: "complete", - }); + 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]); // Only run when serverUrl changes + }, [serverUrl, updateAuthState, authState.oauthTokens]); const startOAuthFlow = useCallback(() => { if (!serverUrl) { diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index b0df65eb..b2fa4c61 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -202,7 +202,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 @@ -314,6 +314,11 @@ describe("AuthDebugger", () => { }); expect(updateAuthState).toHaveBeenCalledWith({ + authServerUrl: null, + authorizationUrl: null, + isInitiatingAuth: false, + resourceMetadata: null, + resourceMetadataError: null, oauthTokens: null, oauthStep: "metadata_discovery", latestError: null, @@ -355,7 +360,7 @@ describe("AuthDebugger", () => { }); expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - "https://example.com", + new URL("https://example.com"), ); }); From 2df723804b6f86e0d1f85f76dd2648c058ae5059 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 30 May 2025 17:13:53 -0400 Subject: [PATCH 18/34] Normalize the reporting of connection setup and connection errors. The logging is verbose and different for every server type. This is an attempt to make the console output cleaner and more readable. * In server/index.js - in createTransport() - stringify query params to put them on one line - remove logging of transport creation (done in callers) - in /mcp POST handler - report POST request if session id is present - otherwise, report new StreamableHttp connection request - report creation of server transport - report creation of client transport - in /stdio GET handler - report new STDIO connection request - report creation of server transport - report creation of client transport - report 401 as authentication error without stack - in stderr data handler - if it includes "MODULE_NOT_FOUND" - report "Command not found" - remove transports - otherwise send to client as usual - in /sse GET handler - report SSE connection request - report 401 as authentication error without stack - report 404 as server that may not support SSE - report error with "ECONNREFUSED" as "Connection refused" without stack - only create, map, and proxy transports if serverTransport was successfully created - in /message POST handler - report received POST message * In mcpProxy.ts - in onServerError() - if error message includes 404 OR error.cause includes ECONNREFUSED, report "Connection refused." --- server/src/index.ts | 104 ++++++++++++++++++++++++----------------- server/src/mcpProxy.ts | 9 +++- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 1586d0a0..70023611 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -53,7 +53,7 @@ const serverTransports: Map = new Map(); / const createTransport = async (req: express.Request): Promise => { const query = req.query; - console.log("Query parameters:", query); + console.log("Query parameters:", JSON.stringify(query)); const transportType = query.transportType as string; @@ -65,7 +65,7 @@ const createTransport = async (req: express.Request): Promise => { const { cmd, args } = findActualExecutable(command, origArgs); - console.log(`Stdio transport: command=${cmd}, args=${args}`); + console.log(`STDIO transport: command=${cmd}, args=${args}`); const transport = new StdioClientTransport({ command: cmd, @@ -75,8 +75,6 @@ const createTransport = async (req: express.Request): Promise => { }); await transport.start(); - - console.log("Spawned stdio transport"); return transport; } else if (transportType === "sse") { const url = query.url as string; @@ -104,8 +102,6 @@ const createTransport = async (req: express.Request): Promise => { }, }); await transport.start(); - - console.log("Connected to SSE transport"); return transport; } else if (transportType === "streamable-http") { const headers: HeadersInit = { @@ -130,7 +126,6 @@ const createTransport = async (req: express.Request): Promise => { }, ); await transport.start(); - console.log("Connected to Streamable HTTP transport"); return transport; } else { console.error(`Invalid transport type: ${transportType}`); @@ -159,11 +154,10 @@ app.get("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; - console.log(`Received POST message for sessionId ${sessionId}`); let serverTransport: Transport | undefined; if (!sessionId) { try { - console.log("New streamable-http connection"); + console.log("New StreamableHttp connection request"); try { serverTransport = await createTransport(req); } catch (error) { @@ -179,16 +173,17 @@ app.post("/mcp", async (req, res) => { throw error; } - console.log("Connected MCP client to server transport"); + console.log("Created StreamableHttp server transport"); const webAppTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (sessionId) => { webAppTransports.set(sessionId, webAppTransport); serverTransports.set(sessionId, serverTransport!); - console.log("Created streamable web app transport " + sessionId); + console.log("New client <-> proxy server sessionId: " + sessionId); }, }); + console.log("Created StreamableHttp client transport"); await webAppTransport.start(); @@ -207,6 +202,7 @@ app.post("/mcp", async (req, res) => { res.status(500).json(error); } } else { + console.log(`Received POST message for sessionId ${sessionId}`); try { const transport = webAppTransports.get( sessionId, @@ -255,15 +251,15 @@ app.delete("/mcp", async (req, res) => { app.get("/stdio", async (req, res) => { try { - console.log("New connection"); + console.log("New STDIO connection request"); let serverTransport: Transport | undefined; try { serverTransport = await createTransport(req); + console.log("Created server transport"); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( - "Received 401 Unauthorized from MCP server:", - error.message, + "Received 401 Unauthorized from MCP server. Authentication failure." ); res.status(401).json(error); return; @@ -272,23 +268,37 @@ app.get("/stdio", async (req, res) => { throw error; } - console.log("Connected MCP client to backing server transport"); - const webAppTransport = new SSEServerTransport("/message", res); + console.log("Created client transport"); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); serverTransports.set(webAppTransport.sessionId, serverTransport); - console.log("Created client/server transports"); await webAppTransport.start(); (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); + if (chunk.toString().includes("MODULE_NOT_FOUND")) { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: "Command not found, transports removed", + }, + }); + webAppTransport.close(); + serverTransport.close(); + webAppTransports.delete(webAppTransport.sessionId); + serverTransports.delete(webAppTransport.sessionId); + console.error("Command not found, transports removed"); + } else { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + } }); mcpProxy({ @@ -296,7 +306,6 @@ app.get("/stdio", async (req, res) => { transportToServer: serverTransport, }); - console.log("Set up MCP proxy"); } catch (error) { console.error("Error in /stdio route:", error); res.status(500).json(error); @@ -306,7 +315,7 @@ app.get("/stdio", async (req, res) => { app.get("/sse", async (req, res) => { try { console.log( - "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", + "New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp", ); let serverTransport: Transport | undefined; try { @@ -314,32 +323,39 @@ app.get("/sse", async (req, res) => { } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( - "Received 401 Unauthorized from MCP server:", - error.message, + "Received 401 Unauthorized from MCP server. Authentication failure." ); res.status(401).json(error); return; + } else if (error instanceof SseError && error.code === 404) { + console.error( + "Received 404 not found from MCP server. Does the MCP server support SSE?", + ); + res.status(404).json(error); + return; + } else if (JSON.stringify(error).includes("ECONNREFUSED")) { + console.error("Connection refused. Is the MCP server running?"); + res.status(500).json(error); + } else { + throw error; } - - throw error; } - console.log("Connected MCP client to backing server transport"); + if (serverTransport) { + const webAppTransport = new SSEServerTransport("/message", res); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); + console.log("Created client transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport!); + console.log("Created server transport"); - const webAppTransport = new SSEServerTransport("/message", res); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); - console.log("Created client transport"); - serverTransports.set(webAppTransport.sessionId, serverTransport); - console.log("Created server transport"); + await webAppTransport.start(); - await webAppTransport.start(); + mcpProxy({ + transportToClient: webAppTransport, + transportToServer: serverTransport, + }); + } - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: serverTransport, - }); - - console.log("Set up MCP proxy"); } catch (error) { console.error("Error in /sse route:", error); res.status(500).json(error); @@ -349,7 +365,7 @@ app.get("/sse", async (req, res) => { app.post("/message", async (req, res) => { try { const sessionId = req.query.sessionId; - console.log(`Received message for sessionId ${sessionId}`); + console.log(`Received POST message for sessionId ${sessionId}`); const transport = webAppTransports.get( sessionId as string, diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 12660504..09ca4012 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -6,7 +6,14 @@ function onClientError(error: Error) { } function onServerError(error: Error) { - console.error("Error from MCP server:", error); + if ( + (error?.message && error.message.includes("Error POSTing to endpoint (HTTP 404)")) || + (error?.cause && JSON.stringify(error.cause).includes('ECONNREFUSED')) + ) { + console.error("Connection refused. Is the MCP server running?"); + } else { + console.error("Error from MCP server:", error); + } } export default function mcpProxy({ From b732ab408588c9f955adda9a9f78314e95885a25 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 30 May 2025 17:51:22 -0400 Subject: [PATCH 19/34] prettier --- server/src/index.ts | 8 +++----- server/src/mcpProxy.ts | 9 +++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 70023611..8fdce368 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -259,7 +259,7 @@ app.get("/stdio", async (req, res) => { } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( - "Received 401 Unauthorized from MCP server. Authentication failure." + "Received 401 Unauthorized from MCP server. Authentication failure.", ); res.status(401).json(error); return; @@ -305,7 +305,6 @@ app.get("/stdio", async (req, res) => { transportToClient: webAppTransport, transportToServer: serverTransport, }); - } catch (error) { console.error("Error in /stdio route:", error); res.status(500).json(error); @@ -323,7 +322,7 @@ app.get("/sse", async (req, res) => { } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( - "Received 401 Unauthorized from MCP server. Authentication failure." + "Received 401 Unauthorized from MCP server. Authentication failure.", ); res.status(401).json(error); return; @@ -333,7 +332,7 @@ app.get("/sse", async (req, res) => { ); res.status(404).json(error); return; - } else if (JSON.stringify(error).includes("ECONNREFUSED")) { + } else if (JSON.stringify(error).includes("ECONNREFUSED")) { console.error("Connection refused. Is the MCP server running?"); res.status(500).json(error); } else { @@ -355,7 +354,6 @@ app.get("/sse", async (req, res) => { transportToServer: serverTransport, }); } - } catch (error) { console.error("Error in /sse route:", error); res.status(500).json(error); diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 09ca4012..15167305 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -6,10 +6,11 @@ function onClientError(error: Error) { } function onServerError(error: Error) { - if ( - (error?.message && error.message.includes("Error POSTing to endpoint (HTTP 404)")) || - (error?.cause && JSON.stringify(error.cause).includes('ECONNREFUSED')) - ) { + if ( + (error?.message && + error.message.includes("Error POSTing to endpoint (HTTP 404)")) || + (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) + ) { console.error("Connection refused. Is the MCP server running?"); } else { console.error("Error from MCP server:", error); From 2d7936d10b7abeed521091cb3c3173230847abe7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Jun 2025 16:20:16 +0100 Subject: [PATCH 20/34] fix type errors --- client/src/components/OAuthDebugCallback.tsx | 3 ++- .../src/components/__tests__/AuthDebugger.test.tsx | 12 ++++++++++-- client/src/lib/auth-types.ts | 5 +---- package.json | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 17705abb..99774297 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -4,6 +4,7 @@ import { generateOAuthErrorDescription, parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; +import { AuthDebuggerState } from "@/lib/auth-types"; interface OAuthCallbackProps { onConnect: ({ @@ -13,7 +14,7 @@ interface OAuthCallbackProps { }: { authorizationCode?: string; errorMsg?: string; - restoredState?: any; + restoredState?: AuthDebuggerState; }) => void; } diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index b2fa4c61..772b3d43 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -441,8 +441,16 @@ describe("AuthDebugger", () => { const updateAuthState = jest.fn(); // Mock window.location.href setter - delete (window as any).location; - window.location = { href: "" } as any; + 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({ diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index daf1bc15..5e8113ef 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -29,10 +29,7 @@ export interface AuthDebuggerState { oauthTokens: OAuthTokens | null; oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: - | Error - | { status: number; statusText: string; message: string } - | null; + resourceMetadataError: Error | null; authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; diff --git a/package.json b/package.json index a5e09c6a..6ee5719e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "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" }, From 98668507c798ce7010e209557ff35be2ebcad92e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Jun 2025 16:20:36 +0100 Subject: [PATCH 21/34] version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6ee5719e..c0f7cf73 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.12.0", + "@modelcontextprotocol/sdk": "^1.12.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", From fa01fafa59e34285bd5e5580201dc4b3ec13ce18 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Jun 2025 16:21:55 +0100 Subject: [PATCH 22/34] version bump + lint --- client/src/components/__tests__/AuthDebugger.test.tsx | 6 +++--- package-lock.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 772b3d43..3727128a 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -445,11 +445,11 @@ describe("AuthDebugger", () => { const locationMock = { ...originalLocation, href: "", - origin: "http://localhost:3000" + origin: "http://localhost:3000", }; - Object.defineProperty(window, 'location', { + Object.defineProperty(window, "location", { writable: true, - value: locationMock + value: locationMock, }); // Setup mocks for OAuth flow diff --git a/package-lock.json b/package-lock.json index 6e5d73f0..36588354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.12.0", + "@modelcontextprotocol/sdk": "^1.12.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -2005,9 +2005,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.0.tgz", - "integrity": "sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", + "integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", From 9371006838de8224046eb8c0d3656b7eb9355af4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 3 Jun 2025 11:02:34 +0100 Subject: [PATCH 23/34] less noisy errors --- client/src/components/__tests__/AuthDebugger.test.tsx | 7 +++++++ client/src/lib/oauth-state-machine.ts | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 3727128a..2130e68b 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -104,6 +104,9 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); + // Supress + jest.spyOn(console, "error").mockImplementation(() => {}); + mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( @@ -124,6 +127,10 @@ describe("AuthDebugger", () => { mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + const renderAuthDebugger = (props: Partial = {}) => { const mergedProps = { ...defaultProps, diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index d05369c4..101a33cf 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -43,8 +43,6 @@ export const oauthTransitions: Record = { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { - console.info(`Failed to find protected resource metadata: ${e}`); - console.log(e); if (e instanceof Error) { resourceMetadataError = e; } else { From e2d3989bb219cd34d249cb1d2cc4c5169e85ae30 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 3 Jun 2025 11:05:10 +0100 Subject: [PATCH 24/34] use scopes from PRM if available --- client/src/lib/oauth-state-machine.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 101a33cf..5f10a783 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -72,9 +72,13 @@ export const oauthTransitions: Record = { 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, { From a387c6ad7e5f006a3138aff5235e42d187cbb8e9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 3 Jun 2025 17:29:22 +0100 Subject: [PATCH 25/34] hook warning --- client/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 00d3bb48..2fbc7494 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -307,7 +307,7 @@ const App = () => { }); } }, - [], + [sseUrl], ); // Load OAuth tokens when sseUrl changes From 2f50a4ba0eecc2774b4ac6544b10149452b96835 Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 26/34] fix: Resource template query parameters not appended to request URI #450 --- client/src/components/ResourcesTab.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 35742799..1293a0f8 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -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,10 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - return template.replace( - /{([^}]+)}/g, - (_, key) => values[key] || `{${key}}`, - ); + console.log("Expanding template:", template, "with values:", values); + const result = new UriTemplate(template).expand(values); + console.log("Filled template:", result); + return result; }; const handleTemplateValueChange = async (key: string, value: string) => { @@ -240,7 +241,8 @@ const ResourcesTab = ({ {selectedTemplate.uriTemplate .match(/{([^}]+)}/g) ?.map((param) => { - const key = param.slice(1, -1); + // Remove leading operator characters (?, &, /, #, ;, +, .) from variable name + const key = param.slice(1, -1).replace(/^[?&/#;+.]/, ""); return (
From f1650d06e3d3a835ecdafe65c8e7ce19f129c317 Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 27/34] use UriTemplate variable names --- client/src/components/ResourcesTab.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 1293a0f8..dd40d1b8 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -238,11 +238,8 @@ const ResourcesTab = ({

{selectedTemplate.description}

- {selectedTemplate.uriTemplate - .match(/{([^}]+)}/g) - ?.map((param) => { - // Remove leading operator characters (?, &, /, #, ;, +, .) from variable name - const key = param.slice(1, -1).replace(/^[?&/#;+.]/, ""); + {new UriTemplate(selectedTemplate.uriTemplate).variableNames + ?.map((key) => { return (
From 07f3e4593b8bb2ffc9cee99fb7813a08ca85f27b Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 28/34] remove logs --- client/src/components/ResourcesTab.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index dd40d1b8..8795e46b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -80,10 +80,7 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - console.log("Expanding template:", template, "with values:", values); - const result = new UriTemplate(template).expand(values); - console.log("Filled template:", result); - return result; + return new UriTemplate(template).expand(values); }; const handleTemplateValueChange = async (key: string, value: string) => { From 26f744866aefde5acaec7a2da6314b8c915ec9be Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 29/34] formatting --- client/src/components/ResourcesTab.tsx | 41 +++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 8795e46b..8ec3f91b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -235,26 +235,27 @@ const ResourcesTab = ({

{selectedTemplate.description}

- {new UriTemplate(selectedTemplate.uriTemplate).variableNames - ?.map((key) => { - return ( -
- - - handleTemplateValueChange(key, value) - } - onInputChange={(value) => - handleTemplateValueChange(key, value) - } - options={completions[key] || []} - /> -
- ); - })} + {new UriTemplate( + selectedTemplate.uriTemplate, + ).variableNames?.map((key) => { + return ( +
+ + + handleTemplateValueChange(key, value) + } + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} + /> +
+ ); + })} + ), +})); + +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(), + subscribeToResource: mockSubscribeToResource, + unsubscribeFromResource: mockUnsubscribeFromResource, + }; + + const renderResourcesTab = (props = {}) => + render( + + + , + ); + + 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(); + }); +}); From c485e57a3995362be56119aa103bf0da021b5fa0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 3 Jun 2025 17:46:43 -0400 Subject: [PATCH 31/34] Add reporting of the Proxy <-> Server sessionId. It's only possible to get the session id for this leg of the proxy if it is a StreamableHttp connection. * In server/src/index.ts - slight format tweak of the Client <-> Proxy message so that the two messages will line up when displayed * In server/src/mcpProxy.ts - add reportedServerSession boolean initialized to false - in transportToServer.onmessage, - if reportedServerSession isn't set yet, report the sessionId if present on the transport, and then set reportedServerSession to true --- server/src/index.ts | 2 +- server/src/mcpProxy.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 1e43eea2..37327fa2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -197,7 +197,7 @@ app.post("/mcp", async (req, res) => { onsessioninitialized: (sessionId) => { webAppTransports.set(sessionId, webAppTransport); serverTransports.set(sessionId, serverTransport!); - console.log("New client <-> proxy server sessionId: " + sessionId); + console.log("Client <-> Proxy sessionId: " + sessionId); }, }); console.log("Created StreamableHttp client transport"); diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 15167305..9dee9de4 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -27,6 +27,8 @@ export default function mcpProxy({ let transportToClientClosed = false; let transportToServerClosed = false; + let reportedServerSession = false; + transportToClient.onmessage = (message) => { transportToServer.send(message).catch((error) => { // Send error response back to client if it was a request (has id) and connection is still open @@ -46,6 +48,15 @@ export default function mcpProxy({ }; transportToServer.onmessage = (message) => { + if (!reportedServerSession) { + if (transportToServer.sessionId) { + // Can only report for StreamableHttp + console.error( + "Proxy <-> Server sessionId: " + transportToServer.sessionId, + ); + } + reportedServerSession = true; + } transportToClient.send(message).catch(onClientError); }; From 93553d591fbfcefca8872009b9603ea849a2658c Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Tue, 3 Jun 2025 22:24:17 -0700 Subject: [PATCH 32/34] Revert "Add tip to README.md" This reverts commit 0e5e94393a3bb7700d0abc8013988dc039cacda7. --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index baf9b322..d7c34b97 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,6 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) -> **Tip:** -> You can resize the left sidebar by dragging its right edge. This allows you to adjust the space for server configuration, environment variables, and other controls to your preference. - ## Running the Inspector ### Requirements From 9508cc46271b5965accda35e98c3a93f5cc4de96 Mon Sep 17 00:00:00 2001 From: dr3s Date: Wed, 4 Jun 2025 14:44:15 -0400 Subject: [PATCH 33/34] pretty --- .../__tests__/ResourcesTab.test.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/client/src/components/__tests__/ResourcesTab.test.tsx b/client/src/components/__tests__/ResourcesTab.test.tsx index dd12cced..d564b02c 100644 --- a/client/src/components/__tests__/ResourcesTab.test.tsx +++ b/client/src/components/__tests__/ResourcesTab.test.tsx @@ -20,7 +20,12 @@ jest.mock("../JsonView", () => { }); jest.mock("@/components/ui/combobox", () => ({ - Combobox: ({ id, value, onChange, placeholder }: { + Combobox: ({ + id, + value, + onChange, + placeholder, + }: { id: string; value: string; onChange: (value: string) => void; @@ -37,7 +42,10 @@ jest.mock("@/components/ui/combobox", () => ({ })); jest.mock("@/components/ui/label", () => ({ - Label: ({ htmlFor, children }: { + Label: ({ + htmlFor, + children, + }: { htmlFor: string; children: React.ReactNode; }) => ( @@ -48,7 +56,12 @@ jest.mock("@/components/ui/label", () => ({ })); jest.mock("@/components/ui/button", () => ({ - Button: ({ children, onClick, disabled, ...props }: { + Button: ({ + children, + onClick, + disabled, + ...props + }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; @@ -139,7 +152,9 @@ describe("ResourcesTab - Template Query Parameters", () => { // 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(); + expect( + screen.getByText("Fetch users with optional filtering and pagination"), + ).toBeInTheDocument(); }); it("should handle template value changes", () => { @@ -180,16 +195,18 @@ describe("ResourcesTab - Template Query Parameters", () => { // 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"); - + 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" + name: "test://users?name=john&limit=10&offset=0", }); }); @@ -217,7 +234,7 @@ describe("ResourcesTab - Template Query Parameters", () => { // 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 @@ -237,7 +254,9 @@ describe("ResourcesTab - Template Query Parameters", () => { fireEvent.click(screen.getByText("Read Resource")); // Should properly encode special characters - expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john%20doe"); + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john%20doe", + ); }); it("should clear template values when switching between templates", () => { @@ -248,7 +267,7 @@ describe("ResourcesTab - Template Query Parameters", () => { }; renderResourcesTab({ - resourceTemplates: [mockResourceTemplate, anotherTemplate] + resourceTemplates: [mockResourceTemplate, anotherTemplate], }); // Select first template and fill values @@ -267,10 +286,10 @@ describe("ResourcesTab - Template Query Parameters", () => { it("should display resource content when a resource is selected", () => { const resourceContent = '{"users": [{"id": 1, "name": "John"}]}'; - + renderResourcesTab({ selectedResource: mockResource, - resourceContent: resourceContent + resourceContent: resourceContent, }); expect(screen.getByTestId("json-view")).toBeInTheDocument(); @@ -280,6 +299,10 @@ describe("ResourcesTab - Template Query Parameters", () => { 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(); + expect( + screen.getByText( + "Select a resource or template from the list to view its contents", + ), + ).toBeInTheDocument(); }); }); From 8ef91e359ecd5d51af8aa0dd315080b47389e19e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 4 Jun 2025 16:13:35 -0400 Subject: [PATCH 34/34] Bump Inspector packages to 0.14.0, and sdk to 1.12.1 --- cli/package.json | 4 ++-- client/package.json | 4 ++-- package-lock.json | 22 +++++++++++----------- package.json | 8 ++++---- server/package.json | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cli/package.json b/cli/package.json index 55f12e15..bd027bfe 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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" } diff --git a/client/package.json b/client/package.json index ee54df3d..daf549fc 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 36588354..846d2eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.13.0", - "@modelcontextprotocol/inspector-client": "^0.13.0", - "@modelcontextprotocol/inspector-server": "^0.13.0", + "@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", @@ -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", @@ -10920,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", diff --git a/package.json b/package.json index c0f7cf73..8742cc8b 100644 --- a/package.json +++ b/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)", @@ -41,9 +41,9 @@ "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/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", diff --git a/server/package.json b/server/package.json index a3333da0..50d74cfc 100644 --- a/server/package.json +++ b/server/package.json @@ -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",