Merge branch 'main' into allow-multiple-client-connections
This commit is contained in:
commit
d0ba4185ee
12 changed files with 972 additions and 115 deletions
|
|
@ -25,6 +25,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"ajv": "^6.12.6",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||||
import { AuthDebuggerState } from "./lib/auth-types";
|
import { AuthDebuggerState } from "./lib/auth-types";
|
||||||
|
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||||
import React, {
|
import React, {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -473,6 +474,8 @@ const App = () => {
|
||||||
);
|
);
|
||||||
setTools(response.tools);
|
setTools(response.tools);
|
||||||
setNextToolCursor(response.nextCursor);
|
setNextToolCursor(response.nextCursor);
|
||||||
|
// Cache output schemas for validation
|
||||||
|
cacheToolOutputSchemas(response.tools);
|
||||||
};
|
};
|
||||||
|
|
||||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||||
|
|
@ -759,6 +762,8 @@ const App = () => {
|
||||||
clearTools={() => {
|
clearTools={() => {
|
||||||
setTools([]);
|
setTools([]);
|
||||||
setNextToolCursor(undefined);
|
setNextToolCursor(undefined);
|
||||||
|
// Clear cached output schemas
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
}}
|
}}
|
||||||
callTool={async (name, params) => {
|
callTool={async (name, params) => {
|
||||||
clearError("tools");
|
clearError("tools");
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
||||||
import {
|
|
||||||
auth,
|
|
||||||
discoverOAuthMetadata,
|
|
||||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
||||||
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { AuthDebuggerState } from "../lib/auth-types";
|
import { AuthDebuggerState } from "../lib/auth-types";
|
||||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||||
|
|
@ -124,22 +119,43 @@ const AuthDebugger = ({
|
||||||
|
|
||||||
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
|
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
|
||||||
try {
|
try {
|
||||||
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
// Step through the OAuth flow using the state machine instead of the auth() function
|
||||||
serverUrl,
|
let currentState: AuthDebuggerState = {
|
||||||
);
|
...authState,
|
||||||
// First discover OAuth metadata separately so we can save it
|
oauthStep: "metadata_discovery",
|
||||||
const metadata = await discoverOAuthMetadata(serverUrl);
|
authorizationUrl: null,
|
||||||
if (!metadata) {
|
latestError: null,
|
||||||
throw new Error("Failed to discover OAuth metadata");
|
};
|
||||||
}
|
|
||||||
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
|
|
||||||
serverAuthProvider.saveServerMetadata(parsedMetadata);
|
|
||||||
|
|
||||||
await auth(serverAuthProvider, { serverUrl: serverUrl });
|
const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
|
||||||
|
// Update our temporary state during the process
|
||||||
|
currentState = { ...currentState, ...updates };
|
||||||
|
// But don't call updateAuthState yet
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manually step through each stage of the OAuth flow
|
||||||
|
while (currentState.oauthStep !== "complete") {
|
||||||
|
await oauthMachine.executeStep(currentState);
|
||||||
|
// In quick mode, we'll just redirect to the authorization URL
|
||||||
|
if (
|
||||||
|
currentState.oauthStep === "authorization_code" &&
|
||||||
|
currentState.authorizationUrl
|
||||||
|
) {
|
||||||
|
// Open the authorization URL automatically
|
||||||
|
window.location.href = currentState.authorizationUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the flow completes or reaches a user-input step, update the app state
|
||||||
updateAuthState({
|
updateAuthState({
|
||||||
|
...currentState,
|
||||||
statusMessage: {
|
statusMessage: {
|
||||||
type: "info",
|
type: "info",
|
||||||
message: "Starting OAuth authentication process...",
|
message:
|
||||||
|
currentState.oauthStep === "complete"
|
||||||
|
? "Authentication completed successfully"
|
||||||
|
: "Please complete authentication in the opened window and enter the code",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -153,7 +169,7 @@ const AuthDebugger = ({
|
||||||
} finally {
|
} finally {
|
||||||
updateAuthState({ isInitiatingAuth: false });
|
updateAuthState({ isInitiatingAuth: false });
|
||||||
}
|
}
|
||||||
}, [serverUrl, updateAuthState]);
|
}, [serverUrl, updateAuthState, authState]);
|
||||||
|
|
||||||
const handleClearOAuth = useCallback(() => {
|
const handleClearOAuth = useCallback(() => {
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
|
||||||
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
|
||||||
interface OAuthStepProps {
|
interface OAuthStepProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -54,24 +56,57 @@ interface OAuthFlowProgressProps {
|
||||||
proceedToNextStep: () => Promise<void>;
|
proceedToNextStep: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const steps: Array<OAuthStep> = [
|
||||||
|
"metadata_discovery",
|
||||||
|
"client_registration",
|
||||||
|
"authorization_redirect",
|
||||||
|
"authorization_code",
|
||||||
|
"token_request",
|
||||||
|
"complete",
|
||||||
|
];
|
||||||
|
|
||||||
export const OAuthFlowProgress = ({
|
export const OAuthFlowProgress = ({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
authState,
|
authState,
|
||||||
updateAuthState,
|
updateAuthState,
|
||||||
proceedToNextStep,
|
proceedToNextStep,
|
||||||
}: OAuthFlowProgressProps) => {
|
}: OAuthFlowProgressProps) => {
|
||||||
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
|
const provider = useMemo(
|
||||||
|
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
||||||
|
[serverUrl],
|
||||||
|
);
|
||||||
|
const [clientInfo, setClientInfo] = useState<OAuthClientInformation | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const steps: Array<OAuthStep> = [
|
|
||||||
"metadata_discovery",
|
|
||||||
"client_registration",
|
|
||||||
"authorization_redirect",
|
|
||||||
"authorization_code",
|
|
||||||
"token_request",
|
|
||||||
"complete",
|
|
||||||
];
|
|
||||||
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
|
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchClientInfo = async () => {
|
||||||
|
if (authState.oauthClientInfo) {
|
||||||
|
setClientInfo(authState.oauthClientInfo);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const info = await provider.clientInformation();
|
||||||
|
if (info) {
|
||||||
|
setClientInfo(info);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch client information:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentStepIdx > steps.indexOf("client_registration")) {
|
||||||
|
fetchClientInfo();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
provider,
|
||||||
|
authState.oauthStep,
|
||||||
|
authState.oauthClientInfo,
|
||||||
|
currentStepIdx,
|
||||||
|
]);
|
||||||
|
|
||||||
// Helper to get step props
|
// Helper to get step props
|
||||||
const getStepProps = (stepName: OAuthStep) => ({
|
const getStepProps = (stepName: OAuthStep) => ({
|
||||||
isComplete:
|
isComplete:
|
||||||
|
|
@ -110,13 +145,13 @@ export const OAuthFlowProgress = ({
|
||||||
label="Client Registration"
|
label="Client Registration"
|
||||||
{...getStepProps("client_registration")}
|
{...getStepProps("client_registration")}
|
||||||
>
|
>
|
||||||
{authState.oauthClientInfo && (
|
{clientInfo && (
|
||||||
<details className="text-xs mt-2">
|
<details className="text-xs mt-2">
|
||||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||||
Registered Client Information
|
Registered Client Information
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||||
{JSON.stringify(authState.oauthClientInfo, null, 2)}
|
{JSON.stringify(clientInfo, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
221
client/src/components/ToolResults.tsx
Normal file
221
client/src/components/ToolResults.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
import {
|
||||||
|
CallToolResultSchema,
|
||||||
|
CompatibilityCallToolResult,
|
||||||
|
Tool,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
|
||||||
|
|
||||||
|
interface ToolResultsProps {
|
||||||
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
|
selectedTool: Tool | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkContentCompatibility = (
|
||||||
|
structuredContent: unknown,
|
||||||
|
unstructuredContent: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>,
|
||||||
|
): { isCompatible: boolean; message: string } => {
|
||||||
|
if (
|
||||||
|
unstructuredContent.length !== 1 ||
|
||||||
|
unstructuredContent[0].type !== "text"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isCompatible: false,
|
||||||
|
message: "Unstructured content is not a single text block",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = unstructuredContent[0].text;
|
||||||
|
if (!textContent) {
|
||||||
|
return {
|
||||||
|
isCompatible: false,
|
||||||
|
message: "Text content is empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedContent = JSON.parse(textContent);
|
||||||
|
const isEqual =
|
||||||
|
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
|
||||||
|
|
||||||
|
if (isEqual) {
|
||||||
|
return {
|
||||||
|
isCompatible: true,
|
||||||
|
message: "Unstructured content matches structured content",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
isCompatible: false,
|
||||||
|
message: "Parsed JSON does not match structured content",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
isCompatible: false,
|
||||||
|
message: "Unstructured content is not valid JSON",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
|
||||||
|
if (!toolResult) return null;
|
||||||
|
|
||||||
|
if ("content" in toolResult) {
|
||||||
|
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||||
|
if (!parsedResult.success) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||||
|
<JsonView data={toolResult} />
|
||||||
|
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||||
|
{parsedResult.error.errors.map((error, idx) => (
|
||||||
|
<JsonView data={error} key={idx} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const structuredResult = parsedResult.data;
|
||||||
|
const isError = structuredResult.isError ?? false;
|
||||||
|
|
||||||
|
let validationResult = null;
|
||||||
|
const toolHasOutputSchema =
|
||||||
|
selectedTool && hasOutputSchema(selectedTool.name);
|
||||||
|
|
||||||
|
if (toolHasOutputSchema) {
|
||||||
|
if (!structuredResult.structuredContent && !isError) {
|
||||||
|
validationResult = {
|
||||||
|
isValid: false,
|
||||||
|
error:
|
||||||
|
"Tool has an output schema but did not return structured content",
|
||||||
|
};
|
||||||
|
} else if (structuredResult.structuredContent) {
|
||||||
|
validationResult = validateToolOutput(
|
||||||
|
selectedTool.name,
|
||||||
|
structuredResult.structuredContent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let compatibilityResult = null;
|
||||||
|
if (
|
||||||
|
structuredResult.structuredContent &&
|
||||||
|
structuredResult.content.length > 0 &&
|
||||||
|
selectedTool &&
|
||||||
|
hasOutputSchema(selectedTool.name)
|
||||||
|
) {
|
||||||
|
compatibilityResult = checkContentCompatibility(
|
||||||
|
structuredResult.structuredContent,
|
||||||
|
structuredResult.content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">
|
||||||
|
Tool Result:{" "}
|
||||||
|
{isError ? (
|
||||||
|
<span className="text-red-600 font-semibold">Error</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600 font-semibold">Success</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
{structuredResult.structuredContent && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5 className="font-semibold mb-2 text-sm">Structured Content:</h5>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||||
|
<JsonView data={structuredResult.structuredContent} />
|
||||||
|
{validationResult && (
|
||||||
|
<div
|
||||||
|
className={`mt-2 p-2 rounded text-sm ${
|
||||||
|
validationResult.isValid
|
||||||
|
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
||||||
|
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{validationResult.isValid ? (
|
||||||
|
"✓ Valid according to output schema"
|
||||||
|
) : (
|
||||||
|
<>✗ Validation Error: {validationResult.error}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!structuredResult.structuredContent &&
|
||||||
|
validationResult &&
|
||||||
|
!validationResult.isValid && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 p-2 rounded text-sm">
|
||||||
|
✗ Validation Error: {validationResult.error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{structuredResult.content.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{structuredResult.structuredContent && (
|
||||||
|
<>
|
||||||
|
<h5 className="font-semibold mb-2 text-sm">
|
||||||
|
Unstructured Content:
|
||||||
|
</h5>
|
||||||
|
{compatibilityResult && (
|
||||||
|
<div
|
||||||
|
className={`mb-2 p-2 rounded text-sm ${
|
||||||
|
compatibilityResult.isCompatible
|
||||||
|
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||||
|
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
|
||||||
|
{compatibilityResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{structuredResult.content.map((item, index) => (
|
||||||
|
<div key={index} className="mb-2">
|
||||||
|
{item.type === "text" && (
|
||||||
|
<JsonView data={item.text} isError={isError} />
|
||||||
|
)}
|
||||||
|
{item.type === "image" && (
|
||||||
|
<img
|
||||||
|
src={`data:${item.mimeType};base64,${item.data}`}
|
||||||
|
alt="Tool result image"
|
||||||
|
className="max-w-full h-auto"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.type === "resource" &&
|
||||||
|
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<p>Your browser does not support audio playback</p>
|
||||||
|
</audio>
|
||||||
|
) : (
|
||||||
|
<JsonView data={item.resource} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if ("toolResult" in toolResult) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||||
|
<JsonView data={toolResult.toolResult} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolResults;
|
||||||
|
|
@ -9,15 +9,15 @@ import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Loader2, Send } from "lucide-react";
|
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import JsonView from "./JsonView";
|
import JsonView from "./JsonView";
|
||||||
|
import ToolResults from "./ToolResults";
|
||||||
|
|
||||||
const ToolsTab = ({
|
const ToolsTab = ({
|
||||||
tools,
|
tools,
|
||||||
|
|
@ -41,6 +41,7 @@ const ToolsTab = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||||
|
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = Object.entries(
|
const params = Object.entries(
|
||||||
|
|
@ -52,75 +53,6 @@ const ToolsTab = ({
|
||||||
setParams(Object.fromEntries(params));
|
setParams(Object.fromEntries(params));
|
||||||
}, [selectedTool]);
|
}, [selectedTool]);
|
||||||
|
|
||||||
const renderToolResult = () => {
|
|
||||||
if (!toolResult) return null;
|
|
||||||
|
|
||||||
if ("content" in toolResult) {
|
|
||||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
|
||||||
if (!parsedResult.success) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
|
||||||
<JsonView data={toolResult} />
|
|
||||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
|
||||||
{parsedResult.error.errors.map((error, idx) => (
|
|
||||||
<JsonView data={error} key={idx} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const structuredResult = parsedResult.data;
|
|
||||||
const isError = structuredResult.isError ?? false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h4 className="font-semibold mb-2">
|
|
||||||
Tool Result:{" "}
|
|
||||||
{isError ? (
|
|
||||||
<span className="text-red-600 font-semibold">Error</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-green-600 font-semibold">Success</span>
|
|
||||||
)}
|
|
||||||
</h4>
|
|
||||||
{structuredResult.content.map((item, index) => (
|
|
||||||
<div key={index} className="mb-2">
|
|
||||||
{item.type === "text" && (
|
|
||||||
<JsonView data={item.text} isError={isError} />
|
|
||||||
)}
|
|
||||||
{item.type === "image" && (
|
|
||||||
<img
|
|
||||||
src={`data:${item.mimeType};base64,${item.data}`}
|
|
||||||
alt="Tool result image"
|
|
||||||
className="max-w-full h-auto"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.type === "resource" &&
|
|
||||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
|
||||||
<audio
|
|
||||||
controls
|
|
||||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<p>Your browser does not support audio playback</p>
|
|
||||||
</audio>
|
|
||||||
) : (
|
|
||||||
<JsonView data={item.resource} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if ("toolResult" in toolResult) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
|
||||||
|
|
||||||
<JsonView data={toolResult.toolResult} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="tools">
|
<TabsContent value="tools">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -262,6 +194,42 @@ const ToolsTab = ({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
{selectedTool.outputSchema && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-sm font-semibold">Output Schema:</h4>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setIsOutputSchemaExpanded(!isOutputSchemaExpanded)
|
||||||
|
}
|
||||||
|
className="h-6 px-2"
|
||||||
|
>
|
||||||
|
{isOutputSchemaExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="h-3 w-3 mr-1" />
|
||||||
|
Collapse
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3 mr-1" />
|
||||||
|
Expand
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`transition-all ${
|
||||||
|
isOutputSchemaExpanded
|
||||||
|
? ""
|
||||||
|
: "max-h-[8rem] overflow-y-auto"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<JsonView data={selectedTool.outputSchema} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -285,7 +253,10 @@ const ToolsTab = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{toolResult && renderToolResult()}
|
<ToolResults
|
||||||
|
toolResult={toolResult}
|
||||||
|
selectedTool={selectedTool}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|
|
||||||
|
|
@ -207,16 +207,10 @@ describe("AuthDebugger", () => {
|
||||||
"https://example.com",
|
"https://example.com",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then should call auth with the server provider
|
|
||||||
expect(mockAuth).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Check that updateAuthState was called with the right info message
|
// Check that updateAuthState was called with the right info message
|
||||||
expect(updateAuthState).toHaveBeenCalledWith(
|
expect(updateAuthState).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
statusMessage: {
|
oauthStep: "authorization_code",
|
||||||
type: "info",
|
|
||||||
message: "Starting OAuth authentication process...",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||||
import ToolsTab from "../ToolsTab";
|
import ToolsTab from "../ToolsTab";
|
||||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
|
||||||
|
|
||||||
describe("ToolsTab", () => {
|
describe("ToolsTab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear the output schema cache before each test
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
|
});
|
||||||
|
|
||||||
const mockTools: Tool[] = [
|
const mockTools: Tool[] = [
|
||||||
{
|
{
|
||||||
name: "tool1",
|
name: "tool1",
|
||||||
|
|
@ -141,4 +147,217 @@ describe("ToolsTab", () => {
|
||||||
|
|
||||||
expect(submitButton.getAttribute("disabled")).toBeNull();
|
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Output Schema Display", () => {
|
||||||
|
const toolWithOutputSchema: Tool = {
|
||||||
|
name: "weatherTool",
|
||||||
|
description: "Get weather",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
city: { type: "string" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
temperature: { type: "number" as const },
|
||||||
|
humidity: { type: "number" as const },
|
||||||
|
},
|
||||||
|
required: ["temperature", "humidity"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should display output schema when tool has one", () => {
|
||||||
|
renderToolsTab({
|
||||||
|
tools: [toolWithOutputSchema],
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Output Schema:")).toBeInTheDocument();
|
||||||
|
// Check for expand/collapse button
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /expand/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not display output schema section when tool doesn't have one", () => {
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[0], // Tool without outputSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle output schema expansion", () => {
|
||||||
|
renderToolsTab({
|
||||||
|
tools: [toolWithOutputSchema],
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /expand/i });
|
||||||
|
|
||||||
|
// Click to expand
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /collapse/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click to collapse
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /expand/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Structured Output Results", () => {
|
||||||
|
const toolWithOutputSchema: Tool = {
|
||||||
|
name: "weatherTool",
|
||||||
|
description: "Get weather",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
temperature: { type: "number" as const },
|
||||||
|
},
|
||||||
|
required: ["temperature"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should display structured content when present", () => {
|
||||||
|
// Cache the tool's output schema so hasOutputSchema returns true
|
||||||
|
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||||
|
|
||||||
|
const structuredResult = {
|
||||||
|
content: [],
|
||||||
|
structuredContent: {
|
||||||
|
temperature: 25,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
toolResult: structuredResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Valid according to output schema/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show validation error for invalid structured content", () => {
|
||||||
|
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||||
|
|
||||||
|
const invalidResult = {
|
||||||
|
content: [],
|
||||||
|
structuredContent: {
|
||||||
|
temperature: "25", // String instead of number
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
toolResult: invalidResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error when tool with output schema doesn't return structured content", () => {
|
||||||
|
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||||
|
|
||||||
|
const resultWithoutStructured = {
|
||||||
|
content: [{ type: "text", text: "some result" }],
|
||||||
|
// No structuredContent
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
toolResult: resultWithoutStructured,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Tool has an output schema but did not return structured content/,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show unstructured content title when both structured and unstructured exist", () => {
|
||||||
|
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||||
|
|
||||||
|
const resultWithBoth = {
|
||||||
|
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||||
|
structuredContent: { temperature: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
toolResult: resultWithBoth,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show unstructured content title when only unstructured exists", () => {
|
||||||
|
const resultWithUnstructuredOnly = {
|
||||||
|
content: [{ type: "text", text: "some result" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[0], // Tool without output schema
|
||||||
|
toolResult: resultWithUnstructuredOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Unstructured Content:"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show compatibility check when tool has output schema", () => {
|
||||||
|
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||||
|
|
||||||
|
const compatibleResult = {
|
||||||
|
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||||
|
structuredContent: { temperature: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: toolWithOutputSchema,
|
||||||
|
toolResult: compatibleResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show compatibility result
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show compatibility check when tool has no output schema", () => {
|
||||||
|
const resultWithBoth = {
|
||||||
|
content: [{ type: "text", text: '{"data": "value"}' }],
|
||||||
|
structuredContent: { different: "data" },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[0], // Tool without output schema
|
||||||
|
toolResult: resultWithBoth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not show any compatibility messages
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||||
SSEClientTransport: jest.fn(),
|
SSEClientTransport: jest.fn((url) => ({
|
||||||
|
toString: () => url,
|
||||||
|
})),
|
||||||
SseError: jest.fn(),
|
SseError: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
|
StreamableHTTPClientTransport: jest.fn((url) => ({
|
||||||
|
toString: () => url,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
||||||
}));
|
}));
|
||||||
|
|
@ -163,4 +171,92 @@ describe("useConnection", () => {
|
||||||
result.current.makeRequest(mockRequest, mockSchema),
|
result.current.makeRequest(mockRequest, mockSchema),
|
||||||
).rejects.toThrow("MCP client not connected");
|
).rejects.toThrow("MCP client not connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("URL Port Handling", () => {
|
||||||
|
const SSEClientTransport = jest.requireMock(
|
||||||
|
"@modelcontextprotocol/sdk/client/sse.js",
|
||||||
|
).SSEClientTransport;
|
||||||
|
const StreamableHTTPClientTransport = jest.requireMock(
|
||||||
|
"@modelcontextprotocol/sdk/client/streamableHttp.js",
|
||||||
|
).StreamableHTTPClientTransport;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves HTTPS port number when connecting", async () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
sseUrl: "https://example.com:8443/api",
|
||||||
|
transportType: "sse" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(props));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = SSEClientTransport.mock.calls[0][0];
|
||||||
|
expect(call.toString()).toContain(
|
||||||
|
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves HTTP port number when connecting", async () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
sseUrl: "http://localhost:3000/api",
|
||||||
|
transportType: "sse" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(props));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = SSEClientTransport.mock.calls[0][0];
|
||||||
|
expect(call.toString()).toContain(
|
||||||
|
"url=http%3A%2F%2Flocalhost%3A3000%2Fapi",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses default port for HTTPS when not specified", async () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
sseUrl: "https://example.com/api",
|
||||||
|
transportType: "sse" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(props));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = SSEClientTransport.mock.calls[0][0];
|
||||||
|
expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi");
|
||||||
|
expect(call.toString()).not.toContain("%3A443");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves port number in streamable-http transport", async () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
sseUrl: "https://example.com:8443/api",
|
||||||
|
transportType: "streamable-http" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(props));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = StreamableHTTPClientTransport.mock.calls[0][0];
|
||||||
|
expect(call.toString()).toContain(
|
||||||
|
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
import {
|
||||||
|
generateDefaultValue,
|
||||||
|
formatFieldLabel,
|
||||||
|
cacheToolOutputSchemas,
|
||||||
|
getToolOutputValidator,
|
||||||
|
validateToolOutput,
|
||||||
|
hasOutputSchema,
|
||||||
|
} from "../schemaUtils";
|
||||||
import type { JsonSchemaType } from "../jsonUtils";
|
import type { JsonSchemaType } from "../jsonUtils";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
describe("generateDefaultValue", () => {
|
describe("generateDefaultValue", () => {
|
||||||
test("generates default string", () => {
|
test("generates default string", () => {
|
||||||
|
|
@ -137,3 +145,220 @@ describe("formatFieldLabel", () => {
|
||||||
expect(formatFieldLabel("")).toBe("");
|
expect(formatFieldLabel("")).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Output Schema Validation", () => {
|
||||||
|
const mockTools: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "weatherTool",
|
||||||
|
description: "Get weather information",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
city: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
temperature: { type: "number" },
|
||||||
|
humidity: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["temperature", "humidity"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "noOutputSchema",
|
||||||
|
description: "Tool without output schema",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complexOutputSchema",
|
||||||
|
description: "Tool with complex output schema",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
age: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear cache before each test
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cacheToolOutputSchemas", () => {
|
||||||
|
test("caches validators for tools with output schemas", () => {
|
||||||
|
cacheToolOutputSchemas(mockTools);
|
||||||
|
|
||||||
|
expect(hasOutputSchema("weatherTool")).toBe(true);
|
||||||
|
expect(hasOutputSchema("complexOutputSchema")).toBe(true);
|
||||||
|
expect(hasOutputSchema("noOutputSchema")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears existing cache when called", () => {
|
||||||
|
cacheToolOutputSchemas(mockTools);
|
||||||
|
expect(hasOutputSchema("weatherTool")).toBe(true);
|
||||||
|
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
|
expect(hasOutputSchema("weatherTool")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles invalid output schemas gracefully", () => {
|
||||||
|
const toolsWithInvalidSchema: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "invalidSchemaTool",
|
||||||
|
description: "Tool with invalid schema",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
outputSchema: {
|
||||||
|
// @ts-expect-error Testing with invalid type
|
||||||
|
type: "invalid-type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() =>
|
||||||
|
cacheToolOutputSchemas(toolsWithInvalidSchema),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(hasOutputSchema("invalidSchemaTool")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateToolOutput", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cacheToolOutputSchemas(mockTools);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates correct structured content", () => {
|
||||||
|
const result = validateToolOutput("weatherTool", {
|
||||||
|
temperature: 25.5,
|
||||||
|
humidity: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid structured content", () => {
|
||||||
|
const result = validateToolOutput("weatherTool", {
|
||||||
|
temperature: "25.5", // Should be number
|
||||||
|
humidity: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toContain("should be number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing required fields", () => {
|
||||||
|
const result = validateToolOutput("weatherTool", {
|
||||||
|
temperature: 25.5,
|
||||||
|
// Missing humidity
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toContain("required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates complex nested structures", () => {
|
||||||
|
const validResult = validateToolOutput("complexOutputSchema", {
|
||||||
|
user: {
|
||||||
|
name: "John",
|
||||||
|
age: 30,
|
||||||
|
},
|
||||||
|
tags: ["tag1", "tag2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validResult.isValid).toBe(true);
|
||||||
|
|
||||||
|
const invalidResult = validateToolOutput("complexOutputSchema", {
|
||||||
|
user: {
|
||||||
|
// Missing required 'name'
|
||||||
|
age: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidResult.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns valid for tools without validators", () => {
|
||||||
|
const result = validateToolOutput("nonExistentTool", { any: "data" });
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates additional properties restriction", () => {
|
||||||
|
const result = validateToolOutput("weatherTool", {
|
||||||
|
temperature: 25.5,
|
||||||
|
humidity: 60,
|
||||||
|
extraField: "should not be here",
|
||||||
|
});
|
||||||
|
|
||||||
|
// This depends on whether additionalProperties is set to false in the schema
|
||||||
|
// If it is, this should fail
|
||||||
|
expect(result.isValid).toBe(true); // By default, additional properties are allowed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getToolOutputValidator", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cacheToolOutputSchemas(mockTools);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns validator for cached tool", () => {
|
||||||
|
const validator = getToolOutputValidator("weatherTool");
|
||||||
|
expect(validator).toBeDefined();
|
||||||
|
expect(typeof validator).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for tool without output schema", () => {
|
||||||
|
const validator = getToolOutputValidator("noOutputSchema");
|
||||||
|
expect(validator).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for non-existent tool", () => {
|
||||||
|
const validator = getToolOutputValidator("nonExistentTool");
|
||||||
|
expect(validator).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasOutputSchema", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cacheToolOutputSchemas(mockTools);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true for tools with output schemas", () => {
|
||||||
|
expect(hasOutputSchema("weatherTool")).toBe(true);
|
||||||
|
expect(hasOutputSchema("complexOutputSchema")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for tools without output schemas", () => {
|
||||||
|
expect(hasOutputSchema("noOutputSchema")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-existent tools", () => {
|
||||||
|
expect(hasOutputSchema("nonExistentTool")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,82 @@
|
||||||
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
||||||
|
import Ajv from "ajv";
|
||||||
|
import type { ValidateFunction } from "ajv";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
|
const ajv = new Ajv();
|
||||||
|
|
||||||
|
// Cache for compiled validators
|
||||||
|
const toolOutputValidators = new Map<string, ValidateFunction>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles and caches output schema validators for a list of tools
|
||||||
|
* Following the same pattern as SDK's Client.cacheToolOutputSchemas
|
||||||
|
* @param tools Array of tools that may have output schemas
|
||||||
|
*/
|
||||||
|
export function cacheToolOutputSchemas(tools: Tool[]): void {
|
||||||
|
toolOutputValidators.clear();
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (tool.outputSchema) {
|
||||||
|
try {
|
||||||
|
const validator = ajv.compile(tool.outputSchema);
|
||||||
|
toolOutputValidators.set(tool.name, validator);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to compile output schema for tool ${tool.name}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the cached output schema validator for a tool
|
||||||
|
* Following the same pattern as SDK's Client.getToolOutputValidator
|
||||||
|
* @param toolName Name of the tool
|
||||||
|
* @returns The compiled validator function, or undefined if not found
|
||||||
|
*/
|
||||||
|
export function getToolOutputValidator(
|
||||||
|
toolName: string,
|
||||||
|
): ValidateFunction | undefined {
|
||||||
|
return toolOutputValidators.get(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates structured content against a tool's output schema
|
||||||
|
* Returns validation result with detailed error messages
|
||||||
|
* @param toolName Name of the tool
|
||||||
|
* @param structuredContent The structured content to validate
|
||||||
|
* @returns An object with isValid boolean and optional error message
|
||||||
|
*/
|
||||||
|
export function validateToolOutput(
|
||||||
|
toolName: string,
|
||||||
|
structuredContent: unknown,
|
||||||
|
): { isValid: boolean; error?: string } {
|
||||||
|
const validator = getToolOutputValidator(toolName);
|
||||||
|
if (!validator) {
|
||||||
|
return { isValid: true }; // No validator means no schema to validate against
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = validator(structuredContent);
|
||||||
|
if (!isValid) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: ajv.errorsText(validator.errors),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a tool has an output schema
|
||||||
|
* @param toolName Name of the tool
|
||||||
|
* @returns true if the tool has an output schema
|
||||||
|
*/
|
||||||
|
export function hasOutputSchema(toolName: string): boolean {
|
||||||
|
return toolOutputValidators.has(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a default value based on a JSON schema type
|
* Generates a default value based on a JSON schema type
|
||||||
|
|
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -77,6 +77,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"ajv": "^6.12.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
|
|
@ -4002,7 +4003,6 @@
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
|
@ -5710,7 +5710,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
|
|
@ -7740,7 +7739,6 @@
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
|
|
@ -8812,7 +8810,6 @@
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
|
|
@ -10488,7 +10485,6 @@
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue