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:
Basil Hosmer 2025-05-27 16:39:39 -04:00
commit de84c472ce
No known key found for this signature in database
3 changed files with 242 additions and 7 deletions

View file

@ -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");

View file

@ -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 {

View file

@ -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