From 6ec6cc7c5020a8f6c9d4f22b547f16a7968ff08f Mon Sep 17 00:00:00 2001 From: go165 <196723798+go165@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:29:55 +0800 Subject: [PATCH] feat(client): add direct streamable HTTP cookie toggle --- client/src/App.tsx | 10 ++++ client/src/components/Sidebar.tsx | 37 +++++++++++++ .../src/components/__tests__/Sidebar.test.tsx | 38 +++++++++++++ .../hooks/__tests__/useConnection.test.tsx | 53 +++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 7 +++ 5 files changed, 145 insertions(+) diff --git a/client/src/App.tsx b/client/src/App.tsx index 7cf6d751a..477393c17 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -186,6 +186,9 @@ const App = () => { ); }, ); + const [includeCookies, setIncludeCookies] = useState(() => { + return localStorage.getItem("lastIncludeCookies") === "true"; + }); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [roots, setRoots] = useState([]); @@ -401,6 +404,7 @@ const App = () => { oauthScope, config, connectionType, + includeCookies, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -550,6 +554,10 @@ const App = () => { localStorage.setItem("lastConnectionType", connectionType); }, [connectionType]); + useEffect(() => { + localStorage.setItem("lastIncludeCookies", String(includeCookies)); + }, [includeCookies]); + useEffect(() => { if (bearerToken) { localStorage.setItem("lastBearerToken", bearerToken); @@ -1406,6 +1414,8 @@ const App = () => { loggingSupported={!!serverCapabilities?.logging || false} connectionType={connectionType} setConnectionType={setConnectionType} + includeCookies={includeCookies} + setIncludeCookies={setIncludeCookies} serverImplementation={serverImplementation} />
void; connectionType: "direct" | "proxy"; setConnectionType: (type: "direct" | "proxy") => void; + includeCookies: boolean; + setIncludeCookies: (includeCookies: boolean) => void; serverImplementation?: | (WithIcons & { name?: string; version?: string; websiteUrl?: string }) | null; @@ -109,6 +112,8 @@ const Sidebar = ({ setConfig, connectionType, setConnectionType, + includeCookies, + setIncludeCookies, serverImplementation, }: SidebarProps) => { const [theme, setTheme] = useTheme(); @@ -361,6 +366,38 @@ const Sidebar = ({ {connectionTypeTip} + + {transportType === "streamable-http" && + connectionType === "direct" && ( + + +
+ + setIncludeCookies(Boolean(checked)) + } + /> + +
+
+ + Target servers must allow credentials with a non-wildcard + Access-Control-Allow-Origin and + Access-Control-Allow-Credentials: true. + +
+ )} )} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 460161e59..70b1e3842 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -63,6 +63,8 @@ describe("Sidebar", () => { setConfig: jest.fn(), connectionType: "proxy" as const, setConnectionType: jest.fn(), + includeCookies: false, + setIncludeCookies: jest.fn(), }; const renderSidebar = (props = {}) => { @@ -632,6 +634,42 @@ describe("Sidebar", () => { }); }); + describe("Connection settings", () => { + it("shows the cookie toggle for direct Streamable HTTP connections", () => { + renderSidebar({ + transportType: "streamable-http", + connectionType: "direct", + }); + + expect(screen.getByText("Send cookies")).toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { name: /send cookies/i }), + ).toHaveAttribute("aria-checked", "false"); + }); + + it("does not show the cookie toggle for proxy connections", () => { + renderSidebar({ + transportType: "streamable-http", + connectionType: "proxy", + }); + + expect(screen.queryByText("Send cookies")).not.toBeInTheDocument(); + }); + + it("updates the cookie toggle when clicked", () => { + const setIncludeCookies = jest.fn(); + renderSidebar({ + transportType: "streamable-http", + connectionType: "direct", + setIncludeCookies, + }); + + fireEvent.click(screen.getByRole("checkbox", { name: /send cookies/i })); + + expect(setIncludeCookies).toHaveBeenCalledWith(true); + }); + }); + describe("Authentication", () => { const openAuthSection = () => { const button = screen.getByTestId("auth-button"); diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 875c9e387..905233e42 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1414,6 +1414,59 @@ describe("useConnection", () => { expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/"); }); + test("includes browser cookies for direct streamable-http when enabled", async () => { + const directProps = { + ...defaultProps, + transportType: "streamable-http" as const, + connectionType: "direct" as const, + includeCookies: true, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + expect(mockStreamableHTTPTransport.options?.requestInit).toHaveProperty( + "credentials", + "include", + ); + + const mockFetch = mockStreamableHTTPTransport.options?.fetch; + await mockFetch?.("http://test.com/mcp", { cache: "no-store" }); + + expect((global.fetch as jest.Mock).mock.calls[0][1]).toHaveProperty( + "credentials", + "include", + ); + }); + + test("does not include browser cookies for direct streamable-http by default", async () => { + const directProps = { + ...defaultProps, + transportType: "streamable-http" as const, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + expect( + mockStreamableHTTPTransport.options?.requestInit, + ).not.toHaveProperty("credentials"); + + const mockFetch = mockStreamableHTTPTransport.options?.fetch; + await mockFetch?.("http://test.com/mcp", { cache: "no-store" }); + + expect((global.fetch as jest.Mock).mock.calls[0][1]).not.toHaveProperty( + "credentials", + ); + }); + test("uses proxy server URL when connectionType is 'proxy'", async () => { const proxyProps = { ...defaultProps, diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9694b891f..92f53c98f 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -91,6 +91,7 @@ interface UseConnectionOptions { oauthScope?: string; config: InspectorConfig; connectionType?: "direct" | "proxy"; + includeCookies?: boolean; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -116,6 +117,7 @@ export function useConnection({ oauthScope, config, connectionType = "proxy", + includeCookies = false, onNotification, onPendingRequest, onElicitationRequest, @@ -604,6 +606,9 @@ export function useConnection({ break; case "streamable-http": + const credentialsInit = includeCookies + ? { credentials: "include" as RequestCredentials } + : {}; transportOptions = { authProvider: serverAuthProvider, fetch: async ( @@ -616,6 +621,7 @@ export function useConnection({ const response = await fetch(url, { headers: requestHeaders, ...init, + ...credentialsInit, }); // Capture protocol-related headers from response @@ -625,6 +631,7 @@ export function useConnection({ }, requestInit: { headers: requestHeaders, + ...credentialsInit, }, // TODO these should be configurable... reconnectionOptions: {