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/16] 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 fecd98399..3bf3b8874 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 6479b5fbb..64798cd9d 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 2654feed9..798467c1f 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 000000000..e26d0b653 --- /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 37073e9b7..7bb3d01c9 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 3d3925d02ad4513da71ee95f7b4a454e6e01d649 Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:01:30 +0200 Subject: [PATCH 05/16] Fixed metadata naming --- client/src/App.tsx | 10 +-- client/src/components/MetaDataTab.tsx | 10 +-- .../components/__tests__/MetaDataTab.test.tsx | 80 +++++++++---------- 3 files changed, 50 insertions(+), 50 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 7f5314711..0f8997bdc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -81,7 +81,7 @@ import { CustomHeaders, migrateFromLegacyAuth, } from "./lib/types/customHeaders"; -import MetaDataTab from "./components/MetaDataTab"; +import MetadataTab from "./components/MetadataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -197,14 +197,14 @@ const App = () => { const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); - // Meta data state - persisted in localStorage + // Metadata state - persisted in localStorage const [metaData, setMetaData] = useState>(() => { const savedMetaData = localStorage.getItem("lastMetaData"); if (savedMetaData) { try { return JSON.parse(savedMetaData); } catch (error) { - console.warn("Failed to parse saved meta data:", error); + console.warn("Failed to parse saved metadata:", error); } } return {}; @@ -1022,7 +1022,7 @@ const App = () => { - Meta Data + Metadata @@ -1184,7 +1184,7 @@ const App = () => { onRootsChange={handleRootsChange} /> - diff --git a/client/src/components/MetaDataTab.tsx b/client/src/components/MetaDataTab.tsx index c4cc6d214..eefedba49 100644 --- a/client/src/components/MetaDataTab.tsx +++ b/client/src/components/MetaDataTab.tsx @@ -10,12 +10,12 @@ interface MetaDataEntry { value: string; } -interface MetaDataTabProps { +interface MetadataTabProps { metaData: Record; onMetaDataChange: (metaData: Record) => void; } -const MetaDataTab: React.FC = ({ +const MetadataTab: React.FC = ({ metaData, onMetaDataChange, }) => { @@ -59,7 +59,7 @@ const MetaDataTab: React.FC = ({
-

Meta Data

+

Metadata

Key-value pairs that will be included in all MCP requests

@@ -109,7 +109,7 @@ const MetaDataTab: React.FC = ({ {entries.length === 0 && (

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

)} @@ -118,4 +118,4 @@ const MetaDataTab: React.FC = ({ ); }; -export default MetaDataTab; +export default MetadataTab; diff --git a/client/src/components/__tests__/MetaDataTab.test.tsx b/client/src/components/__tests__/MetaDataTab.test.tsx index e26d0b653..339e70158 100644 --- a/client/src/components/__tests__/MetaDataTab.test.tsx +++ b/client/src/components/__tests__/MetaDataTab.test.tsx @@ -1,18 +1,18 @@ import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; -import MetaDataTab from "../MetaDataTab"; +import MetadataTab from "../MetadataTab"; import { Tabs } from "@/components/ui/tabs"; -describe("MetaDataTab", () => { +describe("MetadataTab", () => { const defaultProps = { metaData: {}, onMetaDataChange: jest.fn(), }; - const renderMetaDataTab = (props = {}) => { + const renderMetadataTab = (props = {}) => { return render( - + , ); }; @@ -23,9 +23,9 @@ describe("MetaDataTab", () => { describe("Initial Rendering", () => { it("should render the metadata tab with title and description", () => { - renderMetaDataTab(); + renderMetadataTab(); - expect(screen.getByText("Meta Data")).toBeInTheDocument(); + expect(screen.getByText("Metadata")).toBeInTheDocument(); expect( screen.getByText( "Key-value pairs that will be included in all MCP requests", @@ -34,30 +34,30 @@ describe("MetaDataTab", () => { }); it("should render Add Entry button", () => { - renderMetaDataTab(); + renderMetadataTab(); const addButton = screen.getByRole("button", { name: /add entry/i }); expect(addButton).toBeInTheDocument(); }); it("should show empty state message when no entries exist", () => { - renderMetaDataTab(); + renderMetadataTab(); expect( screen.getByText( - 'No meta data entries. Click "Add Entry" to add key-value pairs.', + 'No metadata entries. Click "Add Entry" to add key-value pairs.', ), ).toBeInTheDocument(); }); it("should not show empty state message when entries exist", () => { - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1" }, }); expect( screen.queryByText( - 'No meta data entries. Click "Add Entry" to add key-value pairs.', + 'No metadata entries. Click "Add Entry" to add key-value pairs.', ), ).not.toBeInTheDocument(); }); @@ -70,7 +70,7 @@ describe("MetaDataTab", () => { VERSION: "1.0.0", }; - renderMetaDataTab({ metaData: initialMetaData }); + renderMetadataTab({ metaData: initialMetaData }); expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument(); expect(screen.getByDisplayValue("test-key")).toBeInTheDocument(); @@ -85,7 +85,7 @@ describe("MetaDataTab", () => { THIRD: "third-value", }; - renderMetaDataTab({ metaData: initialMetaData }); + renderMetadataTab({ metaData: initialMetaData }); const keyInputs = screen.getAllByPlaceholderText("Key"); const valueInputs = screen.getAllByPlaceholderText("Value"); @@ -104,7 +104,7 @@ describe("MetaDataTab", () => { describe("Adding Entries", () => { it("should add a new empty entry when Add Entry button is clicked", () => { - renderMetaDataTab(); + renderMetadataTab(); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -119,7 +119,7 @@ describe("MetaDataTab", () => { }); it("should add multiple entries when Add Entry button is clicked multiple times", () => { - renderMetaDataTab(); + renderMetadataTab(); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -135,11 +135,11 @@ describe("MetaDataTab", () => { }); it("should hide empty state message after adding first entry", () => { - renderMetaDataTab(); + renderMetadataTab(); expect( screen.getByText( - 'No meta data entries. Click "Add Entry" to add key-value pairs.', + 'No metadata entries. Click "Add Entry" to add key-value pairs.', ), ).toBeInTheDocument(); @@ -148,7 +148,7 @@ describe("MetaDataTab", () => { expect( screen.queryByText( - 'No meta data entries. Click "Add Entry" to add key-value pairs.', + 'No metadata entries. Click "Add Entry" to add key-value pairs.', ), ).not.toBeInTheDocument(); }); @@ -156,7 +156,7 @@ describe("MetaDataTab", () => { describe("Removing Entries", () => { it("should render remove button for each entry", () => { - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1", key2: "value2" }, }); @@ -166,7 +166,7 @@ describe("MetaDataTab", () => { it("should remove entry when remove button is clicked", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1", key2: "value2" }, onMetaDataChange, }); @@ -179,7 +179,7 @@ describe("MetaDataTab", () => { it("should remove correct entry when multiple entries exist", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { FIRST: "first-value", SECOND: "second-value", @@ -199,7 +199,7 @@ describe("MetaDataTab", () => { it("should show empty state message after removing all entries", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1" }, onMetaDataChange, }); @@ -214,7 +214,7 @@ describe("MetaDataTab", () => { describe("Editing Entries", () => { it("should update key when key input is changed", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { oldKey: "value1" }, onMetaDataChange, }); @@ -227,7 +227,7 @@ describe("MetaDataTab", () => { it("should update value when value input is changed", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "oldValue" }, onMetaDataChange, }); @@ -240,7 +240,7 @@ describe("MetaDataTab", () => { it("should handle editing multiple entries independently", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1", key2: "value2", @@ -273,7 +273,7 @@ describe("MetaDataTab", () => { describe("Data Validation and Trimming", () => { it("should trim whitespace from keys and values", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -291,7 +291,7 @@ describe("MetaDataTab", () => { it("should exclude entries with empty keys or values after trimming", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -315,7 +315,7 @@ describe("MetaDataTab", () => { it("should exclude entries with whitespace-only keys or values", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -339,7 +339,7 @@ describe("MetaDataTab", () => { it("should handle mixed valid and invalid entries", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -370,7 +370,7 @@ describe("MetaDataTab", () => { describe("Input Accessibility", () => { it("should have proper labels for screen readers", () => { - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1" }, }); @@ -382,7 +382,7 @@ describe("MetaDataTab", () => { }); it("should have unique IDs for each input pair", () => { - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1", key2: "value2", @@ -399,7 +399,7 @@ describe("MetaDataTab", () => { }); it("should have proper placeholder text", () => { - renderMetaDataTab(); + renderMetadataTab(); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -415,7 +415,7 @@ describe("MetaDataTab", () => { describe("Edge Cases", () => { it("should handle special characters in keys and values", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -437,7 +437,7 @@ describe("MetaDataTab", () => { it("should handle unicode characters", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -455,7 +455,7 @@ describe("MetaDataTab", () => { it("should handle very long keys and values", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -476,7 +476,7 @@ describe("MetaDataTab", () => { it("should handle duplicate keys by keeping the last one", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ onMetaDataChange }); + renderMetadataTab({ onMetaDataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); fireEvent.click(addButton); @@ -502,7 +502,7 @@ describe("MetaDataTab", () => { describe("Integration with Parent Component", () => { it("should not call onMetaDataChange when component mounts with existing data", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1" }, onMetaDataChange, }); @@ -512,7 +512,7 @@ describe("MetaDataTab", () => { it("should call onMetaDataChange only when user makes changes", () => { const onMetaDataChange = jest.fn(); - renderMetaDataTab({ + renderMetadataTab({ metaData: { key1: "value1" }, onMetaDataChange, }); @@ -529,7 +529,7 @@ describe("MetaDataTab", () => { }); it("should maintain internal state when props change (component doesn't sync with prop changes)", () => { - const { rerender } = renderMetaDataTab({ + const { rerender } = renderMetadataTab({ metaData: { key1: "value1" }, }); @@ -540,7 +540,7 @@ describe("MetaDataTab", () => { // This is the intended behavior since useState initializer only runs once rerender( - From b9963014a0f94cb654268f9c67eef20bdd6d545e Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:02:41 +0200 Subject: [PATCH 06/16] Fixed naming --- client/src/components/ToolsTab.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 04d298e13..780fa2085 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -306,7 +306,9 @@ const ToolsTab = ({ )}
-

Meta:

+

+ Tool-specific Metadata: +

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

- No meta pairs. + No metadata pairs.

) : (
- {metaEntries.map((entry, index) => ( + {metadataEntries.map((entry, index) => (
{ const value = e.target.value; - setMetaEntries((prev) => + setMetadataEntries((prev) => prev.map((m, i) => i === index ? { ...m, key: value } : m, ), @@ -451,18 +451,18 @@ const ToolsTab = ({ className="h-8 flex-1" /> { const value = e.target.value; - setMetaEntries((prev) => + setMetadataEntries((prev) => prev.map((m, i) => i === index ? { ...m, value } : m, ), @@ -475,7 +475,7 @@ const ToolsTab = ({ variant="ghost" className="h-8 w-8 p-0 ml-auto shrink-0" onClick={() => - setMetaEntries((prev) => + setMetadataEntries((prev) => prev.filter((_, i) => i !== index), ) } @@ -533,10 +533,12 @@ const ToolsTab = ({
@@ -565,17 +569,16 @@ const ToolsTab = ({ try { setIsToolRunning(true); - const meta = metaEntries.reduce>( - (acc, { key, value }) => { - if (key.trim() !== "") acc[key] = value; - return acc; - }, - {}, - ); + const metadata = metadataEntries.reduce< + Record + >((acc, { key, value }) => { + if (key.trim() !== "") acc[key] = value; + return acc; + }, {}); await callTool( selectedTool.name, params, - Object.keys(meta).length ? meta : undefined, + Object.keys(metadata).length ? metadata : undefined, ); } finally { setIsToolRunning(false); diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index 0c20e7c81..3e07d017f 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -716,10 +716,10 @@ describe("ToolsTab", () => { }); }); - describe("Meta Display", () => { - const toolWithMeta = { + describe("Metadata Display", () => { + const toolWithMetadata = { name: "metaTool", - description: "Tool with meta", + description: "Tool with metadata", inputSchema: { type: "object" as const, properties: { @@ -732,10 +732,10 @@ describe("ToolsTab", () => { }, } as unknown as Tool; - it("should display meta section when tool has _meta", () => { + it("should display metadata section when tool has _meta", () => { renderToolsTab({ - tools: [toolWithMeta], - selectedTool: toolWithMeta, + tools: [toolWithMetadata], + selectedTool: toolWithMetadata, }); expect(screen.getByText("Meta:")).toBeInTheDocument(); @@ -744,10 +744,10 @@ describe("ToolsTab", () => { ).toBeInTheDocument(); }); - it("should toggle meta schema expansion", () => { + it("should toggle metadata schema expansion", () => { renderToolsTab({ - tools: [toolWithMeta], - selectedTool: toolWithMeta, + tools: [toolWithMetadata], + selectedTool: toolWithMetadata, }); // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section @@ -777,13 +777,13 @@ describe("ToolsTab", () => { }); }); - describe("Meta submission", () => { - it("should send meta values when provided", async () => { + describe("Metadata submission", () => { + it("should send metadata values when provided", async () => { const callToolMock = jest.fn(async () => {}); renderToolsTab({ selectedTool: mockTools[0], callTool: callToolMock }); - // Add a meta key/value pair + // Add a metadata key/value pair const addPairButton = screen.getByRole("button", { name: /add pair/i }); await act(async () => { fireEvent.click(addPairButton); @@ -815,19 +815,19 @@ describe("ToolsTab", () => { }); }); - describe("ToolResults Meta", () => { - it("should display meta information when present in toolResult", () => { - const resultWithMeta = { + describe("ToolResults Metadata", () => { + it("should display metadata information when present in toolResult", () => { + const resultWithMetadata = { content: [], _meta: { info: "details", version: 2 }, }; renderToolsTab({ selectedTool: mockTools[0], - toolResult: resultWithMeta, + toolResult: resultWithMetadata, }); - // Only ToolResults meta should be present since selectedTool has no _meta + // Only ToolResults metadata should be present since selectedTool has no _meta expect(screen.getAllByText("Meta:")).toHaveLength(1); expect(screen.getByText(/info/i)).toBeInTheDocument(); expect(screen.getByText(/version/i)).toBeInTheDocument(); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 4f639c08e..4584ff0a2 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -167,9 +167,9 @@ export function useConnection({ // Add metadata to the request if available, but skip for tool calls // as they handle metadata merging separately - const shouldAddGeneralMeta = + const shouldAddGeneralMetadata = request.method !== "tools/call" && Object.keys(metadata).length > 0; - const requestWithMeta = shouldAddGeneralMeta + const requestWithMetadata = shouldAddGeneralMetadata ? { ...request, params: { @@ -208,16 +208,16 @@ export function useConnection({ let response; try { response = await mcpClient.request( - requestWithMeta, + requestWithMetadata, schema, mcpRequestOptions, ); - pushHistory(requestWithMeta, response); + pushHistory(requestWithMetadata, response); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - pushHistory(requestWithMeta, { error: errorMessage }); + pushHistory(requestWithMetadata, { error: errorMessage }); throw error; } From 5b9e69f36a191f24d3f33387f9cb7f8200752a25 Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Mon, 27 Oct 2025 12:24:47 +0100 Subject: [PATCH 14/16] Aligned naming --- cli/src/client/tools.ts | 8 ++++---- client/src/App.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/src/client/tools.ts b/cli/src/client/tools.ts index 909f17af3..516814115 100644 --- a/cli/src/client/tools.ts +++ b/cli/src/client/tools.ts @@ -115,9 +115,9 @@ export async function callTool( // Merge general metadata with tool-specific metadata // Tool-specific metadata takes precedence over general metadata - let mergedMeta: Record | undefined; + let mergedMetadata: Record | undefined; if (generalMetadata || toolSpecificMetadata) { - mergedMeta = { + mergedMetadata = { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}), }; @@ -127,8 +127,8 @@ export async function callTool( name: name, arguments: convertedArgs, _meta: - mergedMeta && Object.keys(mergedMeta).length > 0 - ? mergedMeta + mergedMetadata && Object.keys(mergedMetadata).length > 0 + ? mergedMetadata : undefined, }); return response; diff --git a/client/src/App.tsx b/client/src/App.tsx index e17c24ede..d58cd8fe4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -821,7 +821,7 @@ const App = () => { // Merge general metadata with tool-specific metadata // Tool-specific metadata takes precedence over general metadata - const mergedMeta = { + const mergedMetadata = { ...metadata, // General metadata first progressToken: progressTokenRef.current++, ...(metadata ?? {}), // Tool-specific metadata overrides @@ -833,7 +833,7 @@ const App = () => { params: { name, arguments: cleanedParams, - _meta: mergedMeta, + _meta: mergedMetadata, }, }, CompatibilityCallToolResultSchema, From 1c1afc30cc6e61a8a9216c3878c295c27191b975 Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:54:23 +0100 Subject: [PATCH 15/16] Fix global metadata not being applied --- client/src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index d58cd8fe4..c53294d52 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -808,7 +808,7 @@ const App = () => { const callTool = async ( name: string, params: Record, - metadata?: Record, + toolMetadata?: Record, ) => { lastToolCallOriginTabRef.current = currentTabRef.current; @@ -822,9 +822,9 @@ const App = () => { // Merge general metadata with tool-specific metadata // Tool-specific metadata takes precedence over general metadata const mergedMetadata = { - ...metadata, // General metadata first + ...metadata, // General metadata progressToken: progressTokenRef.current++, - ...(metadata ?? {}), // Tool-specific metadata overrides + ...(toolMetadata ?? {}), // Tool-specific metadata }; const response = await sendMCPRequest( From a45247a4f289a6ee690f3500f0e08d7144012a09 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 15 Nov 2025 01:26:13 +0200 Subject: [PATCH 16/16] add validations against spec, add reserved metadata key validations --- client/src/App.tsx | 35 +++- client/src/components/MetadataTab.tsx | 117 ++++++++--- client/src/components/ToolsTab.tsx | 196 ++++++++++++------ .../components/__tests__/MetadataTab.test.tsx | 51 ++++- .../components/__tests__/ToolsTab.test.tsx | 36 ++++ client/src/utils/metaUtils.ts | 162 +++++++++++++++ 6 files changed, 489 insertions(+), 108 deletions(-) create mode 100644 client/src/utils/metaUtils.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index c53294d52..fda6b2a8d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,6 +19,11 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; +import { + hasValidMetaName, + hasValidMetaPrefix, + isReservedMetaKey, +} from "@/utils/metaUtils"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; @@ -85,6 +90,24 @@ import MetadataTab from "./components/MetadataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; +const filterReservedMetadata = ( + metadata: Record, +): Record => { + return Object.entries(metadata).reduce>( + (acc, [key, value]) => { + if ( + !isReservedMetaKey(key) && + hasValidMetaPrefix(key) && + hasValidMetaName(key) + ) { + acc[key] = value; + } + return acc; + }, + {}, + ); +}; + const App = () => { const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< @@ -206,7 +229,10 @@ const App = () => { const savedMetadata = localStorage.getItem("lastMetadata"); if (savedMetadata) { try { - return JSON.parse(savedMetadata); + const parsed = JSON.parse(savedMetadata); + if (parsed && typeof parsed === "object") { + return filterReservedMetadata(parsed); + } } catch (error) { console.warn("Failed to parse saved metadata:", error); } @@ -219,8 +245,9 @@ const App = () => { }; const handleMetadataChange = (newMetadata: Record) => { - setMetadata(newMetadata); - localStorage.setItem("lastMetadata", JSON.stringify(newMetadata)); + const sanitizedMetadata = filterReservedMetadata(newMetadata); + setMetadata(sanitizedMetadata); + localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata)); }; const nextRequestId = useRef(0); const rootsRef = useRef([]); @@ -824,7 +851,7 @@ const App = () => { const mergedMetadata = { ...metadata, // General metadata progressToken: progressTokenRef.current++, - ...(toolMetadata ?? {}), // Tool-specific metadata + ...toolMetadata, // Tool-specific metadata }; const response = await sendMCPRequest( diff --git a/client/src/components/MetadataTab.tsx b/client/src/components/MetadataTab.tsx index 477952c31..beb26ac7a 100644 --- a/client/src/components/MetadataTab.tsx +++ b/client/src/components/MetadataTab.tsx @@ -4,6 +4,15 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Trash2, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + META_NAME_RULES_MESSAGE, + META_PREFIX_RULES_MESSAGE, + RESERVED_NAMESPACE_MESSAGE, + hasValidMetaName, + hasValidMetaPrefix, + isReservedMetaKey, +} from "@/utils/metaUtils"; interface MetadataEntry { key: string; @@ -47,8 +56,15 @@ const MetadataTab: React.FC = ({ const updateMetadata = (newEntries: MetadataEntry[]) => { const metadataObject: Record = {}; newEntries.forEach(({ key, value }) => { - if (key.trim() && value.trim()) { - metadataObject[key.trim()] = value.trim(); + const trimmedKey = key.trim(); + if ( + trimmedKey && + value.trim() && + hasValidMetaPrefix(trimmedKey) && + !isReservedMetaKey(trimmedKey) && + hasValidMetaName(trimmedKey) + ) { + metadataObject[trimmedKey] = value.trim(); } }); onMetadataChange(metadataObject); @@ -71,39 +87,72 @@ const MetadataTab: React.FC = ({
- {entries.map((entry, index) => ( -
-
- - updateEntry(index, "key", e.target.value)} - /> + {entries.map((entry, index) => { + const trimmedKey = entry.key.trim(); + const hasInvalidPrefix = + trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey); + const isReservedKey = + trimmedKey !== "" && isReservedMetaKey(trimmedKey); + const hasInvalidName = + trimmedKey !== "" && !hasValidMetaName(trimmedKey); + const validationMessage = hasInvalidPrefix + ? META_PREFIX_RULES_MESSAGE + : isReservedKey + ? RESERVED_NAMESPACE_MESSAGE + : hasInvalidName + ? META_NAME_RULES_MESSAGE + : null; + return ( +
+
+
+ + + updateEntry(index, "key", e.target.value) + } + aria-invalid={Boolean(validationMessage)} + className={cn( + validationMessage && + "border-red-500 focus-visible:ring-red-500 focus-visible:ring-1", + )} + /> +
+
+ + + updateEntry(index, "value", e.target.value) + } + disabled={Boolean(validationMessage)} + /> +
+ +
+ {validationMessage && ( +

+ {validationMessage} +

+ )}
-
- - updateEntry(index, "value", e.target.value)} - /> -
- -
- ))} + ); + })}
{entries.length === 0 && ( diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 02efc729a..f8175f9db 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -40,6 +40,15 @@ import JsonView from "./JsonView"; import ToolResults from "./ToolResults"; import { useToast } from "@/lib/hooks/useToast"; import useCopy from "@/lib/hooks/useCopy"; +import { cn } from "@/lib/utils"; +import { + META_NAME_RULES_MESSAGE, + META_PREFIX_RULES_MESSAGE, + RESERVED_NAMESPACE_MESSAGE, + hasValidMetaName, + hasValidMetaPrefix, + isReservedMetaKey, +} from "@/utils/metaUtils"; // Type guard to safely detect the optional _meta field without using `any` const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } => @@ -122,6 +131,21 @@ const ToolsTab = ({ formRefs.current = {}; }, [selectedTool]); + const hasReservedMetadataEntry = metadataEntries.some(({ key }) => { + const trimmedKey = key.trim(); + return trimmedKey !== "" && isReservedMetaKey(trimmedKey); + }); + + const hasInvalidMetaPrefixEntry = metadataEntries.some(({ key }) => { + const trimmedKey = key.trim(); + return trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey); + }); + + const hasInvalidMetaNameEntry = metadataEntries.some(({ key }) => { + const trimmedKey = key.trim(); + return trimmedKey !== "" && !hasValidMetaName(trimmedKey); + }); + return (
@@ -438,68 +462,102 @@ const ToolsTab = ({

) : (
- {metadataEntries.map((entry, index) => ( -
- - { - const value = e.target.value; - setMetadataEntries((prev) => - prev.map((m, i) => - i === index ? { ...m, key: value } : m, - ), - ); - }} - className="h-8 flex-1" - /> - - { - const value = e.target.value; - setMetadataEntries((prev) => - prev.map((m, i) => - i === index ? { ...m, value } : m, - ), - ); - }} - className="h-8 flex-1" - /> - -
- ))} + {metadataEntries.map((entry, index) => { + const trimmedKey = entry.key.trim(); + const hasInvalidPrefix = + trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey); + const isReservedKey = + trimmedKey !== "" && isReservedMetaKey(trimmedKey); + const hasInvalidName = + trimmedKey !== "" && !hasValidMetaName(trimmedKey); + const validationMessage = hasInvalidPrefix + ? META_PREFIX_RULES_MESSAGE + : isReservedKey + ? RESERVED_NAMESPACE_MESSAGE + : hasInvalidName + ? META_NAME_RULES_MESSAGE + : null; + return ( +
+
+ + { + const value = e.target.value; + setMetadataEntries((prev) => + prev.map((m, i) => + i === index ? { ...m, key: value } : m, + ), + ); + }} + className={cn( + "h-8 flex-1", + validationMessage && + "border-red-500 focus-visible:ring-red-500 focus-visible:ring-1", + )} + aria-invalid={Boolean(validationMessage)} + /> + + { + const value = e.target.value; + setMetadataEntries((prev) => + prev.map((m, i) => + i === index ? { ...m, value } : m, + ), + ); + }} + className="h-8 flex-1" + disabled={Boolean(validationMessage)} + /> + +
+ {validationMessage && ( +

+ {validationMessage} +

+ )} +
+ ); + })}
)} + {(hasReservedMetadataEntry || + hasInvalidMetaPrefixEntry || + hasInvalidMetaNameEntry) && ( +

+ Remove reserved or invalid metadata keys (prefix/name) + before running the tool. +

+ )}
{selectedTool.outputSchema && (
@@ -585,7 +643,15 @@ const ToolsTab = ({ const metadata = metadataEntries.reduce< Record >((acc, { key, value }) => { - if (key.trim() !== "") acc[key] = value; + const trimmedKey = key.trim(); + if ( + trimmedKey !== "" && + hasValidMetaPrefix(trimmedKey) && + !isReservedMetaKey(trimmedKey) && + hasValidMetaName(trimmedKey) + ) { + acc[trimmedKey] = value; + } return acc; }, {}); await callTool( @@ -597,7 +663,13 @@ const ToolsTab = ({ setIsToolRunning(false); } }} - disabled={isToolRunning || hasValidationErrors} + disabled={ + isToolRunning || + hasValidationErrors || + hasReservedMetadataEntry || + hasInvalidMetaPrefixEntry || + hasInvalidMetaNameEntry + } > {isToolRunning ? ( <> diff --git a/client/src/components/__tests__/MetadataTab.test.tsx b/client/src/components/__tests__/MetadataTab.test.tsx index 9d0cbf991..f22e102ff 100644 --- a/client/src/components/__tests__/MetadataTab.test.tsx +++ b/client/src/components/__tests__/MetadataTab.test.tsx @@ -2,6 +2,11 @@ import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import MetadataTab from "../MetadataTab"; import { Tabs } from "@/components/ui/tabs"; +import { + META_NAME_RULES_MESSAGE, + META_PREFIX_RULES_MESSAGE, + RESERVED_NAMESPACE_MESSAGE, +} from "@/utils/metaUtils"; describe("MetadataTab", () => { const defaultProps = { @@ -270,6 +275,38 @@ describe("MetadataTab", () => { }); }); + describe("Reserved Metadata Keys", () => { + test.each` + description | value | message | shouldDisableValue + ${"reserved keys with prefix"} | ${"modelcontextprotocol.io/flip"} | ${RESERVED_NAMESPACE_MESSAGE} | ${true} + ${"reserved root without slash"} | ${"modelcontextprotocol.io"} | ${RESERVED_NAMESPACE_MESSAGE} | ${true} + ${"nested modelcontextprotocol domain"} | ${"api.modelcontextprotocol.org"} | ${RESERVED_NAMESPACE_MESSAGE} | ${false} + ${"nested mcp domain"} | ${"tools.mcp.com/path"} | ${RESERVED_NAMESPACE_MESSAGE} | ${false} + ${"invalid name segments"} | ${"custom/bad-"} | ${META_NAME_RULES_MESSAGE} | ${false} + ${"invalid prefix labels"} | ${"1invalid-prefix/value"} | ${META_PREFIX_RULES_MESSAGE} | ${false} + `( + "should display an error for $description", + ({ value, message, shouldDisableValue }) => { + const onMetadataChange = jest.fn(); + renderMetadataTab({ onMetadataChange }); + + const addButton = screen.getByRole("button", { name: /add entry/i }); + fireEvent.click(addButton); + + const keyInput = screen.getByPlaceholderText("Key"); + fireEvent.change(keyInput, { target: { value } }); + + const valueInput = screen.getByPlaceholderText("Value"); + if (shouldDisableValue) { + expect(valueInput).toBeDisabled(); + } + + expect(screen.getByText(message)).toBeInTheDocument(); + expect(onMetadataChange).toHaveBeenLastCalledWith({}); + }, + ); + }); + describe("Data Validation and Trimming", () => { it("should trim whitespace from keys and values", () => { const onMetadataChange = jest.fn(); @@ -413,7 +450,7 @@ describe("MetadataTab", () => { }); describe("Edge Cases", () => { - it("should handle special characters in keys and values", () => { + it("should flag invalid names that contain unsupported characters", () => { const onMetadataChange = jest.fn(); renderMetadataTab({ onMetadataChange }); @@ -430,12 +467,11 @@ describe("MetadataTab", () => { target: { value: "value with spaces & symbols $%^" }, }); - expect(onMetadataChange).toHaveBeenCalledWith({ - "key-with-special@chars!": "value with spaces & symbols $%^", - }); + expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument(); + expect(onMetadataChange).toHaveBeenLastCalledWith({}); }); - it("should handle unicode characters", () => { + it("should reject unicode names that do not start with an alphanumeric character", () => { const onMetadataChange = jest.fn(); renderMetadataTab({ onMetadataChange }); @@ -448,9 +484,8 @@ describe("MetadataTab", () => { fireEvent.change(keyInput, { target: { value: "🔑_key" } }); fireEvent.change(valueInput, { target: { value: "值_value_🎯" } }); - expect(onMetadataChange).toHaveBeenCalledWith({ - "🔑_key": "值_value_🎯", - }); + expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument(); + expect(onMetadataChange).toHaveBeenLastCalledWith({}); }); it("should handle very long keys and values", () => { diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index 3e07d017f..5e196ed36 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -6,6 +6,11 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Tabs } from "@/components/ui/tabs"; import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; import { within } from "@testing-library/react"; +import { + META_NAME_RULES_MESSAGE, + META_PREFIX_RULES_MESSAGE, + RESERVED_NAMESPACE_MESSAGE, +} from "@/utils/metaUtils"; describe("ToolsTab", () => { beforeEach(() => { @@ -815,6 +820,37 @@ describe("ToolsTab", () => { }); }); + describe("Reserved metadata keys", () => { + test.each` + description | value | message + ${"reserved metadata prefix"} | ${"modelcontextprotocol.io/flip"} | ${RESERVED_NAMESPACE_MESSAGE} + ${"reserved root without slash"} | ${"modelcontextprotocol.io"} | ${RESERVED_NAMESPACE_MESSAGE} + ${"nested modelcontextprotocol domain"} | ${"api.modelcontextprotocol.org"} | ${RESERVED_NAMESPACE_MESSAGE} + ${"nested mcp domain"} | ${"tools.mcp.com/resource"} | ${RESERVED_NAMESPACE_MESSAGE} + ${"invalid name segment"} | ${"custom/bad-"} | ${META_NAME_RULES_MESSAGE} + ${"invalid prefix label"} | ${"1invalid-prefix/value"} | ${META_PREFIX_RULES_MESSAGE} + `( + "should block execution when $description is provided", + async ({ value, message }) => { + renderToolsTab({ selectedTool: mockTools[0] }); + + const addPairButton = screen.getByRole("button", { name: /add pair/i }); + await act(async () => { + fireEvent.click(addPairButton); + }); + + const keyInput = screen.getByPlaceholderText("e.g. requestId"); + await act(async () => { + fireEvent.change(keyInput, { target: { value } }); + }); + + const runButton = screen.getByRole("button", { name: /run tool/i }); + expect(runButton).toBeDisabled(); + expect(screen.getByText(message)).toBeInTheDocument(); + }, + ); + }); + describe("ToolResults Metadata", () => { it("should display metadata information when present in toolResult", () => { const resultWithMetadata = { diff --git a/client/src/utils/metaUtils.ts b/client/src/utils/metaUtils.ts new file mode 100644 index 000000000..c19b63125 --- /dev/null +++ b/client/src/utils/metaUtils.ts @@ -0,0 +1,162 @@ +/** + * Metadata helpers aligned with the official MCP specification. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ + +const META_PREFIX_LABEL_REGEX = /^[a-z](?:[a-z\d-]*[a-z\d])?$/i; +const META_NAME_REGEX = /^[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i; +const RESERVED_NAMESPACE_LABELS = ["modelcontextprotocol", "mcp"]; + +export const RESERVED_NAMESPACE_MESSAGE = + 'Keys using the "modelcontextprotocol.*" or "mcp.*" namespaces are reserved by MCP and cannot be used.'; + +export const META_NAME_RULES_MESSAGE = + "Names must begin and end with an alphanumeric character and may only contain alphanumerics, hyphens (-), underscores (_), or dots (.) in between."; + +export const META_PREFIX_RULES_MESSAGE = + "Prefixes must be dot-separated labels that start with a letter and end with a letter or digit (e.g. example.domain/)."; + +/** + * Extracts the prefix portion (before the first slash) of a metadata key, if present. + * + * @param key - Raw metadata key entered by the user. + * @returns The prefix segment (without the trailing slash) or null when no prefix exists. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ +const getPrefixSegment = (key: string): string | null => { + const trimmedKey = key.trim(); + const slashIndex = trimmedKey.indexOf("/"); + if (slashIndex === -1) { + return null; + } + return trimmedKey.slice(0, slashIndex); +}; + +/** + * Normalizes a potential prefix segment by trimming whitespace, removing schemes, + * and stripping trailing URL components so only the label portion remains. + * + * @param segment - The prefix segment extracted from the metadata key. + * @returns A normalized string suitable for label parsing, or null when empty. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ +const normalizeSegment = (segment: string): string | null => { + if (!segment) return null; + let normalized = segment.trim().toLowerCase(); + if (!normalized) return null; + + const schemeIndex = normalized.indexOf("://"); + if (schemeIndex !== -1) { + normalized = normalized.slice(schemeIndex + 3); + } + + const stopChars = ["?", "#", ":"]; + let endIndex = normalized.length; + stopChars.forEach((char) => { + const idx = normalized.indexOf(char); + if (idx !== -1 && idx < endIndex) { + endIndex = idx; + } + }); + + return normalized.slice(0, endIndex) || null; +}; + +/** + * Splits a normalized prefix into dot-separated labels and validates each label + * against the MCP prefix rules (start with letter, end with letter/digit, interior alphanumerics or hyphens). + * + * @param segment - Normalized prefix string. + * @returns Array of labels if valid, otherwise null. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ +const splitLabels = (segment: string): string[] | null => { + const normalized = normalizeSegment(segment); + if (!normalized) return null; + + const labels = normalized.split("."); + if ( + labels.length === 0 || + labels.some((label) => !label || !META_PREFIX_LABEL_REGEX.test(label)) + ) { + return null; + } + + return labels; +}; + +/** + * Determines whether a metadata key is within the MCP-reserved namespace. + * + * @param key - Full metadata key entered by the user. + * @returns True if the key's prefix belongs to a reserved namespace. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ +export const isReservedMetaKey = (key: string): boolean => { + const trimmedKey = key.trim(); + if (!trimmedKey) { + return false; + } + + const candidateSegment = getPrefixSegment(trimmedKey) ?? trimmedKey; + const labels = splitLabels(candidateSegment); + if (!labels || labels.length < 2) { + return false; + } + + for (let i = 0; i < labels.length - 1; i += 1) { + const current = labels[i]; + const next = labels[i + 1]; + if ( + RESERVED_NAMESPACE_LABELS.includes(current) && + META_PREFIX_LABEL_REGEX.test(next) + ) { + return true; + } + } + + return false; +}; + +/** + * Validates the optional prefix portion of a metadata key. + * + * @param key - Full metadata key entered by the user. + * @returns True when the prefix is absent or satisfies the MCP label requirements. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ +export const hasValidMetaPrefix = (key: string): boolean => { + const prefixSegment = getPrefixSegment(key); + if (prefixSegment === null) { + return true; + } + + return splitLabels(prefixSegment) !== null; +}; + +const extractMetaName = (key: string): string => { + const trimmedKey = key.trim(); + if (!trimmedKey) return ""; + + const slashIndex = trimmedKey.lastIndexOf("/"); + if (slashIndex === -1) { + return trimmedKey; + } + + return trimmedKey.slice(slashIndex + 1); +}; + +/** + * Validates the "name" portion of a metadata key, regardless of whether a prefix exists. + * + * @param key - Full metadata key entered by the user. + * @returns True if the name portion is valid per the MCP spec. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta + */ +export const hasValidMetaName = (key: string): boolean => { + const name = extractMetaName(key); + if (!name) return false; + + return META_NAME_REGEX.test(name); +};