add validations against spec, add reserved metadata key validations
This commit is contained in:
parent
3044d8f14c
commit
a45247a4f2
6 changed files with 489 additions and 108 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
162
client/src/utils/metaUtils.ts
Normal file
162
client/src/utils/metaUtils.ts
Normal 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);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue