Skip to content

Commit e54a70c

Browse files
committed
Added structuredContent support, applied "Remove misleading structured content compatibility warning" fix (PR 1098)
1 parent 4fd1799 commit e54a70c

10 files changed

Lines changed: 155 additions & 51 deletions

File tree

core/__tests__/inspectorClient.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,38 @@ describe("InspectorClient", () => {
634634
expect(hasImage).toBe(true);
635635
});
636636

637+
it("should return both content and structuredContent for tool with outputSchema (get_temp)", async () => {
638+
const result = await client.callTool("get_temp", {
639+
city: "Seattle",
640+
units: "C",
641+
});
642+
643+
expect(result.success).toBe(true);
644+
expect(result.result).toBeDefined();
645+
expect(result.result).toHaveProperty("content");
646+
expect(result.result).toHaveProperty("structuredContent");
647+
648+
const content = result.result!.content as Array<{
649+
type: string;
650+
text?: string;
651+
}>;
652+
expect(Array.isArray(content)).toBe(true);
653+
expect(content[0].type).toBe("text");
654+
expect(content[0].text).toContain("Seattle");
655+
expect(content[0].text).toContain("25");
656+
expect(content[0].text).toContain("degrees C");
657+
658+
const structured = result.result!.structuredContent as Record<
659+
string,
660+
unknown
661+
>;
662+
expect(structured).toEqual({
663+
temperature: 25,
664+
unit: "C",
665+
city: "Seattle",
666+
});
667+
});
668+
637669
it("should handle tool not found", async () => {
638670
const result = await client.callTool("nonexistent-tool", {});
639671
// When tool is not found, the SDK returns an error response, not an exception

docs/inspector-client-todo.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,11 @@ If we are in a container:
7878
Goal: Parity with v1 client
7979

8080
- MCP apps work (remaining)
81-
- Fix handler multiplexing in AppRendererClient
81+
- Fix handler multiplexing in AppRendererClient (AppNotificationHandler multiplexing)
8282
- Update README (client->web, proxy->sandbox)
8383
- Review changes to Client from time of fork to present to make sure we didn't miss anything else
84+
- Structured Content Compatability warning
85+
- https://github.com/modelcontextprotocol/inspector/pull/1098
8486

8587
Goal: Bring Inspector Web support to current spec
8688

test/__tests__/server-composable.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ describe("server-composable", () => {
2929

3030
try {
3131
const { tools } = await client.listTools();
32-
expect(tools).toHaveLength(1);
33-
expect(tools[0].name).toBe("echo");
32+
const echoTool = tools.find((t) => t.name === "echo");
33+
expect(echoTool).toBeDefined();
3434

3535
const result = await client.callTool({
3636
name: "echo",

test/configs/demo.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "composable-demo",
44
"version": "1.0.0"
55
},
6-
"tools": [{ "preset": "echo" }],
6+
"tools": [{ "preset": "echo" }, { "preset": "get_temp" }],
77
"transport": {
88
"type": "stdio"
99
}

test/src/composable-test-server.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
type ListResourceTemplatesResult,
5050
type ListPromptsResult,
5151
} from "@modelcontextprotocol/sdk/types.js";
52+
import type { AnySchema } from "@modelcontextprotocol/sdk/server/zod-compat.js";
5253
import {
5354
ZodRawShapeCompat,
5455
getObjectShape,
@@ -98,6 +99,8 @@ export interface ToolDefinition {
9899
name: string;
99100
description: string;
100101
inputSchema?: ToolInputSchema;
102+
/** Optional Zod object schema for tool output; when set, handler must return structuredContent. */
103+
outputSchema?: unknown;
101104
handler: (
102105
params: Record<string, any>,
103106
context?: TestServerContext,
@@ -470,37 +473,43 @@ export function createMcpServer(config: ServerConfig): McpServer {
470473
{
471474
description: tool.description,
472475
inputSchema: tool.inputSchema,
476+
...(tool.outputSchema != null && {
477+
outputSchema: tool.outputSchema as AnySchema,
478+
}),
473479
},
474480
async (args, extra) => {
475481
const result = await tool.handler(
476482
args as Record<string, any>,
477483
context,
478484
extra,
479485
);
480-
// Handle different return types from tool handlers
481-
// If handler returns content array directly (like get-annotated-message), use it
486+
const rawStructured =
487+
result &&
488+
typeof result === "object" &&
489+
"structuredContent" in result
490+
? (result as { structuredContent?: unknown }).structuredContent
491+
: undefined;
492+
const structuredContent =
493+
rawStructured !== undefined && rawStructured !== null
494+
? (rawStructured as Record<string, unknown>)
495+
: undefined;
496+
// If handler returns content array, use it; otherwise build content from message or stringify
497+
let content: Array<{ type: "text"; text: string }>;
482498
if (result && Array.isArray(result.content)) {
483-
return { content: result.content };
484-
}
485-
// If handler returns message (like echo), format it
486-
if (result && typeof result.message === "string") {
487-
return {
488-
content: [
489-
{
490-
type: "text",
491-
text: result.message,
492-
},
493-
],
494-
};
495-
}
496-
// Otherwise, stringify the result
497-
return {
498-
content: [
499+
content = result.content as Array<{ type: "text"; text: string }>;
500+
} else if (result && typeof result.message === "string") {
501+
content = [{ type: "text" as const, text: result.message }];
502+
} else {
503+
content = [
499504
{
500-
type: "text",
501-
text: JSON.stringify(result),
505+
type: "text" as const,
506+
text: JSON.stringify(result ?? {}),
502507
},
503-
],
508+
];
509+
}
510+
return {
511+
content,
512+
...(structuredContent !== undefined && { structuredContent }),
504513
};
505514
},
506515
);

test/src/preset-registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
createUrlElicitationFormTool,
2323
createSendNotificationTool,
2424
createGetAnnotatedMessageTool,
25+
createGetTempTool,
2526
createAddResourceTool,
2627
createRemoveResourceTool,
2728
createAddToolTool,
@@ -96,6 +97,8 @@ function resolveToolPreset(
9697
return createSendNotificationTool();
9798
case "get_annotated_message":
9899
return createGetAnnotatedMessageTool();
100+
case "get_temp":
101+
return createGetTempTool();
99102
case "add_resource":
100103
return createAddResourceTool();
101104
case "remove_resource":

test/src/test-server-fixtures.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,40 @@ export function createGetAnnotatedMessageTool(): ToolDefinition {
640640
};
641641
}
642642

643+
/** Output schema for get_temp: temperature, unit, city */
644+
const GetTempOutputSchema = z.object({
645+
temperature: z.number().describe("Temperature value"),
646+
unit: z.string().describe("C or F"),
647+
city: z.string().describe("City name"),
648+
});
649+
650+
/**
651+
* Create a "get_temp" tool that returns both content (human-readable) and structuredContent (schema-validated).
652+
* Takes city and units (C/F), returns mock temperature 25 and matching text + structured output.
653+
*/
654+
export function createGetTempTool(): ToolDefinition {
655+
return {
656+
name: "get_temp",
657+
description:
658+
"Get the current temperature for a city (mock; returns 25 in requested units)",
659+
inputSchema: {
660+
city: z.string().describe("City name"),
661+
units: z.enum(["C", "F"]).describe("Temperature units"),
662+
},
663+
outputSchema: GetTempOutputSchema,
664+
handler: async (params: Record<string, any>) => {
665+
const city = (params.city as string) || "Unknown";
666+
const unit = (params.units as "C" | "F") || "C";
667+
const temperature = 25;
668+
const text = `The temperature in ${city} is ${temperature} degrees ${unit}`;
669+
return {
670+
content: [{ type: "text" as const, text }],
671+
structuredContent: { temperature, unit, city },
672+
};
673+
},
674+
};
675+
}
676+
643677
/**
644678
* Create a "simple_prompt" prompt definition
645679
*/
@@ -1823,6 +1857,7 @@ export function getDefaultServerConfig(): ServerConfig {
18231857
createEchoTool(),
18241858
createGetSumTool(),
18251859
createGetAnnotatedMessageTool(),
1860+
createGetTempTool(),
18261861
createSendNotificationTool(),
18271862
createWriteToStderrTool(),
18281863
],

web/src/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,8 +1413,8 @@ const App = () => {
14131413
const compatibilityResult: CompatibilityCallToolResult =
14141414
invocation.result
14151415
? {
1416-
content: invocation.result.content || [],
1417-
isError: false,
1416+
...invocation.result,
1417+
content: invocation.result.content ?? [],
14181418
}
14191419
: {
14201420
content: [
@@ -1447,8 +1447,8 @@ const App = () => {
14471447
const compatibilityResult: CompatibilityCallToolResult =
14481448
invocation.result
14491449
? {
1450-
content: invocation.result.content || [],
1451-
isError: false,
1450+
...invocation.result,
1451+
content: invocation.result.content ?? [],
14521452
}
14531453
: {
14541454
content: [

web/src/components/ToolResults.tsx

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,14 @@ const checkContentCompatibility = (
2222
text?: string;
2323
[key: string]: unknown;
2424
}>,
25-
): { isCompatible: boolean; message: string } => {
25+
): { hasMatch: boolean; message: string } | null => {
2626
// Look for at least one text content block that matches the structured content
2727
const textBlocks = unstructuredContent.filter(
2828
(block) => block.type === "text",
2929
);
3030

3131
if (textBlocks.length === 0) {
32-
return {
33-
isCompatible: false,
34-
message: "No text blocks to match structured content",
35-
};
32+
return null;
3633
}
3734

3835
// Check if any text block contains JSON that matches the structured content
@@ -49,7 +46,7 @@ const checkContentCompatibility = (
4946

5047
if (isEqual) {
5148
return {
52-
isCompatible: true,
49+
hasMatch: true,
5350
message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`,
5451
};
5552
}
@@ -59,10 +56,7 @@ const checkContentCompatibility = (
5956
}
6057
}
6158

62-
return {
63-
isCompatible: false,
64-
message: "No text block matches structured content",
65-
};
59+
return null;
6660
};
6761

6862
const ToolResults = ({
@@ -196,16 +190,9 @@ const ToolResults = ({
196190
<h5 className="font-semibold mb-2 text-sm">
197191
Unstructured Content:
198192
</h5>
199-
{compatibilityResult && (
200-
<div
201-
className={`mb-2 p-2 rounded text-sm ${
202-
compatibilityResult.isCompatible
203-
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
204-
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
205-
}`}
206-
>
207-
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
208-
{compatibilityResult.message}
193+
{compatibilityResult?.hasMatch && (
194+
<div className="mb-2 p-2 rounded text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
195+
{compatibilityResult.message}
209196
</div>
210197
)}
211198
</>

web/src/components/__tests__/ToolsTab.test.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -736,8 +736,13 @@ describe("ToolsTab", () => {
736736
toolResult: noMatchResult,
737737
});
738738

739-
// Should render without crashing - the validation logic has been updated
740739
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
740+
// No compatibility warning when content is not a JSON copy of structuredContent (PR #1098)
741+
expect(
742+
screen.queryByText(
743+
/structured content matches|no text blocks|no.*matches/i,
744+
),
745+
).not.toBeInTheDocument();
741746
});
742747

743748
it("should reject when no text blocks are present", () => {
@@ -752,8 +757,13 @@ describe("ToolsTab", () => {
752757
toolResult: noTextBlocksResult,
753758
});
754759

755-
// Should render without crashing - the validation logic has been updated
756760
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
761+
// No compatibility message when no text blocks (PR #1098)
762+
expect(
763+
screen.queryByText(
764+
/structured content matches|no text blocks|no.*matches/i,
765+
),
766+
).not.toBeInTheDocument();
757767
});
758768

759769
it("should not show compatibility check when tool has no output schema", () => {
@@ -774,6 +784,32 @@ describe("ToolsTab", () => {
774784
),
775785
).not.toBeInTheDocument();
776786
});
787+
788+
it("should not show compatibility warning when content is human-readable (not JSON copy of structuredContent)", () => {
789+
const humanReadableResult = {
790+
content: [
791+
{
792+
type: "text",
793+
text: "The temperature in Seattle is 25 degrees C",
794+
},
795+
],
796+
structuredContent: { temperature: 25, unit: "C", city: "Seattle" },
797+
};
798+
799+
renderToolsTab({
800+
tools: [toolWithOutputSchema],
801+
selectedTool: toolWithOutputSchema,
802+
toolResult: humanReadableResult,
803+
});
804+
805+
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
806+
// No yellow warning; MCP spec allows content to be human-readable (PR #1098)
807+
expect(
808+
screen.queryByText(
809+
/no text blocks? to match|no text block matches structured/i,
810+
),
811+
).not.toBeInTheDocument();
812+
});
777813
});
778814

779815
describe("Resource Link Content Type", () => {

0 commit comments

Comments
 (0)