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