From de84c472ce2862abc77b7e9e98377b6583785adb Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 27 May 2025 16:39:39 -0400 Subject: [PATCH] feat: Add structured output support to browser UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.tsx | 5 + client/src/components/ToolsTab.tsx | 170 +++++++++++++++++++++++++++-- client/src/utils/schemaUtils.ts | 74 +++++++++++++ 3 files changed, 242 insertions(+), 7 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 68065e24..cb411452 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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) => { @@ -759,6 +762,8 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); + // Clear cached output schemas + cacheToolOutputSchemas([]); }} callTool={async (name, params) => { clearError("tools"); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index aa67bfcf..4f4e235f 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -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>({}); 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 ( <>

@@ -82,11 +160,57 @@ const ToolsTab = ({ Success )}

- {structuredResult.content.map((item, index) => ( -
- {item.type === "text" && ( - + {structuredResult.structuredContent && ( +
+
Structured Content:
+
+ + {validationResult && ( +
+ {validationResult.isValid ? ( + "✓ Valid according to output schema" + ) : ( + <> + ✗ Validation Error: {validationResult.error} + + )} +
+ )} +
+
+ )} + {!structuredResult.structuredContent && validationResult && !validationResult.isValid && ( +
+
+ ✗ Validation Error: {validationResult.error} +
+
+ )} + {structuredResult.content.length > 0 && ( +
+ {structuredResult.structuredContent && ( + <> +
Unstructured Content:
+ {compatibilityResult && ( +
+ {compatibilityResult.isCompatible ? "✓" : "⚠"} {compatibilityResult.message} +
+ )} + )} + {structuredResult.content.map((item, index) => ( +
+ {item.type === "text" && ( + + )} {item.type === "image" && ( ))} +
+ ))}
- ))} + )} ); } else if ("toolResult" in toolResult) { @@ -262,6 +388,36 @@ const ToolsTab = ({ ); }, )} + {selectedTool.outputSchema && ( +
+
+

Output Schema:

+ +
+
+ +
+
+ )}