Merge branch 'main' into jesselumarie/test-SEP-974
This commit is contained in:
commit
35286a24c6
16 changed files with 3294 additions and 705 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";
|
||||
|
|
@ -47,6 +52,7 @@ import {
|
|||
Hash,
|
||||
Key,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
import { z } from "zod";
|
||||
|
|
@ -80,9 +86,28 @@ import {
|
|||
CustomHeaders,
|
||||
migrateFromLegacyAuth,
|
||||
} from "./lib/types/customHeaders";
|
||||
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<
|
||||
|
|
@ -199,9 +224,31 @@ const App = () => {
|
|||
const [authState, setAuthState] =
|
||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
||||
|
||||
// Metadata state - persisted in localStorage
|
||||
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
|
||||
const savedMetadata = localStorage.getItem("lastMetadata");
|
||||
if (savedMetadata) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedMetadata);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return filterReservedMetadata(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse saved metadata:", error);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleMetadataChange = (newMetadata: Record<string, string>) => {
|
||||
const sanitizedMetadata = filterReservedMetadata(newMetadata);
|
||||
setMetadata(sanitizedMetadata);
|
||||
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
|
||||
};
|
||||
const nextRequestId = useRef(0);
|
||||
const rootsRef = useRef<Root[]>([]);
|
||||
|
||||
|
|
@ -305,6 +352,7 @@ const App = () => {
|
|||
},
|
||||
getRoots: () => rootsRef.current,
|
||||
defaultLoggingLevel: logLevel,
|
||||
metadata,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -785,7 +833,11 @@ const App = () => {
|
|||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
const callTool = async (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
toolMetadata?: Record<string, unknown>,
|
||||
) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
try {
|
||||
|
|
@ -795,15 +847,21 @@ const App = () => {
|
|||
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
||||
: params;
|
||||
|
||||
// Merge general metadata with tool-specific metadata
|
||||
// Tool-specific metadata takes precedence over general metadata
|
||||
const mergedMetadata = {
|
||||
...metadata, // General metadata
|
||||
progressToken: progressTokenRef.current++,
|
||||
...toolMetadata, // Tool-specific metadata
|
||||
};
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "tools/call" as const,
|
||||
params: {
|
||||
name,
|
||||
arguments: cleanedParams,
|
||||
_meta: {
|
||||
progressToken: progressTokenRef.current++,
|
||||
},
|
||||
_meta: mergedMetadata,
|
||||
},
|
||||
},
|
||||
CompatibilityCallToolResultSchema,
|
||||
|
|
@ -1002,6 +1060,10 @@ const App = () => {
|
|||
<Key className="w-4 h-4 mr-2" />
|
||||
Auth
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metadata">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Metadata
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="w-full">
|
||||
|
|
@ -1112,10 +1174,14 @@ const App = () => {
|
|||
setNextToolCursor(undefined);
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
callTool={async (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => {
|
||||
clearError("tools");
|
||||
setToolResult(null);
|
||||
await callTool(name, params);
|
||||
await callTool(name, params, metadata);
|
||||
}}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={(tool) => {
|
||||
|
|
@ -1158,6 +1224,10 @@ const App = () => {
|
|||
onRootsChange={handleRootsChange}
|
||||
/>
|
||||
<AuthDebuggerWrapper />
|
||||
<MetadataTab
|
||||
metadata={metadata}
|
||||
onMetadataChange={handleMetadataChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -113,8 +113,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
setJsonError(errorMessage);
|
||||
|
||||
// Reset to default for clearly invalid JSON (not just incomplete typing)
|
||||
const trimmed = jsonString.trim();
|
||||
if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
|
||||
const trimmed = jsonString?.trim();
|
||||
if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
|
||||
onChange(generateDefaultValue(schema));
|
||||
}
|
||||
}
|
||||
|
|
@ -155,7 +155,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const jsonStr = rawJsonValue.trim();
|
||||
const jsonStr = rawJsonValue?.trim();
|
||||
if (!jsonStr) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
const validateJson = () => {
|
||||
if (!isJsonMode) return { isValid: true, error: null };
|
||||
try {
|
||||
const jsonStr = rawJsonValue.trim();
|
||||
const jsonStr = rawJsonValue?.trim();
|
||||
if (!jsonStr) return { isValid: true, error: null };
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
// Clear any pending debounced update and immediately update parent
|
||||
|
|
|
|||
170
client/src/components/MetadataTab.tsx
Normal file
170
client/src/components/MetadataTab.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React, { useState } from "react";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
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;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MetadataTabProps {
|
||||
metadata: Record<string, string>;
|
||||
onMetadataChange: (metadata: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const MetadataTab: React.FC<MetadataTabProps> = ({
|
||||
metadata,
|
||||
onMetadataChange,
|
||||
}) => {
|
||||
const [entries, setEntries] = useState<MetadataEntry[]>(() => {
|
||||
return Object.entries(metadata).map(([key, value]) => ({ key, value }));
|
||||
});
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries([...entries, { key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
const newEntries = entries.filter((_, i) => i !== index);
|
||||
setEntries(newEntries);
|
||||
updateMetadata(newEntries);
|
||||
};
|
||||
|
||||
const updateEntry = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
value: string,
|
||||
) => {
|
||||
const newEntries = [...entries];
|
||||
newEntries[index][field] = value;
|
||||
setEntries(newEntries);
|
||||
updateMetadata(newEntries);
|
||||
};
|
||||
|
||||
const updateMetadata = (newEntries: MetadataEntry[]) => {
|
||||
const metadataObject: Record<string, string> = {};
|
||||
newEntries.forEach(({ key, value }) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (
|
||||
trimmedKey &&
|
||||
value.trim() &&
|
||||
hasValidMetaPrefix(trimmedKey) &&
|
||||
!isReservedMetaKey(trimmedKey) &&
|
||||
hasValidMetaName(trimmedKey)
|
||||
) {
|
||||
metadataObject[trimmedKey] = value.trim();
|
||||
}
|
||||
});
|
||||
onMetadataChange(metadataObject);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="metadata">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Metadata</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Key-value pairs that will be included in all MCP requests
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addEntry} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Entry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{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>
|
||||
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
No metadata entries. Click "Add Entry" to add key-value pairs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataTab;
|
||||
|
|
@ -205,7 +205,8 @@ const ToolResults = ({
|
|||
/>
|
||||
)}
|
||||
{item.type === "resource" &&
|
||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||
(item.resource?.mimeType?.startsWith("audio/") &&
|
||||
"blob" in item.resource ? (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
generateDefaultValue,
|
||||
isPropertyRequired,
|
||||
normalizeUnionType,
|
||||
resolveRef,
|
||||
} from "@/utils/schemaUtils";
|
||||
import {
|
||||
CompatibilityCallToolResult,
|
||||
|
|
@ -40,6 +41,15 @@ import ToolResults from "./ToolResults";
|
|||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import useCopy from "@/lib/hooks/useCopy";
|
||||
import IconDisplay, { WithIcons } from "./IconDisplay";
|
||||
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 } =>
|
||||
|
|
@ -61,7 +71,11 @@ const ToolsTab = ({
|
|||
tools: Tool[];
|
||||
listTools: () => void;
|
||||
clearTools: () => void;
|
||||
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
|
||||
callTool: (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
selectedTool: Tool | null;
|
||||
setSelectedTool: (tool: Tool | null) => void;
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
|
|
@ -73,7 +87,10 @@ const ToolsTab = ({
|
|||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
|
||||
const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
|
||||
const [metadataEntries, setMetadataEntries] = useState<
|
||||
{ id: string; key: string; value: string }[]
|
||||
>([]);
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||
const { toast } = useToast();
|
||||
|
|
@ -91,14 +108,21 @@ const ToolsTab = ({
|
|||
useEffect(() => {
|
||||
const params = Object.entries(
|
||||
selectedTool?.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => [
|
||||
key,
|
||||
generateDefaultValue(
|
||||
).map(([key, value]) => {
|
||||
// First resolve any $ref references
|
||||
const resolvedValue = resolveRef(
|
||||
value as JsonSchemaType,
|
||||
key,
|
||||
selectedTool?.inputSchema as JsonSchemaType,
|
||||
),
|
||||
]);
|
||||
);
|
||||
return [
|
||||
key,
|
||||
generateDefaultValue(
|
||||
resolvedValue,
|
||||
key,
|
||||
selectedTool?.inputSchema as JsonSchemaType,
|
||||
),
|
||||
];
|
||||
});
|
||||
setParams(Object.fromEntries(params));
|
||||
|
||||
// Reset validation errors when switching tools
|
||||
|
|
@ -108,6 +132,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">
|
||||
|
|
@ -167,7 +206,12 @@ const ToolsTab = ({
|
|||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const prop = normalizeUnionType(value as JsonSchemaType);
|
||||
// First resolve any $ref references
|
||||
const resolvedValue = resolveRef(
|
||||
value as JsonSchemaType,
|
||||
selectedTool.inputSchema as JsonSchemaType,
|
||||
);
|
||||
const prop = normalizeUnionType(resolvedValue);
|
||||
const inputSchema =
|
||||
selectedTool.inputSchema as JsonSchemaType;
|
||||
const required = isPropertyRequired(key, inputSchema);
|
||||
|
|
@ -194,16 +238,18 @@ const ToolsTab = ({
|
|||
...params,
|
||||
[key]: checked
|
||||
? null
|
||||
: prop.default !== null
|
||||
? prop.default
|
||||
: prop.type === "boolean"
|
||||
? false
|
||||
: prop.type === "string"
|
||||
? ""
|
||||
: prop.type === "number" ||
|
||||
prop.type === "integer"
|
||||
? undefined
|
||||
: undefined,
|
||||
: prop.type === "array"
|
||||
? undefined
|
||||
: prop.default !== null
|
||||
? prop.default
|
||||
: prop.type === "boolean"
|
||||
? false
|
||||
: prop.type === "string"
|
||||
? ""
|
||||
: prop.type === "number" ||
|
||||
prop.type === "integer"
|
||||
? undefined
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -396,6 +442,138 @@ const ToolsTab = ({
|
|||
);
|
||||
},
|
||||
)}
|
||||
<div className="pb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
Tool-specific Metadata:
|
||||
</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2"
|
||||
onClick={() =>
|
||||
setMetadataEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id:
|
||||
(
|
||||
globalThis as unknown as {
|
||||
crypto?: { randomUUID?: () => string };
|
||||
}
|
||||
).crypto?.randomUUID?.() ||
|
||||
Math.random().toString(36).slice(2),
|
||||
key: "",
|
||||
value: "",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Add Pair
|
||||
</Button>
|
||||
</div>
|
||||
{metadataEntries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No metadata pairs.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
|
@ -441,10 +619,12 @@ const ToolsTab = ({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsMetaExpanded(!isMetaExpanded)}
|
||||
onClick={() =>
|
||||
setIsMetadataExpanded(!isMetadataExpanded)
|
||||
}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
{isMetaExpanded ? (
|
||||
{isMetadataExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Collapse
|
||||
|
|
@ -459,40 +639,66 @@ const ToolsTab = ({
|
|||
</div>
|
||||
<div
|
||||
className={`transition-all ${
|
||||
isMetaExpanded ? "" : "max-h-[8rem] overflow-y-auto"
|
||||
isMetadataExpanded
|
||||
? ""
|
||||
: "max-h-[8rem] overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
<JsonView data={selectedTool._meta} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
// Validate JSON inputs before calling tool
|
||||
if (checkValidationErrors()) return;
|
||||
<Button
|
||||
onClick={async () => {
|
||||
// Validate JSON inputs before calling tool
|
||||
if (checkValidationErrors()) return;
|
||||
|
||||
try {
|
||||
setIsToolRunning(true);
|
||||
await callTool(selectedTool.name, params);
|
||||
} finally {
|
||||
setIsToolRunning(false);
|
||||
}
|
||||
}}
|
||||
disabled={isToolRunning || hasValidationErrors}
|
||||
>
|
||||
{isToolRunning ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Run Tool
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
try {
|
||||
setIsToolRunning(true);
|
||||
const metadata = metadataEntries.reduce<
|
||||
Record<string, unknown>
|
||||
>((acc, { key, value }) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (
|
||||
trimmedKey !== "" &&
|
||||
hasValidMetaPrefix(trimmedKey) &&
|
||||
!isReservedMetaKey(trimmedKey) &&
|
||||
hasValidMetaName(trimmedKey)
|
||||
) {
|
||||
acc[trimmedKey] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
await callTool(
|
||||
selectedTool.name,
|
||||
params,
|
||||
Object.keys(metadata).length ? metadata : undefined,
|
||||
);
|
||||
} finally {
|
||||
setIsToolRunning(false);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isToolRunning ||
|
||||
hasValidationErrors ||
|
||||
hasReservedMetadataEntry ||
|
||||
hasInvalidMetaPrefixEntry ||
|
||||
hasInvalidMetaNameEntry
|
||||
}
|
||||
>
|
||||
{isToolRunning ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Run Tool
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe("HistoryAndNotifications", () => {
|
|||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info" as const,
|
||||
message: "First notification",
|
||||
data: "First notification",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -36,7 +36,7 @@ describe("HistoryAndNotifications", () => {
|
|||
params: {
|
||||
progressToken: "test-token",
|
||||
progress: 50,
|
||||
message: "Second notification",
|
||||
total: 100,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
593
client/src/components/__tests__/MetadataTab.test.tsx
Normal file
593
client/src/components/__tests__/MetadataTab.test.tsx
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
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 = {
|
||||
metadata: {},
|
||||
onMetadataChange: jest.fn(),
|
||||
};
|
||||
|
||||
const renderMetadataTab = (props = {}) => {
|
||||
return render(
|
||||
<Tabs defaultValue="metadata">
|
||||
<MetadataTab {...defaultProps} {...props} />
|
||||
</Tabs>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Initial Rendering", () => {
|
||||
it("should render the metadata tab with title and description", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
expect(screen.getByText("Metadata")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Key-value pairs that will be included in all MCP requests",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Add Entry button", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
expect(addButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty state message when no entries exist", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show empty state message when entries exist", () => {
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1" },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Initial Data Handling", () => {
|
||||
it("should initialize with existing metadata", () => {
|
||||
const initialMetadata = {
|
||||
API_KEY: "test-key",
|
||||
VERSION: "1.0.0",
|
||||
};
|
||||
|
||||
renderMetadataTab({ metadata: initialMetadata });
|
||||
|
||||
expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("test-key")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("VERSION")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("1.0.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render multiple entries in correct order", () => {
|
||||
const initialMetadata = {
|
||||
FIRST: "first-value",
|
||||
SECOND: "second-value",
|
||||
THIRD: "third-value",
|
||||
};
|
||||
|
||||
renderMetadataTab({ metadata: initialMetadata });
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
expect(keyInputs).toHaveLength(3);
|
||||
expect(valueInputs).toHaveLength(3);
|
||||
|
||||
// Check that entries are rendered in the order they appear in the object
|
||||
const entries = Object.entries(initialMetadata);
|
||||
entries.forEach(([key, value], index) => {
|
||||
expect(keyInputs[index]).toHaveValue(key);
|
||||
expect(valueInputs[index]).toHaveValue(value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Adding Entries", () => {
|
||||
it("should add a new empty entry when Add Entry button is clicked", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
expect(keyInputs).toHaveLength(1);
|
||||
expect(valueInputs).toHaveLength(1);
|
||||
expect(keyInputs[0]).toHaveValue("");
|
||||
expect(valueInputs[0]).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should add multiple entries when Add Entry button is clicked multiple times", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
expect(keyInputs).toHaveLength(3);
|
||||
expect(valueInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should hide empty state message after adding first entry", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Removing Entries", () => {
|
||||
it("should render remove button for each entry", () => {
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1", key2: "value2" },
|
||||
});
|
||||
|
||||
const removeButtons = screen.getAllByRole("button", { name: "" }); // Trash icon buttons have no text
|
||||
expect(removeButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should remove entry when remove button is clicked", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1", key2: "value2" },
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
const removeButtons = screen.getAllByRole("button", { name: "" });
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({ key2: "value2" });
|
||||
});
|
||||
|
||||
it("should remove correct entry when multiple entries exist", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: {
|
||||
FIRST: "first-value",
|
||||
SECOND: "second-value",
|
||||
THIRD: "third-value",
|
||||
},
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
const removeButtons = screen.getAllByRole("button", { name: "" });
|
||||
fireEvent.click(removeButtons[1]); // Remove second entry
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
FIRST: "first-value",
|
||||
THIRD: "third-value",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show empty state message after removing all entries", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1" },
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
const removeButton = screen.getByRole("button", { name: "" });
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing Entries", () => {
|
||||
it("should update key when key input is changed", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: { oldKey: "value1" },
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
const keyInput = screen.getByDisplayValue("oldKey");
|
||||
fireEvent.change(keyInput, { target: { value: "newKey" } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({ newKey: "value1" });
|
||||
});
|
||||
|
||||
it("should update value when value input is changed", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "oldValue" },
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
const valueInput = screen.getByDisplayValue("oldValue");
|
||||
fireEvent.change(valueInput, { target: { value: "newValue" } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({ key1: "newValue" });
|
||||
});
|
||||
|
||||
it("should handle editing multiple entries independently", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: {
|
||||
key1: "value1",
|
||||
key2: "value2",
|
||||
},
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
// Edit first entry key
|
||||
fireEvent.change(keyInputs[0], { target: { value: "newKey1" } });
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
newKey1: "value1",
|
||||
key2: "value2",
|
||||
});
|
||||
|
||||
// Clear mock to test second edit independently
|
||||
onMetadataChange.mockClear();
|
||||
|
||||
// Edit second entry value
|
||||
fireEvent.change(valueInputs[1], { target: { value: "newValue2" } });
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
newKey1: "value1",
|
||||
key2: "newValue2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText("Key");
|
||||
const valueInput = screen.getByPlaceholderText("Value");
|
||||
|
||||
fireEvent.change(keyInput, { target: { value: " trimmedKey " } });
|
||||
fireEvent.change(valueInput, { target: { value: " trimmedValue " } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
trimmedKey: "trimmedValue",
|
||||
});
|
||||
});
|
||||
|
||||
it("should exclude entries with empty keys or values after trimming", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
// First entry: valid key and value
|
||||
fireEvent.change(keyInputs[0], { target: { value: "validKey" } });
|
||||
fireEvent.change(valueInputs[0], { target: { value: "validValue" } });
|
||||
|
||||
// Second entry: empty key (should be excluded)
|
||||
fireEvent.change(keyInputs[1], { target: { value: "" } });
|
||||
fireEvent.change(valueInputs[1], { target: { value: "someValue" } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
validKey: "validValue",
|
||||
});
|
||||
});
|
||||
|
||||
it("should exclude entries with whitespace-only keys or values", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
// First entry: valid key and value
|
||||
fireEvent.change(keyInputs[0], { target: { value: "validKey" } });
|
||||
fireEvent.change(valueInputs[0], { target: { value: "validValue" } });
|
||||
|
||||
// Second entry: whitespace-only key (should be excluded)
|
||||
fireEvent.change(keyInputs[1], { target: { value: " " } });
|
||||
fireEvent.change(valueInputs[1], { target: { value: "someValue" } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
validKey: "validValue",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mixed valid and invalid entries", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
// First entry: valid
|
||||
fireEvent.change(keyInputs[0], { target: { value: "key1" } });
|
||||
fireEvent.change(valueInputs[0], { target: { value: "value1" } });
|
||||
|
||||
// Second entry: empty key (invalid)
|
||||
fireEvent.change(keyInputs[1], { target: { value: "" } });
|
||||
fireEvent.change(valueInputs[1], { target: { value: "value2" } });
|
||||
|
||||
// Third entry: valid
|
||||
fireEvent.change(keyInputs[2], { target: { value: "key3" } });
|
||||
fireEvent.change(valueInputs[2], { target: { value: "value3" } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
key1: "value1",
|
||||
key3: "value3",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Accessibility", () => {
|
||||
it("should have proper labels for screen readers", () => {
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1" },
|
||||
});
|
||||
|
||||
const keyLabel = screen.getByLabelText("Key", { selector: "input" });
|
||||
const valueLabel = screen.getByLabelText("Value", { selector: "input" });
|
||||
|
||||
expect(keyLabel).toBeInTheDocument();
|
||||
expect(valueLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have unique IDs for each input pair", () => {
|
||||
renderMetadataTab({
|
||||
metadata: {
|
||||
key1: "value1",
|
||||
key2: "value2",
|
||||
},
|
||||
});
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
expect(keyInputs[0]).toHaveAttribute("id", "key-0");
|
||||
expect(keyInputs[1]).toHaveAttribute("id", "key-1");
|
||||
expect(valueInputs[0]).toHaveAttribute("id", "value-0");
|
||||
expect(valueInputs[1]).toHaveAttribute("id", "value-1");
|
||||
});
|
||||
|
||||
it("should have proper placeholder text", () => {
|
||||
renderMetadataTab();
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText("Key");
|
||||
const valueInput = screen.getByPlaceholderText("Value");
|
||||
|
||||
expect(keyInput).toHaveAttribute("placeholder", "Key");
|
||||
expect(valueInput).toHaveAttribute("placeholder", "Value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should flag invalid names that contain unsupported characters", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText("Key");
|
||||
const valueInput = screen.getByPlaceholderText("Value");
|
||||
|
||||
fireEvent.change(keyInput, {
|
||||
target: { value: "key-with-special@chars!" },
|
||||
});
|
||||
fireEvent.change(valueInput, {
|
||||
target: { value: "value with spaces & symbols $%^" },
|
||||
});
|
||||
|
||||
expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
|
||||
expect(onMetadataChange).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it("should reject unicode names that do not start with an alphanumeric character", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText("Key");
|
||||
const valueInput = screen.getByPlaceholderText("Value");
|
||||
|
||||
fireEvent.change(keyInput, { target: { value: "🔑_key" } });
|
||||
fireEvent.change(valueInput, { target: { value: "值_value_🎯" } });
|
||||
|
||||
expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
|
||||
expect(onMetadataChange).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it("should handle very long keys and values", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText("Key");
|
||||
const valueInput = screen.getByPlaceholderText("Value");
|
||||
|
||||
const longKey = "A".repeat(100);
|
||||
const longValue = "B".repeat(500);
|
||||
|
||||
fireEvent.change(keyInput, { target: { value: longKey } });
|
||||
fireEvent.change(valueInput, { target: { value: longValue } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
[longKey]: longValue,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle duplicate keys by keeping the last one", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({ onMetadataChange });
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
// Set same key for both entries
|
||||
fireEvent.change(keyInputs[0], { target: { value: "duplicateKey" } });
|
||||
fireEvent.change(valueInputs[0], { target: { value: "firstValue" } });
|
||||
|
||||
fireEvent.change(keyInputs[1], { target: { value: "duplicateKey" } });
|
||||
fireEvent.change(valueInputs[1], { target: { value: "secondValue" } });
|
||||
|
||||
// The second value should overwrite the first
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||
duplicateKey: "secondValue",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration with Parent Component", () => {
|
||||
it("should not call onMetadataChange when component mounts with existing data", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1" },
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
expect(onMetadataChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onMetadataChange only when user makes changes", () => {
|
||||
const onMetadataChange = jest.fn();
|
||||
renderMetadataTab({
|
||||
metadata: { key1: "value1" },
|
||||
onMetadataChange,
|
||||
});
|
||||
|
||||
// Should not be called on mount
|
||||
expect(onMetadataChange).not.toHaveBeenCalled();
|
||||
|
||||
// Should be called when user changes value
|
||||
const valueInput = screen.getByDisplayValue("value1");
|
||||
fireEvent.change(valueInput, { target: { value: "newValue" } });
|
||||
|
||||
expect(onMetadataChange).toHaveBeenCalledTimes(1);
|
||||
expect(onMetadataChange).toHaveBeenCalledWith({ key1: "newValue" });
|
||||
});
|
||||
|
||||
it("should maintain internal state when props change (component doesn't sync with prop changes)", () => {
|
||||
const { rerender } = renderMetadataTab({
|
||||
metadata: { key1: "value1" },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("value1")).toBeInTheDocument();
|
||||
|
||||
// Rerender with different props - component should maintain its internal state
|
||||
// This is the intended behavior since useState initializer only runs once
|
||||
rerender(
|
||||
<Tabs defaultValue="metadata">
|
||||
<MetadataTab
|
||||
metadata={{ key2: "value2", key3: "value3" }}
|
||||
onMetadataChange={jest.fn()}
|
||||
/>
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
// The component should still show the original values since it maintains internal state
|
||||
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("value1")).toBeInTheDocument();
|
||||
// The new prop values should not be displayed
|
||||
expect(screen.queryByDisplayValue("key2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("value2")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -119,9 +124,13 @@ describe("ToolsTab", () => {
|
|||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
||||
count: 42,
|
||||
});
|
||||
expect(defaultProps.callTool).toHaveBeenCalledWith(
|
||||
mockTools[1].name,
|
||||
{
|
||||
count: 42,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow typing negative numbers", async () => {
|
||||
|
|
@ -140,9 +149,13 @@ describe("ToolsTab", () => {
|
|||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
|
||||
num: -42,
|
||||
});
|
||||
expect(defaultProps.callTool).toHaveBeenCalledWith(
|
||||
mockTools[0].name,
|
||||
{
|
||||
num: -42,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow specifying null value", async () => {
|
||||
|
|
@ -172,9 +185,13 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
|
||||
// Tool should have been called with null value
|
||||
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableField.name, {
|
||||
num: null,
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
toolWithNullableField.name,
|
||||
{
|
||||
num: null,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
|
||||
|
|
@ -211,9 +228,13 @@ describe("ToolsTab", () => {
|
|||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
|
||||
optionalBoolean: null,
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
toolWithNullableBoolean.name,
|
||||
{
|
||||
optionalBoolean: null,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// State 2: Uncheck null checkbox -> should set value to false and enable input
|
||||
await act(async () => {
|
||||
|
|
@ -233,6 +254,7 @@ describe("ToolsTab", () => {
|
|||
{
|
||||
optionalBoolean: false,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// State 3: Check boolean checkbox -> should set value to true
|
||||
|
|
@ -254,6 +276,7 @@ describe("ToolsTab", () => {
|
|||
{
|
||||
optionalBoolean: true,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// State 4: Check null checkbox again -> should set value back to null and disable input
|
||||
|
|
@ -271,6 +294,7 @@ describe("ToolsTab", () => {
|
|||
{
|
||||
optionalBoolean: null,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -697,10 +721,10 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Meta Display", () => {
|
||||
const toolWithMeta = {
|
||||
describe("Metadata Display", () => {
|
||||
const toolWithMetadata = {
|
||||
name: "metaTool",
|
||||
description: "Tool with meta",
|
||||
description: "Tool with metadata",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
|
|
@ -713,10 +737,10 @@ describe("ToolsTab", () => {
|
|||
},
|
||||
} as unknown as Tool;
|
||||
|
||||
it("should display meta section when tool has _meta", () => {
|
||||
it("should display metadata section when tool has _meta", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithMeta],
|
||||
selectedTool: toolWithMeta,
|
||||
tools: [toolWithMetadata],
|
||||
selectedTool: toolWithMetadata,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Meta:")).toBeInTheDocument();
|
||||
|
|
@ -725,10 +749,10 @@ describe("ToolsTab", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle meta expansion", () => {
|
||||
it("should toggle metadata schema expansion", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithMeta],
|
||||
selectedTool: toolWithMeta,
|
||||
tools: [toolWithMetadata],
|
||||
selectedTool: toolWithMetadata,
|
||||
});
|
||||
|
||||
// There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
|
||||
|
|
@ -758,19 +782,88 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("ToolResults Meta", () => {
|
||||
it("should display meta information when present in toolResult", () => {
|
||||
const resultWithMeta = {
|
||||
describe("Metadata submission", () => {
|
||||
it("should send metadata values when provided", async () => {
|
||||
const callToolMock = jest.fn(async () => {});
|
||||
|
||||
renderToolsTab({ selectedTool: mockTools[0], callTool: callToolMock });
|
||||
|
||||
// Add a metadata key/value pair
|
||||
const addPairButton = screen.getByRole("button", { name: /add pair/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(addPairButton);
|
||||
});
|
||||
|
||||
// Fill key and value
|
||||
const keyInputs = screen.getAllByLabelText(/key/i);
|
||||
const valueInputs = screen.getAllByLabelText(/value/i);
|
||||
expect(keyInputs.length).toBeGreaterThan(0);
|
||||
expect(valueInputs.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(keyInputs[0], { target: { value: "requestId" } });
|
||||
fireEvent.change(valueInputs[0], { target: { value: "abc123" } });
|
||||
});
|
||||
|
||||
// Run tool
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
|
||||
expect(callToolMock).toHaveBeenCalledTimes(1);
|
||||
expect(callToolMock).toHaveBeenLastCalledWith(
|
||||
mockTools[0].name,
|
||||
expect.any(Object),
|
||||
{ requestId: "abc123" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
content: [],
|
||||
_meta: { info: "details", version: 2 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0],
|
||||
toolResult: resultWithMeta,
|
||||
toolResult: resultWithMetadata,
|
||||
});
|
||||
|
||||
// Only ToolResults meta should be present since selectedTool has no _meta
|
||||
// Only ToolResults metadata should be present since selectedTool has no _meta
|
||||
expect(screen.getAllByText("Meta:")).toHaveLength(1);
|
||||
expect(screen.getByText(/info/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/version/i)).toBeInTheDocument();
|
||||
|
|
@ -986,6 +1079,7 @@ describe("ToolsTab", () => {
|
|||
message: "test message",
|
||||
count: 5,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -102,16 +102,40 @@ export const clearClientInformationFromSessionStorage = ({
|
|||
sessionStorage.removeItem(key);
|
||||
};
|
||||
|
||||
export const getScopeFromSessionStorage = (
|
||||
serverUrl: string,
|
||||
): string | undefined => {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
||||
const value = sessionStorage.getItem(key);
|
||||
return value || undefined;
|
||||
};
|
||||
|
||||
export const saveScopeToSessionStorage = (
|
||||
serverUrl: string,
|
||||
scope: string | undefined,
|
||||
) => {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
||||
if (scope) {
|
||||
sessionStorage.setItem(key, scope);
|
||||
} else {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearScopeFromSessionStorage = (serverUrl: string) => {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
||||
sessionStorage.removeItem(key);
|
||||
};
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(
|
||||
protected serverUrl: string,
|
||||
scope?: string,
|
||||
) {
|
||||
this.scope = scope;
|
||||
constructor(protected serverUrl: string) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
scope: string | undefined;
|
||||
|
||||
get scope(): string | undefined {
|
||||
return getScopeFromSessionStorage(this.serverUrl);
|
||||
}
|
||||
|
||||
get redirectUrl() {
|
||||
return window.location.origin + "/oauth/callback";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const SESSION_KEYS = {
|
|||
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
|
||||
SERVER_METADATA: "mcp_server_metadata",
|
||||
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
|
||||
SCOPE: "mcp_scope",
|
||||
} as const;
|
||||
|
||||
// Generate server-specific session storage keys
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useConnection } from "../useConnection";
|
||||
import { z } from "zod";
|
||||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
ClientRequest,
|
||||
JSONRPCMessage,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { DEFAULT_INSPECTOR_CONFIG, CLIENT_IDENTITY } from "../../constants";
|
||||
import {
|
||||
SSEClientTransportOptions,
|
||||
|
|
@ -42,10 +45,12 @@ const mockSSETransport: {
|
|||
start: jest.Mock;
|
||||
url: URL | undefined;
|
||||
options: SSEClientTransportOptions | undefined;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
} = {
|
||||
start: jest.fn(),
|
||||
url: undefined,
|
||||
options: undefined,
|
||||
onmessage: undefined,
|
||||
};
|
||||
|
||||
const mockStreamableHTTPTransport: {
|
||||
|
|
@ -112,6 +117,8 @@ jest.mock("../../auth", () => ({
|
|||
})),
|
||||
clearClientInformationFromSessionStorage: jest.fn(),
|
||||
saveClientInformationToSessionStorage: jest.fn(),
|
||||
saveScopeToSessionStorage: jest.fn(),
|
||||
clearScopeFromSessionStorage: jest.fn(),
|
||||
discoverScopes: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -480,6 +487,129 @@ describe("useConnection", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Ref Resolution", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("resolves $ref references in requestedSchema properties before validation", async () => {
|
||||
const mockProtocolOnMessage = jest.fn();
|
||||
|
||||
mockSSETransport.onmessage = mockProtocolOnMessage;
|
||||
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const mockRequestWithRef: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
title: "A Connectable Node",
|
||||
},
|
||||
target: {
|
||||
$ref: "#/properties/source",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
mockSSETransport.onmessage!(mockRequestWithRef);
|
||||
});
|
||||
|
||||
expect(mockProtocolOnMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const message = mockProtocolOnMessage.mock.calls[0][0];
|
||||
expect(message.params.requestedSchema.properties.target).toEqual({
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
title: "A Connectable Node",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves $ref references to $defs in requestedSchema", async () => {
|
||||
const mockProtocolOnMessage = jest.fn();
|
||||
|
||||
mockSSETransport.onmessage = mockProtocolOnMessage;
|
||||
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const mockRequestWithDefs: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
$ref: "#/$defs/UserInput",
|
||||
},
|
||||
},
|
||||
$defs: {
|
||||
UserInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
title: "Age",
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
mockSSETransport.onmessage!(mockRequestWithDefs);
|
||||
});
|
||||
|
||||
expect(mockProtocolOnMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const message = mockProtocolOnMessage.mock.calls[0][0];
|
||||
// The $ref should be resolved to the actual UserInput definition
|
||||
expect(message.params.requestedSchema.properties.user).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
title: "Age",
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Port Handling", () => {
|
||||
const SSEClientTransport = jest.requireMock(
|
||||
"@modelcontextprotocol/sdk/client/sse.js",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import {
|
|||
clearClientInformationFromSessionStorage,
|
||||
InspectorOAuthClientProvider,
|
||||
saveClientInformationToSessionStorage,
|
||||
saveScopeToSessionStorage,
|
||||
clearScopeFromSessionStorage,
|
||||
discoverScopes,
|
||||
} from "../auth";
|
||||
import {
|
||||
|
|
@ -58,6 +60,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
|||
import { InspectorConfig } from "../configurationTypes";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { CustomHeaders } from "../types/customHeaders";
|
||||
import { resolveRefsInMessage } from "@/utils/schemaUtils";
|
||||
|
||||
interface UseConnectionOptions {
|
||||
transportType: "stdio" | "sse" | "streamable-http";
|
||||
|
|
@ -82,6 +85,7 @@ interface UseConnectionOptions {
|
|||
getRoots?: () => any[];
|
||||
defaultLoggingLevel?: LoggingLevel;
|
||||
serverImplementation?: Implementation;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useConnection({
|
||||
|
|
@ -101,6 +105,7 @@ export function useConnection({
|
|||
onElicitationRequest,
|
||||
getRoots,
|
||||
defaultLoggingLevel,
|
||||
metadata = {},
|
||||
}: UseConnectionOptions) {
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("disconnected");
|
||||
|
|
@ -146,6 +151,15 @@ export function useConnection({
|
|||
});
|
||||
}, [oauthClientId, oauthClientSecret, sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthScope) {
|
||||
clearScopeFromSessionStorage(sseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
saveScopeToSessionStorage(sseUrl, oauthScope);
|
||||
}, [oauthScope, sseUrl]);
|
||||
|
||||
const pushHistory = (request: object, response?: object) => {
|
||||
setRequestHistory((prev) => [
|
||||
...prev,
|
||||
|
|
@ -167,6 +181,20 @@ export function useConnection({
|
|||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Add metadata to the request if available, but skip for tool calls
|
||||
// as they handle metadata merging separately
|
||||
const shouldAddGeneralMetadata =
|
||||
request.method !== "tools/call" && Object.keys(metadata).length > 0;
|
||||
const requestWithMetadata = shouldAddGeneralMetadata
|
||||
? {
|
||||
...request,
|
||||
params: {
|
||||
...request.params,
|
||||
_meta: metadata,
|
||||
},
|
||||
}
|
||||
: request;
|
||||
|
||||
// prepare MCP Client request options
|
||||
const mcpRequestOptions: RequestOptions = {
|
||||
signal: options?.signal ?? abortController.signal,
|
||||
|
|
@ -195,13 +223,17 @@ export function useConnection({
|
|||
|
||||
let response;
|
||||
try {
|
||||
response = await mcpClient.request(request, schema, mcpRequestOptions);
|
||||
response = await mcpClient.request(
|
||||
requestWithMetadata,
|
||||
schema,
|
||||
mcpRequestOptions,
|
||||
);
|
||||
|
||||
pushHistory(request, response);
|
||||
pushHistory(requestWithMetadata, response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
pushHistory(request, { error: errorMessage });
|
||||
pushHistory(requestWithMetadata, { error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -350,10 +382,9 @@ export function useConnection({
|
|||
}
|
||||
scope = await discoverScopes(sseUrl, resourceMetadata);
|
||||
}
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(
|
||||
sseUrl,
|
||||
scope,
|
||||
);
|
||||
|
||||
saveScopeToSessionStorage(sseUrl, scope);
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
const result = await auth(serverAuthProvider, {
|
||||
serverUrl: sseUrl,
|
||||
|
|
@ -685,6 +716,14 @@ export function useConnection({
|
|||
|
||||
await client.connect(transport as Transport);
|
||||
|
||||
const protocolOnMessage = transport.onmessage;
|
||||
if (protocolOnMessage) {
|
||||
transport.onmessage = (message) => {
|
||||
const resolvedMessage = resolveRefsInMessage(message);
|
||||
protocolOnMessage(resolvedMessage);
|
||||
};
|
||||
}
|
||||
|
||||
setClientTransport(transport);
|
||||
|
||||
capabilities = client.getServerCapabilities();
|
||||
|
|
|
|||
|
|
@ -80,13 +80,16 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
const metadata = context.state.oauthMetadata!;
|
||||
const clientMetadata = context.provider.clientMetadata;
|
||||
|
||||
// Prefer scopes from resource metadata if available
|
||||
const scopesSupported =
|
||||
context.state.resourceMetadata?.scopes_supported ||
|
||||
metadata.scopes_supported;
|
||||
// Add all supported scopes to client registration
|
||||
if (scopesSupported) {
|
||||
clientMetadata.scope = scopesSupported.join(" ");
|
||||
// Priority: user-provided scope > discovered scopes
|
||||
if (!context.provider.scope || context.provider.scope.trim() === "") {
|
||||
// Prefer scopes from resource metadata if available
|
||||
const scopesSupported =
|
||||
context.state.resourceMetadata?.scopes_supported ||
|
||||
metadata.scopes_supported;
|
||||
// Add all supported scopes to client registration
|
||||
if (scopesSupported) {
|
||||
clientMetadata.scope = scopesSupported.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// Try Static client first, with DCR as fallback
|
||||
|
|
@ -113,10 +116,14 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
const metadata = context.state.oauthMetadata!;
|
||||
const clientInformation = context.state.oauthClientInfo!;
|
||||
|
||||
const scope = await discoverScopes(
|
||||
context.serverUrl,
|
||||
context.state.resourceMetadata ?? undefined,
|
||||
);
|
||||
// Priority: user-provided scope > discovered scopes
|
||||
let scope = context.provider.scope;
|
||||
if (!scope || scope.trim() === "") {
|
||||
scope = await discoverScopes(
|
||||
context.serverUrl,
|
||||
context.state.resourceMetadata ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||
context.serverUrl,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export type JsonSchemaType = {
|
|||
const?: JsonValue;
|
||||
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
|
||||
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
|
@ -84,8 +85,9 @@ export function tryParseJson(str: string): {
|
|||
success: boolean;
|
||||
data: JsonValue;
|
||||
} {
|
||||
const trimmed = str.trim();
|
||||
const trimmed = str?.trim();
|
||||
if (
|
||||
trimmed &&
|
||||
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
) {
|
||||
|
|
|
|||
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);
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
||||
import Ajv from "ajv";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
|
|
@ -145,6 +146,50 @@ export function isPropertyRequired(
|
|||
return schema.required?.includes(propertyName) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $ref references in JSON schema
|
||||
* @param schema The schema that may contain $ref
|
||||
* @param rootSchema The root schema to resolve references against
|
||||
* @returns The resolved schema without $ref
|
||||
*/
|
||||
export function resolveRef(
|
||||
schema: JsonSchemaType,
|
||||
rootSchema: JsonSchemaType,
|
||||
): JsonSchemaType {
|
||||
if (!("$ref" in schema) || !schema.$ref) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const ref = schema.$ref;
|
||||
|
||||
// Handle simple #/properties/name references
|
||||
if (ref.startsWith("#/")) {
|
||||
const path = ref.substring(2).split("/");
|
||||
let current: unknown = rootSchema;
|
||||
|
||||
for (const segment of path) {
|
||||
if (
|
||||
current &&
|
||||
typeof current === "object" &&
|
||||
current !== null &&
|
||||
segment in current
|
||||
) {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
// If reference cannot be resolved, return the original schema
|
||||
console.warn(`Could not resolve $ref: ${ref}`);
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
|
||||
return current as JsonSchemaType;
|
||||
}
|
||||
|
||||
// For other types of references, return the original schema
|
||||
console.warn(`Unsupported $ref format: ${ref}`);
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes union types (like string|null from FastMCP) to simple types for form rendering
|
||||
* @param schema The JSON schema to normalize
|
||||
|
|
@ -191,6 +236,16 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
|||
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly array and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "array") &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||
) {
|
||||
return { ...schema, type: "array", anyOf: undefined, nullable: true };
|
||||
}
|
||||
|
||||
// Handle array type with exactly string and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
|
|
@ -245,3 +300,41 @@ export function formatFieldLabel(key: string): string {
|
|||
.replace(/_/g, " ") // Replace underscores with spaces
|
||||
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `$ref` references in a JSON-RPC "elicitation/create" message's `requestedSchema` field
|
||||
* @param message The JSON-RPC message that may contain $ref references
|
||||
* @returns A new message with resolved $ref references, or the original message if no resolution is needed
|
||||
*/
|
||||
export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage {
|
||||
if (!isJSONRPCRequest(message) || !message.params?.requestedSchema) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const requestedSchema = message.params.requestedSchema as JsonSchemaType;
|
||||
|
||||
if (!requestedSchema?.properties) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const resolvedMessage = {
|
||||
...message,
|
||||
params: {
|
||||
...message.params,
|
||||
requestedSchema: {
|
||||
...requestedSchema,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(requestedSchema.properties).map(
|
||||
([key, propSchema]) => {
|
||||
const resolved = resolveRef(propSchema, requestedSchema);
|
||||
const normalized = normalizeUnionType(resolved);
|
||||
return [key, normalized];
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return resolvedMessage;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue