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"; } from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import {
hasValidMetaName,
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import { cacheToolOutputSchemas } from "./utils/schemaUtils";
@ -85,6 +90,24 @@ import MetadataTab from "./components/MetadataTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; 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 App = () => {
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState< const [resourceTemplates, setResourceTemplates] = useState<
@ -206,7 +229,10 @@ const App = () => {
const savedMetadata = localStorage.getItem("lastMetadata"); const savedMetadata = localStorage.getItem("lastMetadata");
if (savedMetadata) { if (savedMetadata) {
try { try {
return JSON.parse(savedMetadata); const parsed = JSON.parse(savedMetadata);
if (parsed && typeof parsed === "object") {
return filterReservedMetadata(parsed);
}
} catch (error) { } catch (error) {
console.warn("Failed to parse saved metadata:", error); console.warn("Failed to parse saved metadata:", error);
} }
@ -219,8 +245,9 @@ const App = () => {
}; };
const handleMetadataChange = (newMetadata: Record<string, string>) => { const handleMetadataChange = (newMetadata: Record<string, string>) => {
setMetadata(newMetadata); const sanitizedMetadata = filterReservedMetadata(newMetadata);
localStorage.setItem("lastMetadata", JSON.stringify(newMetadata)); setMetadata(sanitizedMetadata);
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
}; };
const nextRequestId = useRef(0); const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]); const rootsRef = useRef<Root[]>([]);
@ -824,7 +851,7 @@ const App = () => {
const mergedMetadata = { const mergedMetadata = {
...metadata, // General metadata ...metadata, // General metadata
progressToken: progressTokenRef.current++, progressToken: progressTokenRef.current++,
...(toolMetadata ?? {}), // Tool-specific metadata ...toolMetadata, // Tool-specific metadata
}; };
const response = await sendMCPRequest( const response = await sendMCPRequest(

View file

@ -4,6 +4,15 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Trash2, Plus } from "lucide-react"; 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 { interface MetadataEntry {
key: string; key: string;
@ -47,8 +56,15 @@ const MetadataTab: React.FC<MetadataTabProps> = ({
const updateMetadata = (newEntries: MetadataEntry[]) => { const updateMetadata = (newEntries: MetadataEntry[]) => {
const metadataObject: Record<string, string> = {}; const metadataObject: Record<string, string> = {};
newEntries.forEach(({ key, value }) => { newEntries.forEach(({ key, value }) => {
if (key.trim() && value.trim()) { const trimmedKey = key.trim();
metadataObject[key.trim()] = value.trim(); if (
trimmedKey &&
value.trim() &&
hasValidMetaPrefix(trimmedKey) &&
!isReservedMetaKey(trimmedKey) &&
hasValidMetaName(trimmedKey)
) {
metadataObject[trimmedKey] = value.trim();
} }
}); });
onMetadataChange(metadataObject); onMetadataChange(metadataObject);
@ -71,39 +87,72 @@ const MetadataTab: React.FC<MetadataTabProps> = ({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{entries.map((entry, index) => ( {entries.map((entry, index) => {
<div key={index} className="flex items-center space-x-2"> const trimmedKey = entry.key.trim();
<div className="flex-1"> const hasInvalidPrefix =
<Label htmlFor={`key-${index}`} className="sr-only"> trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
Key const isReservedKey =
</Label> trimmedKey !== "" && isReservedMetaKey(trimmedKey);
<Input const hasInvalidName =
id={`key-${index}`} trimmedKey !== "" && !hasValidMetaName(trimmedKey);
placeholder="Key" const validationMessage = hasInvalidPrefix
value={entry.key} ? META_PREFIX_RULES_MESSAGE
onChange={(e) => updateEntry(index, "key", e.target.value)} : 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>
<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> </div>
{entries.length === 0 && ( {entries.length === 0 && (

View file

@ -40,6 +40,15 @@ import JsonView from "./JsonView";
import ToolResults from "./ToolResults"; import ToolResults from "./ToolResults";
import { useToast } from "@/lib/hooks/useToast"; import { useToast } from "@/lib/hooks/useToast";
import useCopy from "@/lib/hooks/useCopy"; 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` // Type guard to safely detect the optional _meta field without using `any`
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } => const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
@ -122,6 +131,21 @@ const ToolsTab = ({
formRefs.current = {}; formRefs.current = {};
}, [selectedTool]); }, [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 ( return (
<TabsContent value="tools"> <TabsContent value="tools">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -438,68 +462,102 @@ const ToolsTab = ({
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{metadataEntries.map((entry, index) => ( {metadataEntries.map((entry, index) => {
<div const trimmedKey = entry.key.trim();
key={entry.id} const hasInvalidPrefix =
className="flex items-center gap-2 w-full" trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
> const isReservedKey =
<Label trimmedKey !== "" && isReservedMetaKey(trimmedKey);
htmlFor={`metadata-key-${entry.id}`} const hasInvalidName =
className="text-xs shrink-0" trimmedKey !== "" && !hasValidMetaName(trimmedKey);
> const validationMessage = hasInvalidPrefix
Key ? META_PREFIX_RULES_MESSAGE
</Label> : isReservedKey
<Input ? RESERVED_NAMESPACE_MESSAGE
id={`metadata-key-${entry.id}`} : hasInvalidName
value={entry.key} ? META_NAME_RULES_MESSAGE
placeholder="e.g. requestId" : null;
onChange={(e) => { return (
const value = e.target.value; <div key={entry.id} className="space-y-1">
setMetadataEntries((prev) => <div className="flex items-center gap-2 w-full">
prev.map((m, i) => <Label
i === index ? { ...m, key: value } : m, htmlFor={`metadata-key-${entry.id}`}
), className="text-xs shrink-0"
); >
}} Key
className="h-8 flex-1" </Label>
/> <Input
<Label id={`metadata-key-${entry.id}`}
htmlFor={`metadata-value-${entry.id}`} value={entry.key}
className="text-xs shrink-0" placeholder="e.g. requestId"
> onChange={(e) => {
Value const value = e.target.value;
</Label> setMetadataEntries((prev) =>
<Input prev.map((m, i) =>
id={`metadata-value-${entry.id}`} i === index ? { ...m, key: value } : m,
value={entry.value} ),
placeholder="e.g. 12345" );
onChange={(e) => { }}
const value = e.target.value; className={cn(
setMetadataEntries((prev) => "h-8 flex-1",
prev.map((m, i) => validationMessage &&
i === index ? { ...m, value } : m, "border-red-500 focus-visible:ring-red-500 focus-visible:ring-1",
), )}
); aria-invalid={Boolean(validationMessage)}
}} />
className="h-8 flex-1" <Label
/> htmlFor={`metadata-value-${entry.id}`}
<Button className="text-xs shrink-0"
size="sm" >
variant="ghost" Value
className="h-8 w-8 p-0 ml-auto shrink-0" </Label>
onClick={() => <Input
setMetadataEntries((prev) => id={`metadata-value-${entry.id}`}
prev.filter((_, i) => i !== index), value={entry.value}
) placeholder="e.g. 12345"
} onChange={(e) => {
aria-label={`Remove meta pair ${index + 1}`} const value = e.target.value;
> setMetadataEntries((prev) =>
- prev.map((m, i) =>
</Button> i === index ? { ...m, value } : m,
</div> ),
))} );
}}
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> </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> </div>
{selectedTool.outputSchema && ( {selectedTool.outputSchema && (
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg"> <div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
@ -585,7 +643,15 @@ const ToolsTab = ({
const metadata = metadataEntries.reduce< const metadata = metadataEntries.reduce<
Record<string, unknown> Record<string, unknown>
>((acc, { key, value }) => { >((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; return acc;
}, {}); }, {});
await callTool( await callTool(
@ -597,7 +663,13 @@ const ToolsTab = ({
setIsToolRunning(false); setIsToolRunning(false);
} }
}} }}
disabled={isToolRunning || hasValidationErrors} disabled={
isToolRunning ||
hasValidationErrors ||
hasReservedMetadataEntry ||
hasInvalidMetaPrefixEntry ||
hasInvalidMetaNameEntry
}
> >
{isToolRunning ? ( {isToolRunning ? (
<> <>

View file

@ -2,6 +2,11 @@ import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import MetadataTab from "../MetadataTab"; import MetadataTab from "../MetadataTab";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
} from "@/utils/metaUtils";
describe("MetadataTab", () => { describe("MetadataTab", () => {
const defaultProps = { 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", () => { describe("Data Validation and Trimming", () => {
it("should trim whitespace from keys and values", () => { it("should trim whitespace from keys and values", () => {
const onMetadataChange = jest.fn(); const onMetadataChange = jest.fn();
@ -413,7 +450,7 @@ describe("MetadataTab", () => {
}); });
describe("Edge Cases", () => { 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(); const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange }); renderMetadataTab({ onMetadataChange });
@ -430,12 +467,11 @@ describe("MetadataTab", () => {
target: { value: "value with spaces & symbols $%^" }, target: { value: "value with spaces & symbols $%^" },
}); });
expect(onMetadataChange).toHaveBeenCalledWith({ expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
"key-with-special@chars!": "value with spaces & symbols $%^", 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(); const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange }); renderMetadataTab({ onMetadataChange });
@ -448,9 +484,8 @@ describe("MetadataTab", () => {
fireEvent.change(keyInput, { target: { value: "🔑_key" } }); fireEvent.change(keyInput, { target: { value: "🔑_key" } });
fireEvent.change(valueInput, { target: { value: "值_value_🎯" } }); fireEvent.change(valueInput, { target: { value: "值_value_🎯" } });
expect(onMetadataChange).toHaveBeenCalledWith({ expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
"🔑_key": "值_value_🎯", expect(onMetadataChange).toHaveBeenLastCalledWith({});
});
}); });
it("should handle very long keys and values", () => { 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 { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
import { within } from "@testing-library/react"; import { within } from "@testing-library/react";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
} from "@/utils/metaUtils";
describe("ToolsTab", () => { describe("ToolsTab", () => {
beforeEach(() => { 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", () => { describe("ToolResults Metadata", () => {
it("should display metadata information when present in toolResult", () => { it("should display metadata information when present in toolResult", () => {
const resultWithMetadata = { 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);
};