From 4f88947292bb08b583194b4594702796ecc096a1 Mon Sep 17 00:00:00 2001 From: SarthakB11 Date: Wed, 1 Jul 2026 21:25:51 +0000 Subject: [PATCH] feat(client): add credentials: 'include' toggle for cookie-auth servers Cookie is a forbidden request header, so JS cannot set it manually. The only standards path to send cookies cross-origin is credentials: 'include' on the fetch call. The inspector never set it, so cookie-auth MCP servers were unreachable from the UI without patching the built bundle. Add a client-side toggle in connection settings, persisted to localStorage, applied at both SSE and streamable-http fetch call sites. Off by default. Tooltip notes the server-side CORS requirements (Access-Control-Allow-Credentials + non-wildcard origin). Closes #1454 Signed-off-by: SarthakB11 --- client/src/App.tsx | 13 +++++ client/src/components/Sidebar.tsx | 34 +++++++++++ .../hooks/__tests__/useConnection.test.tsx | 58 +++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 7 +++ 4 files changed, 112 insertions(+) diff --git a/client/src/App.tsx b/client/src/App.tsx index 7cf6d751a..2aa15e33a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -186,6 +186,9 @@ const App = () => { ); }, ); + const [credentialsInclude, setCredentialsInclude] = useState(() => { + return localStorage.getItem("lastCredentialsInclude") === "true"; + }); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [roots, setRoots] = useState([]); @@ -401,6 +404,7 @@ const App = () => { oauthScope, config, connectionType, + credentialsInclude, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -550,6 +554,13 @@ const App = () => { localStorage.setItem("lastConnectionType", connectionType); }, [connectionType]); + useEffect(() => { + localStorage.setItem( + "lastCredentialsInclude", + credentialsInclude ? "true" : "false", + ); + }, [credentialsInclude]); + useEffect(() => { if (bearerToken) { localStorage.setItem("lastBearerToken", bearerToken); @@ -1406,6 +1417,8 @@ const App = () => { loggingSupported={!!serverCapabilities?.logging || false} connectionType={connectionType} setConnectionType={setConnectionType} + credentialsInclude={credentialsInclude} + setCredentialsInclude={setCredentialsInclude} serverImplementation={serverImplementation} />
void; connectionType: "direct" | "proxy"; setConnectionType: (type: "direct" | "proxy") => void; + credentialsInclude: boolean; + setCredentialsInclude: (value: boolean) => void; serverImplementation?: | (WithIcons & { name?: string; version?: string; websiteUrl?: string }) | null; @@ -109,6 +112,8 @@ const Sidebar = ({ setConfig, connectionType, setConnectionType, + credentialsInclude, + setCredentialsInclude, serverImplementation, }: SidebarProps) => { const [theme, setTheme] = useTheme(); @@ -123,6 +128,8 @@ const Sidebar = ({ const connectionTypeTip = "Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy"; + const credentialsIncludeTip = + "Send browser cookies with direct requests (credentials: 'include'). The target server must respond with Access-Control-Allow-Credentials: true and a non-wildcard Access-Control-Allow-Origin, and any cross-site cookies must use SameSite=None; Secure."; // Reusable error reporter for copy actions const reportError = useCallback( (error: unknown) => { @@ -361,6 +368,33 @@ const Sidebar = ({ {connectionTypeTip} + + {/* Send-cookies toggle - only applies to the direct transport */} + {connectionType === "direct" && ( + + +
+ + setCredentialsInclude(checked === true) + } + data-testid="credentials-include-checkbox" + /> + +
+
+ + {credentialsIncludeTip} + +
+ )} )} diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 875c9e387..1041cd225 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -982,6 +982,64 @@ describe("useConnection", () => { }); }); + describe("Credentials Include (direct connection)", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSSETransport.url = undefined; + mockSSETransport.options = undefined; + }); + + test("passes credentials: 'include' to direct fetch when credentialsInclude is true", async () => { + const props = { + ...defaultProps, + connectionType: "direct" as const, + credentialsInclude: true, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const mockFetch = mockSSETransport.options?.fetch; + expect(mockFetch).toBeDefined(); + + await mockFetch?.("http://test.com", { + headers: { Accept: "text/event-stream" }, + }); + + expect((global.fetch as jest.Mock).mock.calls[0][1]).toHaveProperty( + "credentials", + "include", + ); + }); + + test("omits credentials on direct fetch when credentialsInclude is unset", async () => { + const props = { + ...defaultProps, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const mockFetch = mockSSETransport.options?.fetch; + expect(mockFetch).toBeDefined(); + + await mockFetch?.("http://test.com", { + headers: { Accept: "text/event-stream" }, + }); + + expect((global.fetch as jest.Mock).mock.calls[0][1]).not.toHaveProperty( + "credentials", + ); + }); + }); + describe("Proxy Authentication Headers", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9694b891f..37c5c3e47 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -102,6 +102,10 @@ interface UseConnectionOptions { defaultLoggingLevel?: LoggingLevel; serverImplementation?: Implementation; metadata?: Record; + // When true, pass `credentials: 'include'` on direct-connection fetches so + // the browser attaches cookies stored for the target origin. Applies to the + // direct transport only; proxy connections are unaffected. + credentialsInclude?: boolean; } export function useConnection({ @@ -116,6 +120,7 @@ export function useConnection({ oauthScope, config, connectionType = "proxy", + credentialsInclude = false, onNotification, onPendingRequest, onElicitationRequest, @@ -591,6 +596,7 @@ export function useConnection({ const response = await fetch(url, { ...init, headers: requestHeaders, + ...(credentialsInclude && { credentials: "include" }), }); // Capture protocol-related headers from response @@ -616,6 +622,7 @@ export function useConnection({ const response = await fetch(url, { headers: requestHeaders, ...init, + ...(credentialsInclude && { credentials: "include" }), }); // Capture protocol-related headers from response