From 1c406eafbd0a377c0094257810829bff1a343429 Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:52:31 +0200 Subject: [PATCH 01/81] Added UI to provide additional _meta values --- client/src/App.tsx | 15 ++- client/src/components/ToolResults.tsx | 2 +- client/src/components/ToolsTab.tsx | 120 +++++++++++++++++- .../components/__tests__/ToolsTab.test.tsx | 70 ++++++++-- 4 files changed, 188 insertions(+), 19 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index fecd9839..3bf3b887 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -698,7 +698,11 @@ const App = () => { cacheToolOutputSchemas(response.tools); }; - const callTool = async (name: string, params: Record) => { + const callTool = async ( + name: string, + params: Record, + meta?: Record, + ) => { lastToolCallOriginTabRef.current = currentTabRef.current; try { @@ -710,6 +714,7 @@ const App = () => { arguments: params, _meta: { progressToken: progressTokenRef.current++, + ...(meta ?? {}), }, }, }, @@ -1008,10 +1013,14 @@ const App = () => { setNextToolCursor(undefined); cacheToolOutputSchemas([]); }} - callTool={async (name, params) => { + callTool={async ( + name: string, + params: Record, + meta?: Record, + ) => { clearError("tools"); setToolResult(null); - await callTool(name, params); + await callTool(name, params, meta); }} selectedTool={selectedTool} setSelectedTool={(tool) => { diff --git a/client/src/components/ToolResults.tsx b/client/src/components/ToolResults.tsx index 6479b5fb..64798cd9 100644 --- a/client/src/components/ToolResults.tsx +++ b/client/src/components/ToolResults.tsx @@ -156,7 +156,7 @@ const ToolResults = ({ )} {structuredResult._meta && (
-
Meta:
+
Meta Schema:
diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 2654feed..798467c1 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -42,7 +42,11 @@ const ToolsTab = ({ tools: Tool[]; listTools: () => void; clearTools: () => void; - callTool: (name: string, params: Record) => Promise; + callTool: ( + name: string, + params: Record, + meta?: Record, + ) => Promise; selectedTool: Tool | null; setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; @@ -55,6 +59,9 @@ const ToolsTab = ({ const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); const [isMetaExpanded, setIsMetaExpanded] = useState(false); + const [metaEntries, setMetaEntries] = useState< + { id: string; key: string; value: string }[] + >([]); useEffect(() => { const params = Object.entries( @@ -221,6 +228,102 @@ const ToolsTab = ({ ); }, )} +
+
+

Meta:

+ +
+ {metaEntries.length === 0 ? ( +

+ No meta pairs. +

+ ) : ( +
+ {metaEntries.map((entry, index) => ( +
+ + { + const value = e.target.value; + setMetaEntries((prev) => + prev.map((m, i) => + i === index ? { ...m, key: value } : m, + ), + ); + }} + className="h-8 flex-1" + /> + + { + const value = e.target.value; + setMetaEntries((prev) => + prev.map((m, i) => + i === index ? { ...m, value } : m, + ), + ); + }} + className="h-8 flex-1" + /> + +
+ ))} +
+ )} +
{selectedTool.outputSchema && (
@@ -262,7 +365,7 @@ const ToolsTab = ({ selectedTool._meta && (
-

Meta:

+

Meta Schema:

- +
+ +
+ {entries.map((entry, index) => ( +
+
+ + updateEntry(index, "key", e.target.value)} + /> +
+
+ + updateEntry(index, "value", e.target.value)} + /> +
+ +
+ ))} +
+ + {entries.length === 0 && ( +
+

+ No meta data entries. Click "Add Entry" to add key-value pairs. +

+
+ )} +
+ + ); +}; + +export default MetaDataTab; diff --git a/client/src/components/__tests__/MetaDataTab.test.tsx b/client/src/components/__tests__/MetaDataTab.test.tsx new file mode 100644 index 00000000..e26d0b65 --- /dev/null +++ b/client/src/components/__tests__/MetaDataTab.test.tsx @@ -0,0 +1,558 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import MetaDataTab from "../MetaDataTab"; +import { Tabs } from "@/components/ui/tabs"; + +describe("MetaDataTab", () => { + const defaultProps = { + metaData: {}, + onMetaDataChange: jest.fn(), + }; + + const renderMetaDataTab = (props = {}) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Initial Rendering", () => { + it("should render the metadata tab with title and description", () => { + renderMetaDataTab(); + + expect(screen.getByText("Meta Data")).toBeInTheDocument(); + expect( + screen.getByText( + "Key-value pairs that will be included in all MCP requests", + ), + ).toBeInTheDocument(); + }); + + it("should render Add Entry button", () => { + renderMetaDataTab(); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + expect(addButton).toBeInTheDocument(); + }); + + it("should show empty state message when no entries exist", () => { + renderMetaDataTab(); + + expect( + screen.getByText( + 'No meta data entries. Click "Add Entry" to add key-value pairs.', + ), + ).toBeInTheDocument(); + }); + + it("should not show empty state message when entries exist", () => { + renderMetaDataTab({ + metaData: { key1: "value1" }, + }); + + expect( + screen.queryByText( + 'No meta data entries. Click "Add Entry" to add key-value pairs.', + ), + ).not.toBeInTheDocument(); + }); + }); + + describe("Initial Data Handling", () => { + it("should initialize with existing metadata", () => { + const initialMetaData = { + API_KEY: "test-key", + VERSION: "1.0.0", + }; + + renderMetaDataTab({ metaData: initialMetaData }); + + expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument(); + expect(screen.getByDisplayValue("test-key")).toBeInTheDocument(); + expect(screen.getByDisplayValue("VERSION")).toBeInTheDocument(); + expect(screen.getByDisplayValue("1.0.0")).toBeInTheDocument(); + }); + + it("should render multiple entries in correct order", () => { + const initialMetaData = { + FIRST: "first-value", + SECOND: "second-value", + THIRD: "third-value", + }; + + renderMetaDataTab({ metaData: initialMetaData }); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + expect(keyInputs).toHaveLength(3); + expect(valueInputs).toHaveLength(3); + + // Check that entries are rendered in the order they appear in the object + const entries = Object.entries(initialMetaData); + entries.forEach(([key, value], index) => { + expect(keyInputs[index]).toHaveValue(key); + expect(valueInputs[index]).toHaveValue(value); + }); + }); + }); + + describe("Adding Entries", () => { + it("should add a new empty entry when Add Entry button is clicked", () => { + renderMetaDataTab(); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + expect(keyInputs).toHaveLength(1); + expect(valueInputs).toHaveLength(1); + expect(keyInputs[0]).toHaveValue(""); + expect(valueInputs[0]).toHaveValue(""); + }); + + it("should add multiple entries when Add Entry button is clicked multiple times", () => { + renderMetaDataTab(); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + + fireEvent.click(addButton); + fireEvent.click(addButton); + fireEvent.click(addButton); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + expect(keyInputs).toHaveLength(3); + expect(valueInputs).toHaveLength(3); + }); + + it("should hide empty state message after adding first entry", () => { + renderMetaDataTab(); + + expect( + screen.getByText( + 'No meta data entries. Click "Add Entry" to add key-value pairs.', + ), + ).toBeInTheDocument(); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + expect( + screen.queryByText( + 'No meta data entries. Click "Add Entry" to add key-value pairs.', + ), + ).not.toBeInTheDocument(); + }); + }); + + describe("Removing Entries", () => { + it("should render remove button for each entry", () => { + renderMetaDataTab({ + metaData: { key1: "value1", key2: "value2" }, + }); + + const removeButtons = screen.getAllByRole("button", { name: "" }); // Trash icon buttons have no text + expect(removeButtons).toHaveLength(2); + }); + + it("should remove entry when remove button is clicked", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { key1: "value1", key2: "value2" }, + onMetaDataChange, + }); + + const removeButtons = screen.getAllByRole("button", { name: "" }); + fireEvent.click(removeButtons[0]); + + expect(onMetaDataChange).toHaveBeenCalledWith({ key2: "value2" }); + }); + + it("should remove correct entry when multiple entries exist", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { + FIRST: "first-value", + SECOND: "second-value", + THIRD: "third-value", + }, + onMetaDataChange, + }); + + const removeButtons = screen.getAllByRole("button", { name: "" }); + fireEvent.click(removeButtons[1]); // Remove second entry + + expect(onMetaDataChange).toHaveBeenCalledWith({ + FIRST: "first-value", + THIRD: "third-value", + }); + }); + + it("should show empty state message after removing all entries", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { key1: "value1" }, + onMetaDataChange, + }); + + const removeButton = screen.getByRole("button", { name: "" }); + fireEvent.click(removeButton); + + expect(onMetaDataChange).toHaveBeenCalledWith({}); + }); + }); + + describe("Editing Entries", () => { + it("should update key when key input is changed", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { oldKey: "value1" }, + onMetaDataChange, + }); + + const keyInput = screen.getByDisplayValue("oldKey"); + fireEvent.change(keyInput, { target: { value: "newKey" } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ newKey: "value1" }); + }); + + it("should update value when value input is changed", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { key1: "oldValue" }, + onMetaDataChange, + }); + + const valueInput = screen.getByDisplayValue("oldValue"); + fireEvent.change(valueInput, { target: { value: "newValue" } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ key1: "newValue" }); + }); + + it("should handle editing multiple entries independently", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { + key1: "value1", + key2: "value2", + }, + onMetaDataChange, + }); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + // Edit first entry key + fireEvent.change(keyInputs[0], { target: { value: "newKey1" } }); + expect(onMetaDataChange).toHaveBeenCalledWith({ + newKey1: "value1", + key2: "value2", + }); + + // Clear mock to test second edit independently + onMetaDataChange.mockClear(); + + // Edit second entry value + fireEvent.change(valueInputs[1], { target: { value: "newValue2" } }); + expect(onMetaDataChange).toHaveBeenCalledWith({ + newKey1: "value1", + key2: "newValue2", + }); + }); + }); + + describe("Data Validation and Trimming", () => { + it("should trim whitespace from keys and values", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInput = screen.getByPlaceholderText("Key"); + const valueInput = screen.getByPlaceholderText("Value"); + + fireEvent.change(keyInput, { target: { value: " trimmedKey " } }); + fireEvent.change(valueInput, { target: { value: " trimmedValue " } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + trimmedKey: "trimmedValue", + }); + }); + + it("should exclude entries with empty keys or values after trimming", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + fireEvent.click(addButton); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + // First entry: valid key and value + fireEvent.change(keyInputs[0], { target: { value: "validKey" } }); + fireEvent.change(valueInputs[0], { target: { value: "validValue" } }); + + // Second entry: empty key (should be excluded) + fireEvent.change(keyInputs[1], { target: { value: "" } }); + fireEvent.change(valueInputs[1], { target: { value: "someValue" } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + validKey: "validValue", + }); + }); + + it("should exclude entries with whitespace-only keys or values", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + fireEvent.click(addButton); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + // First entry: valid key and value + fireEvent.change(keyInputs[0], { target: { value: "validKey" } }); + fireEvent.change(valueInputs[0], { target: { value: "validValue" } }); + + // Second entry: whitespace-only key (should be excluded) + fireEvent.change(keyInputs[1], { target: { value: " " } }); + fireEvent.change(valueInputs[1], { target: { value: "someValue" } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + validKey: "validValue", + }); + }); + + it("should handle mixed valid and invalid entries", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + fireEvent.click(addButton); + fireEvent.click(addButton); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + // First entry: valid + fireEvent.change(keyInputs[0], { target: { value: "key1" } }); + fireEvent.change(valueInputs[0], { target: { value: "value1" } }); + + // Second entry: empty key (invalid) + fireEvent.change(keyInputs[1], { target: { value: "" } }); + fireEvent.change(valueInputs[1], { target: { value: "value2" } }); + + // Third entry: valid + fireEvent.change(keyInputs[2], { target: { value: "key3" } }); + fireEvent.change(valueInputs[2], { target: { value: "value3" } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + key1: "value1", + key3: "value3", + }); + }); + }); + + describe("Input Accessibility", () => { + it("should have proper labels for screen readers", () => { + renderMetaDataTab({ + metaData: { key1: "value1" }, + }); + + const keyLabel = screen.getByLabelText("Key", { selector: "input" }); + const valueLabel = screen.getByLabelText("Value", { selector: "input" }); + + expect(keyLabel).toBeInTheDocument(); + expect(valueLabel).toBeInTheDocument(); + }); + + it("should have unique IDs for each input pair", () => { + renderMetaDataTab({ + metaData: { + key1: "value1", + key2: "value2", + }, + }); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + expect(keyInputs[0]).toHaveAttribute("id", "key-0"); + expect(keyInputs[1]).toHaveAttribute("id", "key-1"); + expect(valueInputs[0]).toHaveAttribute("id", "value-0"); + expect(valueInputs[1]).toHaveAttribute("id", "value-1"); + }); + + it("should have proper placeholder text", () => { + renderMetaDataTab(); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInput = screen.getByPlaceholderText("Key"); + const valueInput = screen.getByPlaceholderText("Value"); + + expect(keyInput).toHaveAttribute("placeholder", "Key"); + expect(valueInput).toHaveAttribute("placeholder", "Value"); + }); + }); + + describe("Edge Cases", () => { + it("should handle special characters in keys and values", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInput = screen.getByPlaceholderText("Key"); + const valueInput = screen.getByPlaceholderText("Value"); + + fireEvent.change(keyInput, { + target: { value: "key-with-special@chars!" }, + }); + fireEvent.change(valueInput, { + target: { value: "value with spaces & symbols $%^" }, + }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + "key-with-special@chars!": "value with spaces & symbols $%^", + }); + }); + + it("should handle unicode characters", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInput = screen.getByPlaceholderText("Key"); + const valueInput = screen.getByPlaceholderText("Value"); + + fireEvent.change(keyInput, { target: { value: "🔑_key" } }); + fireEvent.change(valueInput, { target: { value: "值_value_🎯" } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + "🔑_key": "值_value_🎯", + }); + }); + + it("should handle very long keys and values", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInput = screen.getByPlaceholderText("Key"); + const valueInput = screen.getByPlaceholderText("Value"); + + const longKey = "A".repeat(100); + const longValue = "B".repeat(500); + + fireEvent.change(keyInput, { target: { value: longKey } }); + fireEvent.change(valueInput, { target: { value: longValue } }); + + expect(onMetaDataChange).toHaveBeenCalledWith({ + [longKey]: longValue, + }); + }); + + it("should handle duplicate keys by keeping the last one", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ onMetaDataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + fireEvent.click(addButton); + + const keyInputs = screen.getAllByPlaceholderText("Key"); + const valueInputs = screen.getAllByPlaceholderText("Value"); + + // Set same key for both entries + fireEvent.change(keyInputs[0], { target: { value: "duplicateKey" } }); + fireEvent.change(valueInputs[0], { target: { value: "firstValue" } }); + + fireEvent.change(keyInputs[1], { target: { value: "duplicateKey" } }); + fireEvent.change(valueInputs[1], { target: { value: "secondValue" } }); + + // The second value should overwrite the first + expect(onMetaDataChange).toHaveBeenCalledWith({ + duplicateKey: "secondValue", + }); + }); + }); + + describe("Integration with Parent Component", () => { + it("should not call onMetaDataChange when component mounts with existing data", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { key1: "value1" }, + onMetaDataChange, + }); + + expect(onMetaDataChange).not.toHaveBeenCalled(); + }); + + it("should call onMetaDataChange only when user makes changes", () => { + const onMetaDataChange = jest.fn(); + renderMetaDataTab({ + metaData: { key1: "value1" }, + onMetaDataChange, + }); + + // Should not be called on mount + expect(onMetaDataChange).not.toHaveBeenCalled(); + + // Should be called when user changes value + const valueInput = screen.getByDisplayValue("value1"); + fireEvent.change(valueInput, { target: { value: "newValue" } }); + + expect(onMetaDataChange).toHaveBeenCalledTimes(1); + expect(onMetaDataChange).toHaveBeenCalledWith({ key1: "newValue" }); + }); + + it("should maintain internal state when props change (component doesn't sync with prop changes)", () => { + const { rerender } = renderMetaDataTab({ + metaData: { key1: "value1" }, + }); + + expect(screen.getByDisplayValue("key1")).toBeInTheDocument(); + expect(screen.getByDisplayValue("value1")).toBeInTheDocument(); + + // Rerender with different props - component should maintain its internal state + // This is the intended behavior since useState initializer only runs once + rerender( + + + , + ); + + // The component should still show the original values since it maintains internal state + expect(screen.getByDisplayValue("key1")).toBeInTheDocument(); + expect(screen.getByDisplayValue("value1")).toBeInTheDocument(); + // The new prop values should not be displayed + expect(screen.queryByDisplayValue("key2")).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue("value2")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 37073e9b..7bb3d01c 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -79,6 +79,7 @@ interface UseConnectionOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any getRoots?: () => any[]; defaultLoggingLevel?: LoggingLevel; + metaData?: Record; } export function useConnection({ @@ -97,6 +98,7 @@ export function useConnection({ onElicitationRequest, getRoots, defaultLoggingLevel, + metaData = {}, }: UseConnectionOptions) { const [connectionStatus, setConnectionStatus] = useState("disconnected"); @@ -153,6 +155,20 @@ export function useConnection({ try { const abortController = new AbortController(); + // Add metadata to the request if available, but skip for tool calls + // as they handle metadata merging separately + const shouldAddGeneralMeta = + request.method !== "tools/call" && Object.keys(metaData).length > 0; + const requestWithMeta = shouldAddGeneralMeta + ? { + ...request, + params: { + ...request.params, + _meta: metaData, + }, + } + : request; + // prepare MCP Client request options const mcpRequestOptions: RequestOptions = { signal: options?.signal ?? abortController.signal, @@ -181,13 +197,17 @@ export function useConnection({ let response; try { - response = await mcpClient.request(request, schema, mcpRequestOptions); + response = await mcpClient.request( + requestWithMeta, + schema, + mcpRequestOptions, + ); - pushHistory(request, response); + pushHistory(requestWithMeta, response); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - pushHistory(request, { error: errorMessage }); + pushHistory(requestWithMeta, { error: errorMessage }); throw error; } From 2bfbdca48592f5b910881f1e7adcd4f058b78e34 Mon Sep 17 00:00:00 2001 From: John Howard Date: Thu, 28 Aug 2025 15:03:38 -0700 Subject: [PATCH 05/81] Add a toast on connection error When failing to connect to an MCP server, its very hard to understand why. There is only an opaque "Connection Error" message with no additional info. This also doesn't change at all on repeated attempts, so there is no indication we attempted again and failed. This change introduces a `toast` to visualize the error, and adds the `cause` into the error message for further debugging --- client/src/lib/hooks/useConnection.ts | 5 +++++ server/src/mcpProxy.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8639feeb..8d8fee46 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -777,6 +777,11 @@ export function useConnection({ variant: "destructive", }); } + toast({ + title: "Connection error", + description: `Connection failed: "${e}"`, + variant: "destructive", + }); console.error(e); setConnectionStatus("error"); } diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 664f1711..174eef0e 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -36,7 +36,9 @@ export default function mcpProxy({ id: message.id, error: { code: -32001, - message: error.message, + message: error.cause + ? `${error.message} (cause: ${error.cause})` + : error.message, data: error, }, }; From 0b569f2fff7374e4b8ad03a889f8eee1c8fe830a Mon Sep 17 00:00:00 2001 From: John Howard Date: Mon, 13 Oct 2025 08:22:10 -0700 Subject: [PATCH 06/81] Move under else --- client/src/lib/hooks/useConnection.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8d8fee46..f37ec637 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -776,12 +776,13 @@ export function useConnection({ description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`, variant: "destructive", }); + } else { + toast({ + title: "Connection error", + description: `Connection failed: "${e}"`, + variant: "destructive", + }); } - toast({ - title: "Connection error", - description: `Connection failed: "${e}"`, - variant: "destructive", - }); console.error(e); setConnectionStatus("error"); } From c2dbff738e582941d6b1af04c4b9f41c28305487 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Thu, 16 Oct 2025 00:50:42 +0300 Subject: [PATCH 07/81] fix: set user specified scope in DCR --- client/src/lib/auth.ts | 34 ++++++++++++++++++++++----- client/src/lib/constants.ts | 1 + client/src/lib/hooks/useConnection.ts | 12 ++++++---- client/src/lib/oauth-state-machine.ts | 29 ++++++++++++++--------- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 23e21b6a..edd3ef58 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -102,16 +102,35 @@ export const clearClientInformationFromSessionStorage = ({ sessionStorage.removeItem(key); }; +export const getScopeFromSessionStorage = ( + serverUrl: string, +): string | undefined => { + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); + const value = sessionStorage.getItem(key); + return value || undefined; +}; + +export const saveScopeToSessionStorage = ( + serverUrl: string, + scope: string | undefined, +) => { + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); + if (scope) { + sessionStorage.setItem(key, scope); + } else { + sessionStorage.removeItem(key); + } +}; + export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor( - protected serverUrl: string, - scope?: string, - ) { - this.scope = scope; + constructor(protected serverUrl: string) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } - scope: string | undefined; + + get scope(): string | undefined { + return getScopeFromSessionStorage(this.serverUrl); + } get redirectUrl() { return window.location.origin + "/oauth/callback"; @@ -224,6 +243,9 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { sessionStorage.removeItem( getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl), ); + sessionStorage.removeItem( + getServerSpecificKey(SESSION_KEYS.SCOPE, this.serverUrl), + ); } } diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 8e4ff2b5..7e4e0697 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -17,6 +17,7 @@ export const SESSION_KEYS = { PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", SERVER_METADATA: "mcp_server_metadata", AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", + SCOPE: "mcp_scope", } as const; // Generate server-specific session storage keys diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8639feeb..f6d42380 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -45,6 +45,7 @@ import { clearClientInformationFromSessionStorage, InspectorOAuthClientProvider, saveClientInformationToSessionStorage, + saveScopeToSessionStorage, discoverScopes, } from "../auth"; import { @@ -142,6 +143,10 @@ export function useConnection({ }); }, [oauthClientId, oauthClientSecret, sseUrl]); + useEffect(() => { + saveScopeToSessionStorage(sseUrl, oauthScope); + }, [oauthScope, sseUrl]); + const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ ...prev, @@ -346,10 +351,9 @@ export function useConnection({ } scope = await discoverScopes(sseUrl, resourceMetadata); } - const serverAuthProvider = new InspectorOAuthClientProvider( - sseUrl, - scope, - ); + + saveScopeToSessionStorage(sseUrl, scope); + const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); const result = await auth(serverAuthProvider, { serverUrl: sseUrl, diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index c505f698..8dc9da8f 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -80,13 +80,16 @@ 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 (scopesSupported) { - clientMetadata.scope = scopesSupported.join(" "); + // Priority: user-provided scope > discovered scopes + if (!context.provider.scope || context.provider.scope.trim() === "") { + // 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 (scopesSupported) { + clientMetadata.scope = scopesSupported.join(" "); + } } // Try Static client first, with DCR as fallback @@ -113,10 +116,14 @@ export const oauthTransitions: Record = { const metadata = context.state.oauthMetadata!; const clientInformation = context.state.oauthClientInfo!; - const scope = await discoverScopes( - context.serverUrl, - context.state.resourceMetadata ?? undefined, - ); + // Priority: user-provided scope > discovered scopes + let scope = context.provider.scope; + if (!scope || scope.trim() === "") { + scope = await discoverScopes( + context.serverUrl, + context.state.resourceMetadata ?? undefined, + ); + } const { authorizationUrl, codeVerifier } = await startAuthorization( context.serverUrl, From 40fa6cb27d65fec026bec36f54e7f8d059817e2f Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Thu, 16 Oct 2025 00:57:06 +0300 Subject: [PATCH 08/81] refactor test --- client/src/lib/hooks/__tests__/useConnection.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index a9ba6825..2d09b8b2 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -112,6 +112,7 @@ jest.mock("../../auth", () => ({ })), clearClientInformationFromSessionStorage: jest.fn(), saveClientInformationToSessionStorage: jest.fn(), + saveScopeToSessionStorage: jest.fn(), discoverScopes: jest.fn(), })); From 9dc3b748a86d1c0b31de4d38679d16c37be04b01 Mon Sep 17 00:00:00 2001 From: Jesse Lumarie Date: Sat, 18 Oct 2025 20:42:46 -0600 Subject: [PATCH 09/81] mcp: add icons --- client/src/App.tsx | 2 + client/src/__tests__/App.routing.test.tsx | 1 + client/src/components/IconDisplay.tsx | 57 +++++++++++++++++++++++ client/src/components/PromptsTab.tsx | 3 ++ client/src/components/Sidebar.tsx | 45 ++++++++++++++++++ client/src/components/ToolsTab.tsx | 13 ++++++ client/src/lib/hooks/useConnection.ts | 8 ++++ 7 files changed, 129 insertions(+) create mode 100644 client/src/components/IconDisplay.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 5937d738..1427a4f6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -249,6 +249,7 @@ const App = () => { const { connectionStatus, serverCapabilities, + serverImplementation, mcpClient, requestHistory, clearRequestHistory, @@ -920,6 +921,7 @@ const App = () => { loggingSupported={!!serverCapabilities?.logging || false} connectionType={connectionType} setConnectionType={setConnectionType} + serverImplementation={serverImplementation} />
{ + if (!icons || icons.length === 0) { + return null; + } + + const sizeClasses = { + sm: "w-4 h-4", + md: "w-6 h-6", + lg: "w-8 h-8", + }; + + const sizeClass = sizeClasses[size]; + + return ( +
+ {icons.map((icon, index) => ( + { + // Hide broken images + e.currentTarget.style.display = "none"; + }} + /> + ))} +
+ ); +}; + +export default IconDisplay; diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 424845d0..3e53df11 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -14,6 +14,7 @@ import { useEffect, useState } from "react"; import ListPane from "./ListPane"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; import JsonView from "./JsonView"; +import IconDisplay from "./IconDisplay"; export type Prompt = { name: string; @@ -23,6 +24,7 @@ export type Prompt = { description?: string; required?: boolean; }[]; + icons?: { src: string; mimeType?: string; sizes?: string[] }[]; }; const PromptsTab = ({ @@ -109,6 +111,7 @@ const PromptsTab = ({ }} renderItem={(prompt) => (
+ {prompt.name} {prompt.description} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b369a967..13a4f24f 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { RefreshCwOff, Copy, CheckCheck, + Server, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -40,6 +41,7 @@ import { import CustomHeaders from "./CustomHeaders"; import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders"; import { useToast } from "../lib/hooks/useToast"; +import IconDisplay, { WithIcons } from "./IconDisplay"; interface SidebarProps { connectionStatus: ConnectionStatus; @@ -71,6 +73,9 @@ interface SidebarProps { setConfig: (config: InspectorConfig) => void; connectionType: "direct" | "proxy"; setConnectionType: (type: "direct" | "proxy") => void; + serverImplementation?: + | (WithIcons & { name?: string; version?: string; websiteUrl?: string }) + | null; } const Sidebar = ({ @@ -102,6 +107,7 @@ const Sidebar = ({ setConfig, connectionType, setConnectionType, + serverImplementation, }: SidebarProps) => { const [theme, setTheme] = useTheme(); const [showEnvVars, setShowEnvVars] = useState(false); @@ -776,6 +782,45 @@ const Sidebar = ({
+ {connectionStatus === "connected" && serverImplementation && ( +
+
+ {(serverImplementation as WithIcons).icons && + (serverImplementation as WithIcons).icons!.length > 0 ? ( + + ) : ( + + )} + {(serverImplementation as { websiteUrl?: string }) + .websiteUrl ? ( + + {serverImplementation.name || "MCP Server"} + + ) : ( + + {serverImplementation.name || "MCP Server"} + + )} +
+ {serverImplementation.version && ( +
+ Version: {serverImplementation.version} +
+ )} +
+ )} + {loggingSupported && connectionStatus === "connected" && (