From 46d3d844f8884f2099a5e3fb078b5bf337ab2a9f Mon Sep 17 00:00:00 2001
From: Paul Carleton
+ {JSON.stringify(authState.resourceMetadata, null, 2)}
+
+ + 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']})`} +
+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)}
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 12/28] 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 13/28] 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 14/28] 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 02aacb46a2851cf6c5066659f1bc3b56a44d3c76 Mon Sep 17 00:00:00 2001
From: cliffhall
Date: Thu, 29 May 2025 19:54:36 -0400
Subject: [PATCH 15/28] Send HTTP/DELETE when disconnecting client * In
server/src/index.ts - add delete handler for /mcp endpoint - gets the
server transport for the sessionId - calls terminateSession on it -
removes the webapp and server transports for the sessionId from the maps
- returns status 200 to the client.
* In client/src/lib/hooks/useConnection.ts
- import Transport
- add useState for clientTransport, type Transport or null initialized to null
- in connect() function
- move creation of client transport down a ways, just before calling client.connect with it
- immendiately efter calling client.connect, call setClientTransport with the connected transport (has to happen after, so that what's saved has the abort controller, etc. otherwise it doesn't work to save it prior.
- in disconnect() function
- immediately call clientTransport.terminateSession if transportType is "streamable-http"
- setClientTransport to null
---
client/src/lib/hooks/useConnection.ts | 32 +++++++++++++++++++--------
server/src/index.ts | 27 ++++++++++++++++++++++
2 files changed, 50 insertions(+), 9 deletions(-)
diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts
index 9c825318..39da55cb 100644
--- a/client/src/lib/hooks/useConnection.ts
+++ b/client/src/lib/hooks/useConnection.ts
@@ -45,6 +45,7 @@ import {
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
+import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
interface UseConnectionOptions {
transportType: "stdio" | "sse" | "streamable-http";
@@ -83,6 +84,9 @@ export function useConnection({
const [serverCapabilities, setServerCapabilities] =
useState(null);
const [mcpClient, setMcpClient] = useState(null);
+ const [clientTransport, setClientTransport] = useState(
+ null,
+ );
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
@@ -377,14 +381,6 @@ export function useConnection({
transportType,
);
- const clientTransport =
- transportType === "streamable-http"
- ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
- sessionId: undefined,
- ...transportOptions,
- })
- : new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
-
if (onNotification) {
[
CancelledNotificationSchema,
@@ -414,7 +410,20 @@ export function useConnection({
let capabilities;
try {
- await client.connect(clientTransport);
+ const transport =
+ transportType === "streamable-http"
+ ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
+ sessionId: undefined,
+ ...transportOptions,
+ })
+ : new SSEClientTransport(
+ mcpProxyServerUrl as URL,
+ transportOptions,
+ );
+
+ await client.connect(transport as Transport);
+
+ setClientTransport(transport);
capabilities = client.getServerCapabilities();
const initializeRequest = {
@@ -468,10 +477,15 @@ export function useConnection({
};
const disconnect = async () => {
+ if (transportType === "streamable-http")
+ await (
+ clientTransport as StreamableHTTPClientTransport
+ ).terminateSession();
await mcpClient?.close();
const authProvider = new InspectorOAuthClientProvider(sseUrl);
authProvider.clear();
setMcpClient(null);
+ setClientTransport(null);
setConnectionStatus("disconnected");
setCompletionsSupported(false);
setServerCapabilities(null);
diff --git a/server/src/index.ts b/server/src/index.ts
index 73288f67..1586d0a0 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -226,6 +226,33 @@ app.post("/mcp", async (req, res) => {
}
});
+app.delete("/mcp", async (req, res) => {
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
+ console.log(`Received DELETE message for sessionId ${sessionId}`);
+ let serverTransport: Transport | undefined;
+ if (sessionId) {
+ try {
+ serverTransport = serverTransports.get(
+ sessionId,
+ ) as StreamableHTTPClientTransport;
+ if (!serverTransport) {
+ res.status(404).end("Transport not found for sessionId " + sessionId);
+ } else {
+ await (
+ serverTransport as StreamableHTTPClientTransport
+ ).terminateSession();
+ webAppTransports.delete(sessionId);
+ serverTransports.delete(sessionId);
+ console.log(`Transports removed for sessionId ${sessionId}`);
+ }
+ res.status(200).end();
+ } catch (error) {
+ console.error("Error in /mcp route:", error);
+ res.status(500).json(error);
+ }
+ }
+});
+
app.get("/stdio", async (req, res) => {
try {
console.log("New connection");
From 2d7936d10b7abeed521091cb3c3173230847abe7 Mon Sep 17 00:00:00 2001
From: Paul Carleton
Date: Mon, 2 Jun 2025 16:20:16 +0100
Subject: [PATCH 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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] || []}
+ />
+
+ );
+ })}