Skip to content

Commit 0f9a812

Browse files
committed
refactor: remove UI business logic from subclass using structured content
1 parent 122e24b commit 0f9a812

File tree

5 files changed

+127
-48
lines changed

5 files changed

+127
-48
lines changed

src/tools/mongodb/metadata/listDatabases.tsx

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,35 @@ import { MongoDBToolBase } from "../mongodbTool.js";
33
import type * as bson from "bson";
44
import type { OperationType } from "../../tool.js";
55
import { formatUntrustedData } from "../../tool.js";
6-
import { createUIResource } from "@mcp-ui/server";
6+
import { ListDatabasesOutputSchema, type ListDatabasesOutput } from "../../../ui/components/ListDatabases/schema.js";
7+
8+
// Re-export for consumers who need the schema/type
9+
export { ListDatabasesOutputSchema, type ListDatabasesOutput };
710

811
export class ListDatabasesTool extends MongoDBToolBase {
912
public name = "list-databases";
1013
protected description = "List all databases for a MongoDB connection";
1114
protected argsShape = {};
15+
protected override outputSchema = ListDatabasesOutputSchema;
1216
static operationType: OperationType = "metadata";
1317

1418
protected async execute(): Promise<CallToolResult> {
1519
const provider = await this.ensureConnected();
1620
const dbs = (await provider.listDatabases("")).databases as { name: string; sizeOnDisk: bson.Long }[];
21+
const databases = dbs.map((db) => ({
22+
name: db.name,
23+
size: Number(db.sizeOnDisk),
24+
}));
1725

18-
const toolResult = {
26+
return {
1927
content: formatUntrustedData(
2028
`Found ${dbs.length} databases`,
2129
...dbs.map((db) => `Name: ${db.name}, Size: ${db.sizeOnDisk.toString()} bytes`)
2230
),
23-
};
24-
25-
const ui = this.getUI();
26-
27-
if (!ui) {
28-
return toolResult;
29-
}
30-
31-
const uiDatabases = dbs.map((db) => ({
32-
name: db.name,
33-
size: Number(db.sizeOnDisk),
34-
}));
35-
36-
const uiResource = createUIResource({
37-
uri: `ui://list-databases/${Date.now()}`,
38-
content: {
39-
type: "rawHtml",
40-
htmlString: this.getUI() || "",
41-
},
42-
encoding: "text",
43-
uiMetadata: {
44-
"initial-render-data": {
45-
databases: uiDatabases,
46-
totalCount: uiDatabases.length,
47-
},
31+
structuredContent: {
32+
databases,
33+
totalCount: databases.length,
4834
},
49-
});
50-
51-
return {
52-
...toolResult,
53-
content: [...(toolResult.content || []), uiResource],
5435
};
5536
}
5637
}

src/tools/tool.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { z, ZodRawShape } from "zod";
1+
import { z, type ZodRawShape } from "zod";
22
import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
33
import type { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
44
import type { Session } from "../common/session.js";
@@ -10,6 +10,7 @@ import type { Server } from "../server.js";
1010
import type { Elicitation } from "../elicitation.js";
1111
import type { PreviewFeature } from "../common/schemas.js";
1212
import type { UIRegistry } from "../ui/registry/index.js";
13+
import { createUIResource } from "@mcp-ui/server";
1314

1415
export type ToolArgs<T extends ZodRawShape> = {
1516
[K in keyof T]: z.infer<T[K]>;
@@ -278,6 +279,37 @@ export abstract class ToolBase {
278279
*/
279280
protected abstract argsShape: ZodRawShape;
280281

282+
/**
283+
* Optional Zod schema defining the tool's structured output.
284+
*
285+
* When defined:
286+
* 1. The MCP SDK will validate `structuredContent` against this schema
287+
* 2. If the tool has a registered UI component, the base class will validate
288+
* `structuredContent` before creating a UIResource. If validation fails,
289+
* the UI is skipped and only the text result is returned.
290+
*
291+
* This enables a clean separation: tools define their output schema and return
292+
* structured data, while the base class handles validation and UI integration
293+
* automatically.
294+
*
295+
* @example
296+
* ```typescript
297+
* protected outputSchema = {
298+
* items: z.array(z.object({ name: z.string(), count: z.number() })),
299+
* totalCount: z.number(),
300+
* };
301+
*
302+
* protected async execute(): Promise<CallToolResult> {
303+
* const items = await this.fetchItems();
304+
* return {
305+
* content: [{ type: "text", text: `Found ${items.length} items` }],
306+
* structuredContent: { items, totalCount: items.length },
307+
* };
308+
* }
309+
* ```
310+
*/
311+
protected outputSchema?: ZodRawShape;
312+
281313
private registeredTool: RegisteredTool | undefined;
282314

283315
protected get annotations(): ToolAnnotations {
@@ -460,15 +492,17 @@ export abstract class ToolBase {
460492
});
461493

462494
const result = await this.execute(args, { signal });
463-
this.emitToolEvent(args, { startTime, result });
495+
const finalResult = this.appendUIResourceIfAvailable(result);
496+
497+
this.emitToolEvent(args, { startTime, result: finalResult });
464498

465499
this.session.logger.debug({
466500
id: LogId.toolExecute,
467501
context: "tool",
468502
message: `Executed tool ${this.name}`,
469503
noRedaction: true,
470504
});
471-
return result;
505+
return finalResult;
472506
} catch (error: unknown) {
473507
this.session.logger.error({
474508
id: LogId.toolExecuteFailure,
@@ -491,6 +525,7 @@ export abstract class ToolBase {
491525
config: {
492526
description?: string;
493527
inputSchema?: ZodRawShape;
528+
outputSchema?: ZodRawShape;
494529
annotations?: ToolAnnotations;
495530
},
496531
cb: (args: ToolArgs<ZodRawShape>, extra: ToolExecutionContext) => Promise<CallToolResult>
@@ -500,6 +535,7 @@ export abstract class ToolBase {
500535
{
501536
description: this.description,
502537
inputSchema: this.argsShape,
538+
outputSchema: this.outputSchema,
503539
annotations: this.annotations,
504540
},
505541
callback
@@ -694,6 +730,56 @@ export abstract class ToolBase {
694730
protected getUI(): string | undefined {
695731
return this.uiRegistry?.get(this.name);
696732
}
733+
734+
/**
735+
* Automatically appends a UIResource to the tool result if:
736+
* 1. The tool has a registered UI component
737+
* 2. The result contains `structuredContent`
738+
* 3. If `outputSchema` is defined, `structuredContent` must validate against it
739+
*
740+
* This method is called automatically after `execute()` in the `register()` callback.
741+
* Tools don't need to call this directly - they just need to return `structuredContent`
742+
* in their result and the base class handles the rest.
743+
*
744+
* @param result - The result from the tool's `execute()` method
745+
* @returns The result with UIResource appended if conditions are met, otherwise unchanged
746+
*/
747+
private appendUIResourceIfAvailable(result: CallToolResult): CallToolResult {
748+
const uiHtml = this.getUI();
749+
if (!uiHtml || !result.structuredContent) {
750+
return result;
751+
}
752+
753+
if (this.outputSchema) {
754+
const schema = z.object(this.outputSchema);
755+
const validation = schema.safeParse(result.structuredContent);
756+
if (!validation.success) {
757+
this.session.logger.warning({
758+
id: LogId.toolExecute,
759+
context: `tool - ${this.name}`,
760+
message: `structuredContent failed validation against outputSchema, skipping UI resource: ${validation.error.message}`,
761+
});
762+
return result;
763+
}
764+
}
765+
766+
const uiResource = createUIResource({
767+
uri: `ui://${this.name}/${Date.now()}`,
768+
content: {
769+
type: "rawHtml",
770+
htmlString: uiHtml,
771+
},
772+
encoding: "text",
773+
uiMetadata: {
774+
"initial-render-data": result.structuredContent,
775+
},
776+
});
777+
778+
return {
779+
...result,
780+
content: [...(result.content || []), uiResource],
781+
};
782+
}
697783
}
698784

699785
/**

src/ui/components/ListDatabases/ListDatabases.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,13 @@ import {
1111
TableHead,
1212
} from "@leafygreen-ui/table";
1313
import { tableStyles } from "./ListDatabases.styles.js";
14+
import { ListDatabasesOutputSchema, type ListDatabasesOutput } from "./schema.js";
1415

1516
const HeaderCell = LGHeaderCell as React.FC<React.ComponentPropsWithoutRef<"th">>;
1617
const Cell = LGCell as React.FC<React.ComponentPropsWithoutRef<"td">>;
1718
const Row = LGRow as React.FC<React.ComponentPropsWithoutRef<"tr">>;
1819

19-
const DatabaseInfoSchema = z.object({
20-
name: z.string(),
21-
size: z.number(),
22-
});
23-
24-
const ListDatabasesDataSchema = z.object({
25-
databases: z.array(DatabaseInfoSchema),
26-
totalCount: z.number(),
27-
});
28-
29-
type ListDatabasesRenderData = z.infer<typeof ListDatabasesDataSchema>;
20+
const ListDatabasesDataSchema = z.object(ListDatabasesOutputSchema);
3021

3122
function formatBytes(bytes: number): string {
3223
if (bytes === 0) return "0 Bytes";
@@ -39,7 +30,7 @@ function formatBytes(bytes: number): string {
3930
}
4031

4132
export const ListDatabases = () => {
42-
const { data, isLoading, error } = useRenderData<ListDatabasesRenderData>();
33+
const { data, isLoading, error } = useRenderData<ListDatabasesOutput>();
4334

4435
if (isLoading) {
4536
return <div>Loading...</div>;
@@ -49,7 +40,6 @@ export const ListDatabases = () => {
4940
return <div>Error: {error}</div>;
5041
}
5142

52-
// Validate props against schema
5343
const validationResult = ListDatabasesDataSchema.safeParse(data);
5444

5545
if (!validationResult.success) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { ListDatabases } from "./ListDatabases.js";
2+
export { ListDatabasesOutputSchema, type ListDatabasesOutput } from "./schema.js";
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Shared schema for the list-databases tool output.
5+
*
6+
* This schema is the single source of truth for the data contract between:
7+
* - The ListDatabasesTool (which returns structuredContent matching this schema)
8+
* - The ListDatabases UI component (which renders this data)
9+
*/
10+
export const ListDatabasesOutputSchema = {
11+
databases: z.array(
12+
z.object({
13+
name: z.string(),
14+
size: z.number(),
15+
})
16+
),
17+
totalCount: z.number(),
18+
};
19+
20+
/** Type derived from the output schema */
21+
export type ListDatabasesOutput = z.infer<z.ZodObject<typeof ListDatabasesOutputSchema>>;

0 commit comments

Comments
 (0)