feat: Add structured output support to browser UI
This commit adds comprehensive support for structured output validation in tool calls, following the MCP specification for tools with output schemas. UI Changes: 1. Output Schema Display: - Added collapsible output schema section in ToolsTab - Shows output schemas after input fields, before the Run Tool button - Default view shows 8 lines with scrolling, expandable to full view - Expand/Collapse button with chevron icons for better UX 2. Structured Content Display: - New "Structured Content" section when tools return structuredContent - Shows structured data in a formatted JSON view - Validation status indicator (green checkmark for valid, red X for errors) - Detailed validation error messages when content doesn't match schema 3. Unstructured Content Labeling: - Added "Unstructured Content" heading when both structured and unstructured content exist - Only shows label when structured content is also present - Maintains clean UI when only unstructured content exists 4. Compatibility Checking: - Checks if unstructured content matches structured content (when output schema exists) - Shows compatibility status with blue (compatible) or yellow (incompatible) indicators - Detailed messages explain why content doesn't match - Only runs compatibility check for tools with output schemas 5. Validation Error Handling: - Shows error when tool with output schema doesn't return structured content - Clear error messages for schema validation failures - Maintains proper error state display Technical Implementation: - Added schema validation utilities using Ajv (same as SDK) - Caches compiled validators for performance - Validates on tool result display, not during the call - Follows SDK's Client.callTool validation pattern 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
183f80104a
commit
de84c472ce
3 changed files with 242 additions and 7 deletions
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||
import { AuthDebuggerState } from "./lib/auth-types";
|
||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
|
|
@ -473,6 +474,8 @@ const App = () => {
|
|||
);
|
||||
setTools(response.tools);
|
||||
setNextToolCursor(response.nextCursor);
|
||||
// Cache output schemas for validation
|
||||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
|
|
@ -759,6 +762,8 @@ const App = () => {
|
|||
clearTools={() => {
|
||||
setTools([]);
|
||||
setNextToolCursor(undefined);
|
||||
// Clear cached output schemas
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
clearError("tools");
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { TabsContent } from "@/components/ui/tabs";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import DynamicJsonForm from "./DynamicJsonForm";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import { generateDefaultValue, validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
} 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 ListPane from "./ListPane";
|
||||
import JsonView from "./JsonView";
|
||||
|
|
@ -41,6 +41,7 @@ const ToolsTab = ({
|
|||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = Object.entries(
|
||||
|
|
@ -52,6 +53,53 @@ const ToolsTab = ({
|
|||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
||||
// Check compatibility between structured and unstructured content
|
||||
const checkContentCompatibility = (
|
||||
structuredContent: unknown,
|
||||
unstructuredContent: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
): { isCompatible: boolean; message: string } => {
|
||||
// Check if unstructured content is a single text block
|
||||
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 {
|
||||
// Try to parse the text as JSON
|
||||
const parsedContent = JSON.parse(textContent);
|
||||
|
||||
// Deep equality check
|
||||
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 (e) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not valid JSON"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const renderToolResult = () => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
|
|
@ -72,6 +120,36 @@ const ToolsTab = ({
|
|||
const structuredResult = parsedResult.data;
|
||||
const isError = structuredResult.isError ?? false;
|
||||
|
||||
// Validate structured content if present and tool has output schema
|
||||
let validationResult = null;
|
||||
const toolHasOutputSchema = selectedTool && hasOutputSchema(selectedTool.name);
|
||||
|
||||
if (toolHasOutputSchema) {
|
||||
if (!structuredResult.structuredContent && !isError) {
|
||||
// Tool has output schema but didn't return structured content (and it's not an error)
|
||||
validationResult = {
|
||||
isValid: false,
|
||||
error: "Tool has an output schema but did not return structured content"
|
||||
};
|
||||
} else if (structuredResult.structuredContent) {
|
||||
// Validate the structured content
|
||||
validationResult = validateToolOutput(selectedTool.name, structuredResult.structuredContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Check compatibility if both structured and unstructured content exist
|
||||
// AND the tool has an output schema
|
||||
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">
|
||||
|
|
@ -82,11 +160,57 @@ const ToolsTab = ({
|
|||
<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} />
|
||||
{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}`}
|
||||
|
|
@ -106,8 +230,10 @@ const ToolsTab = ({
|
|||
) : (
|
||||
<JsonView data={item.resource} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if ("toolResult" in toolResult) {
|
||||
|
|
@ -262,6 +388,36 @@ 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
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,78 @@
|
|||
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
||||
import Ajv from "ajv";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
// Create a single Ajv instance following the SDK pattern
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue