Skip to content

Commit de84c47

Browse files
bhosmer-antclaude
andcommitted
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 <[email protected]>
1 parent 183f801 commit de84c47

File tree

3 files changed

+242
-7
lines changed

3 files changed

+242
-7
lines changed

client/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
2121
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
2222
import { AuthDebuggerState } from "./lib/auth-types";
23+
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
2324
import React, {
2425
Suspense,
2526
useCallback,
@@ -473,6 +474,8 @@ const App = () => {
473474
);
474475
setTools(response.tools);
475476
setNextToolCursor(response.nextCursor);
477+
// Cache output schemas for validation
478+
cacheToolOutputSchemas(response.tools);
476479
};
477480

478481
const callTool = async (name: string, params: Record<string, unknown>) => {
@@ -759,6 +762,8 @@ const App = () => {
759762
clearTools={() => {
760763
setTools([]);
761764
setNextToolCursor(undefined);
765+
// Clear cached output schemas
766+
cacheToolOutputSchemas([]);
762767
}}
763768
callTool={async (name, params) => {
764769
clearError("tools");

client/src/components/ToolsTab.tsx

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import { TabsContent } from "@/components/ui/tabs";
77
import { Textarea } from "@/components/ui/textarea";
88
import DynamicJsonForm from "./DynamicJsonForm";
99
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
10-
import { generateDefaultValue } from "@/utils/schemaUtils";
10+
import { generateDefaultValue, validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
1111
import {
1212
CallToolResultSchema,
1313
CompatibilityCallToolResult,
1414
ListToolsResult,
1515
Tool,
1616
} from "@modelcontextprotocol/sdk/types.js";
17-
import { Loader2, Send } from "lucide-react";
17+
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
1818
import { useEffect, useState } from "react";
1919
import ListPane from "./ListPane";
2020
import JsonView from "./JsonView";
@@ -41,6 +41,7 @@ const ToolsTab = ({
4141
}) => {
4242
const [params, setParams] = useState<Record<string, unknown>>({});
4343
const [isToolRunning, setIsToolRunning] = useState(false);
44+
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
4445

4546
useEffect(() => {
4647
const params = Object.entries(
@@ -52,6 +53,53 @@ const ToolsTab = ({
5253
setParams(Object.fromEntries(params));
5354
}, [selectedTool]);
5455

56+
// Check compatibility between structured and unstructured content
57+
const checkContentCompatibility = (
58+
structuredContent: unknown,
59+
unstructuredContent: Array<{ type: string; text?: string; [key: string]: unknown }>
60+
): { isCompatible: boolean; message: string } => {
61+
// Check if unstructured content is a single text block
62+
if (unstructuredContent.length !== 1 || unstructuredContent[0].type !== "text") {
63+
return {
64+
isCompatible: false,
65+
message: "Unstructured content is not a single text block"
66+
};
67+
}
68+
69+
const textContent = unstructuredContent[0].text;
70+
if (!textContent) {
71+
return {
72+
isCompatible: false,
73+
message: "Text content is empty"
74+
};
75+
}
76+
77+
try {
78+
// Try to parse the text as JSON
79+
const parsedContent = JSON.parse(textContent);
80+
81+
// Deep equality check
82+
const isEqual = JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
83+
84+
if (isEqual) {
85+
return {
86+
isCompatible: true,
87+
message: "Unstructured content matches structured content"
88+
};
89+
} else {
90+
return {
91+
isCompatible: false,
92+
message: "Parsed JSON does not match structured content"
93+
};
94+
}
95+
} catch (e) {
96+
return {
97+
isCompatible: false,
98+
message: "Unstructured content is not valid JSON"
99+
};
100+
}
101+
};
102+
55103
const renderToolResult = () => {
56104
if (!toolResult) return null;
57105

@@ -72,6 +120,36 @@ const ToolsTab = ({
72120
const structuredResult = parsedResult.data;
73121
const isError = structuredResult.isError ?? false;
74122

123+
// Validate structured content if present and tool has output schema
124+
let validationResult = null;
125+
const toolHasOutputSchema = selectedTool && hasOutputSchema(selectedTool.name);
126+
127+
if (toolHasOutputSchema) {
128+
if (!structuredResult.structuredContent && !isError) {
129+
// Tool has output schema but didn't return structured content (and it's not an error)
130+
validationResult = {
131+
isValid: false,
132+
error: "Tool has an output schema but did not return structured content"
133+
};
134+
} else if (structuredResult.structuredContent) {
135+
// Validate the structured content
136+
validationResult = validateToolOutput(selectedTool.name, structuredResult.structuredContent);
137+
}
138+
}
139+
140+
// Check compatibility if both structured and unstructured content exist
141+
// AND the tool has an output schema
142+
let compatibilityResult = null;
143+
if (structuredResult.structuredContent &&
144+
structuredResult.content.length > 0 &&
145+
selectedTool &&
146+
hasOutputSchema(selectedTool.name)) {
147+
compatibilityResult = checkContentCompatibility(
148+
structuredResult.structuredContent,
149+
structuredResult.content
150+
);
151+
}
152+
75153
return (
76154
<>
77155
<h4 className="font-semibold mb-2">
@@ -82,11 +160,57 @@ const ToolsTab = ({
82160
<span className="text-green-600 font-semibold">Success</span>
83161
)}
84162
</h4>
85-
{structuredResult.content.map((item, index) => (
86-
<div key={index} className="mb-2">
87-
{item.type === "text" && (
88-
<JsonView data={item.text} isError={isError} />
163+
{structuredResult.structuredContent && (
164+
<div className="mb-4">
165+
<h5 className="font-semibold mb-2 text-sm">Structured Content:</h5>
166+
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
167+
<JsonView data={structuredResult.structuredContent} />
168+
{validationResult && (
169+
<div className={`mt-2 p-2 rounded text-sm ${
170+
validationResult.isValid
171+
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
172+
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
173+
}`}>
174+
{validationResult.isValid ? (
175+
"✓ Valid according to output schema"
176+
) : (
177+
<>
178+
✗ Validation Error: {validationResult.error}
179+
</>
180+
)}
181+
</div>
182+
)}
183+
</div>
184+
</div>
185+
)}
186+
{!structuredResult.structuredContent && validationResult && !validationResult.isValid && (
187+
<div className="mb-4">
188+
<div className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 p-2 rounded text-sm">
189+
✗ Validation Error: {validationResult.error}
190+
</div>
191+
</div>
192+
)}
193+
{structuredResult.content.length > 0 && (
194+
<div className="mb-4">
195+
{structuredResult.structuredContent && (
196+
<>
197+
<h5 className="font-semibold mb-2 text-sm">Unstructured Content:</h5>
198+
{compatibilityResult && (
199+
<div className={`mb-2 p-2 rounded text-sm ${
200+
compatibilityResult.isCompatible
201+
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
202+
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
203+
}`}>
204+
{compatibilityResult.isCompatible ? "✓" : "⚠"} {compatibilityResult.message}
205+
</div>
206+
)}
207+
</>
89208
)}
209+
{structuredResult.content.map((item, index) => (
210+
<div key={index} className="mb-2">
211+
{item.type === "text" && (
212+
<JsonView data={item.text} isError={isError} />
213+
)}
90214
{item.type === "image" && (
91215
<img
92216
src={`data:${item.mimeType};base64,${item.data}`}
@@ -106,8 +230,10 @@ const ToolsTab = ({
106230
) : (
107231
<JsonView data={item.resource} />
108232
))}
233+
</div>
234+
))}
109235
</div>
110-
))}
236+
)}
111237
</>
112238
);
113239
} else if ("toolResult" in toolResult) {
@@ -262,6 +388,36 @@ const ToolsTab = ({
262388
);
263389
},
264390
)}
391+
{selectedTool.outputSchema && (
392+
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
393+
<div className="flex items-center justify-between mb-2">
394+
<h4 className="text-sm font-semibold">Output Schema:</h4>
395+
<Button
396+
size="sm"
397+
variant="ghost"
398+
onClick={() => setIsOutputSchemaExpanded(!isOutputSchemaExpanded)}
399+
className="h-6 px-2"
400+
>
401+
{isOutputSchemaExpanded ? (
402+
<>
403+
<ChevronUp className="h-3 w-3 mr-1" />
404+
Collapse
405+
</>
406+
) : (
407+
<>
408+
<ChevronDown className="h-3 w-3 mr-1" />
409+
Expand
410+
</>
411+
)}
412+
</Button>
413+
</div>
414+
<div className={`transition-all ${
415+
isOutputSchemaExpanded ? "" : "max-h-[8rem] overflow-y-auto"
416+
}`}>
417+
<JsonView data={selectedTool.outputSchema} />
418+
</div>
419+
</div>
420+
)}
265421
<Button
266422
onClick={async () => {
267423
try {

client/src/utils/schemaUtils.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,78 @@
11
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
2+
import Ajv from "ajv";
3+
import type { ValidateFunction } from "ajv";
4+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
5+
6+
// Create a single Ajv instance following the SDK pattern
7+
const ajv = new Ajv();
8+
9+
// Cache for compiled validators
10+
const toolOutputValidators = new Map<string, ValidateFunction>();
11+
12+
/**
13+
* Compiles and caches output schema validators for a list of tools
14+
* Following the same pattern as SDK's Client.cacheToolOutputSchemas
15+
* @param tools Array of tools that may have output schemas
16+
*/
17+
export function cacheToolOutputSchemas(tools: Tool[]): void {
18+
toolOutputValidators.clear();
19+
for (const tool of tools) {
20+
if (tool.outputSchema) {
21+
try {
22+
const validator = ajv.compile(tool.outputSchema);
23+
toolOutputValidators.set(tool.name, validator);
24+
} catch (error) {
25+
console.warn(`Failed to compile output schema for tool ${tool.name}:`, error);
26+
}
27+
}
28+
}
29+
}
30+
31+
/**
32+
* Gets the cached output schema validator for a tool
33+
* Following the same pattern as SDK's Client.getToolOutputValidator
34+
* @param toolName Name of the tool
35+
* @returns The compiled validator function, or undefined if not found
36+
*/
37+
export function getToolOutputValidator(toolName: string): ValidateFunction | undefined {
38+
return toolOutputValidators.get(toolName);
39+
}
40+
41+
/**
42+
* Validates structured content against a tool's output schema
43+
* Returns validation result with detailed error messages
44+
* @param toolName Name of the tool
45+
* @param structuredContent The structured content to validate
46+
* @returns An object with isValid boolean and optional error message
47+
*/
48+
export function validateToolOutput(
49+
toolName: string,
50+
structuredContent: unknown
51+
): { isValid: boolean; error?: string } {
52+
const validator = getToolOutputValidator(toolName);
53+
if (!validator) {
54+
return { isValid: true }; // No validator means no schema to validate against
55+
}
56+
57+
const isValid = validator(structuredContent);
58+
if (!isValid) {
59+
return {
60+
isValid: false,
61+
error: ajv.errorsText(validator.errors),
62+
};
63+
}
64+
65+
return { isValid: true };
66+
}
67+
68+
/**
69+
* Checks if a tool has an output schema
70+
* @param toolName Name of the tool
71+
* @returns true if the tool has an output schema
72+
*/
73+
export function hasOutputSchema(toolName: string): boolean {
74+
return toolOutputValidators.has(toolName);
75+
}
276

377
/**
478
* Generates a default value based on a JSON schema type

0 commit comments

Comments
 (0)