add validations against spec, add reserved metadata key validations

This commit is contained in:
Konstantin Konstantinov 2025-11-15 01:26:13 +02:00
commit a45247a4f2
No known key found for this signature in database
6 changed files with 489 additions and 108 deletions

View file

@ -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<string, string>,
): Record<string, string> => {
return Object.entries(metadata).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (
!isReservedMetaKey(key) &&
hasValidMetaPrefix(key) &&
hasValidMetaName(key)
) {
acc[key] = value;
}
return acc;
},
{},
);
};
const App = () => {
const [resources, setResources] = useState<Resource[]>([]);
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<string, string>) => {
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<Root[]>([]);
@ -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(

View file

@ -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<MetadataTabProps> = ({
const updateMetadata = (newEntries: MetadataEntry[]) => {
const metadataObject: Record<string, string> = {};
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<MetadataTabProps> = ({
</div>
<div className="space-y-3">
{entries.map((entry, index) => (
<div key={index} className="flex items-center space-x-2">
<div className="flex-1">
<Label htmlFor={`key-${index}`} className="sr-only">
Key
</Label>
<Input
id={`key-${index}`}
placeholder="Key"
value={entry.key}
onChange={(e) => 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 (
<div key={index} className="space-y-1">
<div className="flex items-center space-x-2">
<div className="flex-1">
<Label htmlFor={`key-${index}`} className="sr-only">
Key
</Label>
<Input
id={`key-${index}`}
placeholder="Key"
value={entry.key}
onChange={(e) =>
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",
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`value-${index}`} className="sr-only">
Value
</Label>
<Input
id={`value-${index}`}
placeholder="Value"
value={entry.value}
onChange={(e) =>
updateEntry(index, "value", e.target.value)
}
disabled={Boolean(validationMessage)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeEntry(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400">
{validationMessage}
</p>
)}
</div>
<div className="flex-1">
<Label htmlFor={`value-${index}`} className="sr-only">
Value
</Label>
<Input
id={`value-${index}`}
placeholder="Value"
value={entry.value}
onChange={(e) => updateEntry(index, "value", e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeEntry(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
);
})}
</div>
{entries.length === 0 && (

View file

@ -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 (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
@ -438,68 +462,102 @@ const ToolsTab = ({
</p>
) : (
<div className="space-y-2">
{metadataEntries.map((entry, index) => (
<div
key={entry.id}
className="flex items-center gap-2 w-full"
>
<Label
htmlFor={`metadata-key-${entry.id}`}
className="text-xs shrink-0"
>
Key
</Label>
<Input
id={`metadata-key-${entry.id}`}
value={entry.key}
placeholder="e.g. requestId"
onChange={(e) => {
const value = e.target.value;
setMetadataEntries((prev) =>
prev.map((m, i) =>
i === index ? { ...m, key: value } : m,
),
);
}}
className="h-8 flex-1"
/>
<Label
htmlFor={`metadata-value-${entry.id}`}
className="text-xs shrink-0"
>
Value
</Label>
<Input
id={`metadata-value-${entry.id}`}
value={entry.value}
placeholder="e.g. 12345"
onChange={(e) => {
const value = e.target.value;
setMetadataEntries((prev) =>
prev.map((m, i) =>
i === index ? { ...m, value } : m,
),
);
}}
className="h-8 flex-1"
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 ml-auto shrink-0"
onClick={() =>
setMetadataEntries((prev) =>
prev.filter((_, i) => i !== index),
)
}
aria-label={`Remove meta pair ${index + 1}`}
>
-
</Button>
</div>
))}
{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 (
<div key={entry.id} className="space-y-1">
<div className="flex items-center gap-2 w-full">
<Label
htmlFor={`metadata-key-${entry.id}`}
className="text-xs shrink-0"
>
Key
</Label>
<Input
id={`metadata-key-${entry.id}`}
value={entry.key}
placeholder="e.g. requestId"
onChange={(e) => {
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)}
/>
<Label
htmlFor={`metadata-value-${entry.id}`}
className="text-xs shrink-0"
>
Value
</Label>
<Input
id={`metadata-value-${entry.id}`}
value={entry.value}
placeholder="e.g. 12345"
onChange={(e) => {
const value = e.target.value;
setMetadataEntries((prev) =>
prev.map((m, i) =>
i === index ? { ...m, value } : m,
),
);
}}
className="h-8 flex-1"
disabled={Boolean(validationMessage)}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 ml-auto shrink-0"
onClick={() =>
setMetadataEntries((prev) =>
prev.filter((_, i) => i !== index),
)
}
aria-label={`Remove meta pair ${index + 1}`}
>
-
</Button>
</div>
{validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400">
{validationMessage}
</p>
)}
</div>
);
})}
</div>
)}
{(hasReservedMetadataEntry ||
hasInvalidMetaPrefixEntry ||
hasInvalidMetaNameEntry) && (
<p className="text-xs text-red-600 dark:text-red-400">
Remove reserved or invalid metadata keys (prefix/name)
before running the tool.
</p>
)}
</div>
{selectedTool.outputSchema && (
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
@ -585,7 +643,15 @@ const ToolsTab = ({
const metadata = metadataEntries.reduce<
Record<string, unknown>
>((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 ? (
<>

View file

@ -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", () => {

View file

@ -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 = {

View file

@ -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);
};