From a45247a4f289a6ee690f3500f0e08d7144012a09 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 15 Nov 2025 01:26:13 +0200 Subject: [PATCH] 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 c53294d5..fda6b2a8 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 477952c3..beb26ac7 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 02efc729..f8175f9d 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 9d0cbf99..f22e102f 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 3e07d017..5e196ed3 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 00000000..c19b6312 --- /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); +};