refactor: Address PR comments

- Extract tool results rendering into separate ToolResults component
- Hoist checkContentCompatibility function to module level following codebase conventions
- Add ajv@6.12.6 as direct dependency to client package.json
- All tests pass and linting is clean

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Basil Hosmer 2025-05-28 22:18:19 -04:00
commit d8a2821344
No known key found for this signature in database
5 changed files with 229 additions and 231 deletions

View file

@ -25,6 +25,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.5",
"@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",

View 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;

View file

@ -7,13 +7,8 @@ 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,
@ -22,6 +17,7 @@ import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
const ToolsTab = ({
tools,
@ -57,224 +53,6 @@ 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 {
return {
isCompatible: false,
message: "Unstructured content is not valid JSON",
};
}
};
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;
// 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">
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 (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
@ -475,7 +253,10 @@ const ToolsTab = ({
</>
)}
</Button>
{toolResult && renderToolResult()}
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}
/>
</div>
) : (
<Alert>

View file

@ -3,7 +3,6 @@ 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

6
package-lock.json generated
View file

@ -77,6 +77,7 @@
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"ajv": "^6.12.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
@ -4002,7 +4003,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@ -5710,7 +5710,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
@ -7740,7 +7739,6 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@ -8812,7 +8810,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -10488,7 +10485,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"