Skip to content

Commit e364273

Browse files
authored
Merge pull request #456 from modelcontextprotocol/feature/structured-output-support
Add structured output support
2 parents 183f801 + d8a2821 commit e364273

File tree

8 files changed

+795
-79
lines changed

8 files changed

+795
-79
lines changed

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"dependencies": {
2626
"@modelcontextprotocol/sdk": "^1.11.5",
2727
"@radix-ui/react-checkbox": "^1.1.4",
28+
"ajv": "^6.12.6",
2829
"@radix-ui/react-dialog": "^1.1.3",
2930
"@radix-ui/react-icons": "^1.3.0",
3031
"@radix-ui/react-label": "^2.1.0",

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/ToolResults.tsx

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import JsonView from "./JsonView";
2+
import {
3+
CallToolResultSchema,
4+
CompatibilityCallToolResult,
5+
Tool,
6+
} from "@modelcontextprotocol/sdk/types.js";
7+
import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
8+
9+
interface ToolResultsProps {
10+
toolResult: CompatibilityCallToolResult | null;
11+
selectedTool: Tool | null;
12+
}
13+
14+
const checkContentCompatibility = (
15+
structuredContent: unknown,
16+
unstructuredContent: Array<{
17+
type: string;
18+
text?: string;
19+
[key: string]: unknown;
20+
}>,
21+
): { isCompatible: boolean; message: string } => {
22+
if (
23+
unstructuredContent.length !== 1 ||
24+
unstructuredContent[0].type !== "text"
25+
) {
26+
return {
27+
isCompatible: false,
28+
message: "Unstructured content is not a single text block",
29+
};
30+
}
31+
32+
const textContent = unstructuredContent[0].text;
33+
if (!textContent) {
34+
return {
35+
isCompatible: false,
36+
message: "Text content is empty",
37+
};
38+
}
39+
40+
try {
41+
const parsedContent = JSON.parse(textContent);
42+
const isEqual =
43+
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
44+
45+
if (isEqual) {
46+
return {
47+
isCompatible: true,
48+
message: "Unstructured content matches structured content",
49+
};
50+
} else {
51+
return {
52+
isCompatible: false,
53+
message: "Parsed JSON does not match structured content",
54+
};
55+
}
56+
} catch {
57+
return {
58+
isCompatible: false,
59+
message: "Unstructured content is not valid JSON",
60+
};
61+
}
62+
};
63+
64+
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
65+
if (!toolResult) return null;
66+
67+
if ("content" in toolResult) {
68+
const parsedResult = CallToolResultSchema.safeParse(toolResult);
69+
if (!parsedResult.success) {
70+
return (
71+
<>
72+
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
73+
<JsonView data={toolResult} />
74+
<h4 className="font-semibold mb-2">Errors:</h4>
75+
{parsedResult.error.errors.map((error, idx) => (
76+
<JsonView data={error} key={idx} />
77+
))}
78+
</>
79+
);
80+
}
81+
const structuredResult = parsedResult.data;
82+
const isError = structuredResult.isError ?? false;
83+
84+
let validationResult = null;
85+
const toolHasOutputSchema =
86+
selectedTool && hasOutputSchema(selectedTool.name);
87+
88+
if (toolHasOutputSchema) {
89+
if (!structuredResult.structuredContent && !isError) {
90+
validationResult = {
91+
isValid: false,
92+
error:
93+
"Tool has an output schema but did not return structured content",
94+
};
95+
} else if (structuredResult.structuredContent) {
96+
validationResult = validateToolOutput(
97+
selectedTool.name,
98+
structuredResult.structuredContent,
99+
);
100+
}
101+
}
102+
103+
let compatibilityResult = null;
104+
if (
105+
structuredResult.structuredContent &&
106+
structuredResult.content.length > 0 &&
107+
selectedTool &&
108+
hasOutputSchema(selectedTool.name)
109+
) {
110+
compatibilityResult = checkContentCompatibility(
111+
structuredResult.structuredContent,
112+
structuredResult.content,
113+
);
114+
}
115+
116+
return (
117+
<>
118+
<h4 className="font-semibold mb-2">
119+
Tool Result:{" "}
120+
{isError ? (
121+
<span className="text-red-600 font-semibold">Error</span>
122+
) : (
123+
<span className="text-green-600 font-semibold">Success</span>
124+
)}
125+
</h4>
126+
{structuredResult.structuredContent && (
127+
<div className="mb-4">
128+
<h5 className="font-semibold mb-2 text-sm">Structured Content:</h5>
129+
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
130+
<JsonView data={structuredResult.structuredContent} />
131+
{validationResult && (
132+
<div
133+
className={`mt-2 p-2 rounded text-sm ${
134+
validationResult.isValid
135+
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
136+
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
137+
}`}
138+
>
139+
{validationResult.isValid ? (
140+
"✓ Valid according to output schema"
141+
) : (
142+
<>✗ Validation Error: {validationResult.error}</>
143+
)}
144+
</div>
145+
)}
146+
</div>
147+
</div>
148+
)}
149+
{!structuredResult.structuredContent &&
150+
validationResult &&
151+
!validationResult.isValid && (
152+
<div className="mb-4">
153+
<div className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 p-2 rounded text-sm">
154+
✗ Validation Error: {validationResult.error}
155+
</div>
156+
</div>
157+
)}
158+
{structuredResult.content.length > 0 && (
159+
<div className="mb-4">
160+
{structuredResult.structuredContent && (
161+
<>
162+
<h5 className="font-semibold mb-2 text-sm">
163+
Unstructured Content:
164+
</h5>
165+
{compatibilityResult && (
166+
<div
167+
className={`mb-2 p-2 rounded text-sm ${
168+
compatibilityResult.isCompatible
169+
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
170+
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
171+
}`}
172+
>
173+
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
174+
{compatibilityResult.message}
175+
</div>
176+
)}
177+
</>
178+
)}
179+
{structuredResult.content.map((item, index) => (
180+
<div key={index} className="mb-2">
181+
{item.type === "text" && (
182+
<JsonView data={item.text} isError={isError} />
183+
)}
184+
{item.type === "image" && (
185+
<img
186+
src={`data:${item.mimeType};base64,${item.data}`}
187+
alt="Tool result image"
188+
className="max-w-full h-auto"
189+
/>
190+
)}
191+
{item.type === "resource" &&
192+
(item.resource?.mimeType?.startsWith("audio/") ? (
193+
<audio
194+
controls
195+
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
196+
className="w-full"
197+
>
198+
<p>Your browser does not support audio playback</p>
199+
</audio>
200+
) : (
201+
<JsonView data={item.resource} />
202+
))}
203+
</div>
204+
))}
205+
</div>
206+
)}
207+
</>
208+
);
209+
} else if ("toolResult" in toolResult) {
210+
return (
211+
<>
212+
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
213+
<JsonView data={toolResult.toolResult} />
214+
</>
215+
);
216+
}
217+
218+
return null;
219+
};
220+
221+
export default ToolResults;

0 commit comments

Comments
 (0)