From 842aa3c8ca33a93821afc8587940463c2064b7bc Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:23:29 +0200 Subject: [PATCH] Added general meta data section to add pairs for all requests --- cli/src/client/prompts.ts | 20 +- cli/src/client/resources.ts | 21 +- cli/src/client/tools.ts | 27 +- cli/src/index.ts | 45 +- client/src/App.tsx | 42 +- client/src/components/MetaDataTab.tsx | 121 ++++ .../components/__tests__/MetaDataTab.test.tsx | 558 ++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 26 +- 8 files changed, 835 insertions(+), 25 deletions(-) create mode 100644 client/src/components/MetaDataTab.tsx create mode 100644 client/src/components/__tests__/MetaDataTab.test.tsx diff --git a/cli/src/client/prompts.ts b/cli/src/client/prompts.ts index 87014617..45e269f2 100644 --- a/cli/src/client/prompts.ts +++ b/cli/src/client/prompts.ts @@ -12,9 +12,14 @@ type JsonValue = | { [key: string]: JsonValue }; // List available prompts -export async function listPrompts(client: Client): Promise { +export async function listPrompts( + client: Client, + metaData?: Record, +): Promise { try { - const response = await client.listPrompts(); + const params = + metaData && Object.keys(metaData).length > 0 ? { _meta: metaData } : {}; + const response = await client.listPrompts(params); return response; } catch (error) { throw new Error( @@ -28,6 +33,7 @@ export async function getPrompt( client: Client, name: string, args?: Record, + metaData?: Record, ): Promise { try { // Convert all arguments to strings for prompt arguments @@ -44,10 +50,16 @@ export async function getPrompt( } } - const response = await client.getPrompt({ + const params: any = { name, arguments: stringArgs, - }); + }; + + if (metaData && Object.keys(metaData).length > 0) { + params._meta = metaData; + } + + const response = await client.getPrompt(params); return response; } catch (error) { diff --git a/cli/src/client/resources.ts b/cli/src/client/resources.ts index bf33d64d..d530bbc2 100644 --- a/cli/src/client/resources.ts +++ b/cli/src/client/resources.ts @@ -2,9 +2,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpResponse } from "./types.js"; // List available resources -export async function listResources(client: Client): Promise { +export async function listResources( + client: Client, + metaData?: Record, +): Promise { try { - const response = await client.listResources(); + const params = + metaData && Object.keys(metaData).length > 0 ? { _meta: metaData } : {}; + const response = await client.listResources(params); return response; } catch (error) { throw new Error( @@ -17,9 +22,14 @@ export async function listResources(client: Client): Promise { export async function readResource( client: Client, uri: string, + metaData?: Record, ): Promise { try { - const response = await client.readResource({ uri }); + const params: any = { uri }; + if (metaData && Object.keys(metaData).length > 0) { + params._meta = metaData; + } + const response = await client.readResource(params); return response; } catch (error) { throw new Error( @@ -31,9 +41,12 @@ export async function readResource( // List resource templates export async function listResourceTemplates( client: Client, + metaData?: Record, ): Promise { try { - const response = await client.listResourceTemplates(); + const params = + metaData && Object.keys(metaData).length > 0 ? { _meta: metaData } : {}; + const response = await client.listResourceTemplates(params); return response; } catch (error) { throw new Error( diff --git a/cli/src/client/tools.ts b/cli/src/client/tools.ts index 9da0b4d9..df2634a7 100644 --- a/cli/src/client/tools.ts +++ b/cli/src/client/tools.ts @@ -19,9 +19,14 @@ type JsonSchemaType = { items?: JsonSchemaType; }; -export async function listTools(client: Client): Promise { +export async function listTools( + client: Client, + metaData?: Record, +): Promise { try { - const response = await client.listTools(); + const params = + metaData && Object.keys(metaData).length > 0 ? { _meta: metaData } : {}; + const response = await client.listTools(params); return response; } catch (error) { throw new Error( @@ -82,9 +87,11 @@ export async function callTool( client: Client, name: string, args: Record, + generalMetaData?: Record, + toolSpecificMetaData?: Record, ): Promise { try { - const toolsResponse = await listTools(client); + const toolsResponse = await listTools(client, generalMetaData); const tools = toolsResponse.tools as Tool[]; const tool = tools.find((t) => t.name === name); @@ -106,9 +113,23 @@ export async function callTool( } } + // Merge general metadata with tool-specific metadata + // Tool-specific metadata takes precedence over general metadata + let mergedMeta: Record | undefined; + if (generalMetaData || toolSpecificMetaData) { + mergedMeta = { + ...(generalMetaData || {}), + ...(toolSpecificMetaData || {}), + }; + } + const response = await client.callTool({ name: name, arguments: convertedArgs, + _meta: + mergedMeta && Object.keys(mergedMeta).length > 0 + ? mergedMeta + : undefined, }); return response; } catch (error) { diff --git a/cli/src/index.ts b/cli/src/index.ts index a7c5896d..e9f60ebd 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -42,8 +42,10 @@ type Args = { logLevel?: LogLevel; toolName?: string; toolArg?: Record; + toolMeta?: Record; transport?: "sse" | "stdio" | "http"; headers?: Record; + metaData?: Record; }; function createTransportOptions( @@ -121,7 +123,7 @@ async function callMethod(args: Args): Promise { // Tools methods if (args.method === "tools/list") { - result = await listTools(client); + result = await listTools(client, args.metaData); } else if (args.method === "tools/call") { if (!args.toolName) { throw new Error( @@ -129,11 +131,17 @@ async function callMethod(args: Args): Promise { ); } - result = await callTool(client, args.toolName, args.toolArg || {}); + result = await callTool( + client, + args.toolName, + args.toolArg || {}, + args.metaData, + args.toolMeta, + ); } // Resources methods else if (args.method === "resources/list") { - result = await listResources(client); + result = await listResources(client, args.metaData); } else if (args.method === "resources/read") { if (!args.uri) { throw new Error( @@ -141,13 +149,13 @@ async function callMethod(args: Args): Promise { ); } - result = await readResource(client, args.uri); + result = await readResource(client, args.uri, args.metaData); } else if (args.method === "resources/templates/list") { - result = await listResourceTemplates(client); + result = await listResourceTemplates(client, args.metaData); } // Prompts methods else if (args.method === "prompts/list") { - result = await listPrompts(client); + result = await listPrompts(client, args.metaData); } else if (args.method === "prompts/get") { if (!args.promptName) { throw new Error( @@ -155,7 +163,12 @@ async function callMethod(args: Args): Promise { ); } - result = await getPrompt(client, args.promptName, args.promptArgs || {}); + result = await getPrompt( + client, + args.promptName, + args.promptArgs || {}, + args.metaData, + ); } // Logging methods else if (args.method === "logging/setLevel") { @@ -327,6 +340,8 @@ function parseArgs(): Args { const options = program.opts() as Omit & { header?: Record; + meta?: Record; + toolMeta?: Record; }; let remainingArgs = program.args; @@ -344,6 +359,22 @@ function parseArgs(): Args { target: finalArgs, ...options, headers: options.header, // commander.js uses 'header' field, map to 'headers' + metaData: options.meta + ? Object.fromEntries( + Object.entries(options.meta).map(([key, value]) => [ + key, + String(value), + ]), + ) + : undefined, + toolMeta: options.toolMeta + ? Object.fromEntries( + Object.entries(options.toolMeta).map(([key, value]) => [ + key, + String(value), + ]), + ) + : undefined, }; } diff --git a/client/src/App.tsx b/client/src/App.tsx index fa9bc42a..7f531471 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -47,6 +47,7 @@ import { Hash, Key, MessageSquare, + Settings, } from "lucide-react"; import { z } from "zod"; @@ -80,6 +81,7 @@ import { CustomHeaders, migrateFromLegacyAuth, } from "./lib/types/customHeaders"; +import MetaDataTab from "./components/MetaDataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -195,9 +197,27 @@ const App = () => { const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); + // Meta data 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); + } + } + return {}; + }); + const updateAuthState = (updates: Partial) => { setAuthState((prev) => ({ ...prev, ...updates })); }; + + const handleMetaDataChange = (newMetaData: Record) => { + setMetaData(newMetaData); + localStorage.setItem("lastMetaData", JSON.stringify(newMetaData)); + }; const nextRequestId = useRef(0); const rootsRef = useRef([]); @@ -299,6 +319,7 @@ const App = () => { }, getRoots: () => rootsRef.current, defaultLoggingLevel: logLevel, + metaData, }); useEffect(() => { @@ -789,16 +810,21 @@ const App = () => { ? cleanParams(params, tool.inputSchema as JsonSchemaType) : params; + // Merge general metadata with tool-specific metadata + // Tool-specific metadata takes precedence over general metadata + const mergedMeta = { + ...metaData, // General metadata first + progressToken: progressTokenRef.current++, + ...(meta ?? {}), // Tool-specific metadata overrides + }; + const response = await sendMCPRequest( { method: "tools/call" as const, params: { name, arguments: cleanedParams, - _meta: { - progressToken: progressTokenRef.current++, - ...(meta ?? {}), - }, + _meta: mergedMeta, }, }, CompatibilityCallToolResultSchema, @@ -994,6 +1020,10 @@ const App = () => { Auth + + + Meta Data +
@@ -1154,6 +1184,10 @@ const App = () => { onRootsChange={handleRootsChange} /> + )}
diff --git a/client/src/components/MetaDataTab.tsx b/client/src/components/MetaDataTab.tsx new file mode 100644 index 00000000..c4cc6d21 --- /dev/null +++ b/client/src/components/MetaDataTab.tsx @@ -0,0 +1,121 @@ +import React, { useState } from "react"; +import { TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Trash2, Plus } from "lucide-react"; + +interface MetaDataEntry { + key: string; + value: string; +} + +interface MetaDataTabProps { + metaData: Record; + onMetaDataChange: (metaData: Record) => void; +} + +const MetaDataTab: React.FC = ({ + metaData, + onMetaDataChange, +}) => { + const [entries, setEntries] = useState(() => { + return Object.entries(metaData).map(([key, value]) => ({ key, value })); + }); + + const addEntry = () => { + setEntries([...entries, { key: "", value: "" }]); + }; + + const removeEntry = (index: number) => { + const newEntries = entries.filter((_, i) => i !== index); + setEntries(newEntries); + updateMetaData(newEntries); + }; + + const updateEntry = ( + index: number, + field: "key" | "value", + value: string, + ) => { + const newEntries = [...entries]; + newEntries[index][field] = value; + setEntries(newEntries); + updateMetaData(newEntries); + }; + + const updateMetaData = (newEntries: MetaDataEntry[]) => { + const metaDataObject: Record = {}; + newEntries.forEach(({ key, value }) => { + if (key.trim() && value.trim()) { + metaDataObject[key.trim()] = value.trim(); + } + }); + onMetaDataChange(metaDataObject); + }; + + return ( + +
+
+
+

Meta Data

+

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

+
+ +
+ +
+ {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; }