1- import type { z , ZodRawShape } from "zod" ;
1+ import { z , type ZodRawShape } from "zod" ;
22import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js" ;
33import type { CallToolResult , ToolAnnotations } from "@modelcontextprotocol/sdk/types.js" ;
44import type { Session } from "../common/session.js" ;
@@ -10,6 +10,7 @@ import type { Server } from "../server.js";
1010import type { Elicitation } from "../elicitation.js" ;
1111import type { PreviewFeature } from "../common/schemas.js" ;
1212import type { UIRegistry } from "../ui/registry/index.js" ;
13+ import { createUIResource } from "@mcp-ui/server" ;
1314
1415export 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/**
0 commit comments