Improve handling of tool responses, adding explicit classes

This commit is contained in:
Dominik Jain 2025-09-11 13:55:33 +02:00
parent f5bdb1accb
commit e714caaef2
5 changed files with 47 additions and 49 deletions

View File

@ -19,7 +19,7 @@
*/ */
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { Tool } from "./interfaces/Tool.js"; import { Tool } from "./interfaces/Tool.js";
@ -100,7 +100,7 @@ class PenpotMcpServer {
}; };
}); });
this.server.setRequestHandler(CallToolRequestSchema, async (request) => { this.server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args } = request.params; const { name, arguments: args } = request.params;
const tool = this.tools.get(name); const tool = this.tools.get(name);

View File

@ -2,6 +2,7 @@ import { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";
import { validate, ValidationError } from "class-validator"; import { validate, ValidationError } from "class-validator";
import { plainToClass } from "class-transformer"; import { plainToClass } from "class-transformer";
import "reflect-metadata"; import "reflect-metadata";
import { ToolResponse } from "./ToolResponse";
/** /**
* Defines the contract for MCP tool implementations. * Defines the contract for MCP tool implementations.
@ -21,7 +22,7 @@ export interface Tool {
* @param args - The arguments passed to the tool (validated by implementation) * @param args - The arguments passed to the tool (validated by implementation)
* @returns A promise that resolves to the tool's execution result * @returns A promise that resolves to the tool's execution result
*/ */
execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }>; execute(args: unknown): Promise<ToolResponse>;
} }
/** /**
@ -66,7 +67,7 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
* This method handles the unknown args from the MCP protocol, * This method handles the unknown args from the MCP protocol,
* validates them, and delegates to the type-safe implementation. * validates them, and delegates to the type-safe implementation.
*/ */
async execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }> { async execute(args: unknown): Promise<ToolResponse> {
try { try {
// Transform plain object to class instance // Transform plain object to class instance
const argsInstance = plainToClass(this.ArgsClass, args as object); const argsInstance = plainToClass(this.ArgsClass, args as object);
@ -207,5 +208,5 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
* *
* @param args - The validated, strongly-typed arguments * @param args - The validated, strongly-typed arguments
*/ */
protected abstract executeTypeSafe(args: TArgs): Promise<{ content: Array<{ type: string; text: string }> }>; protected abstract executeTypeSafe(args: TArgs): Promise<ToolResponse>;
} }

View File

@ -0,0 +1,20 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
type CallToolContent = CallToolResult["content"][number];
type TextItem = Extract<CallToolContent, { type: "text" }>;
class TextContent implements TextItem {
[x: string]: unknown;
readonly type = "text" as const;
constructor(public text: string) {}
}
export class TextResponse implements CallToolResult {
[x: string]: unknown;
content: CallToolContent[]; // <- IMPORTANT: protocols union
constructor(text: string) {
this.content = [new TextContent(text)];
}
}
export type ToolResponse = TextResponse;

View File

@ -1,6 +1,8 @@
import { IsString, IsNotEmpty } from "class-validator"; import { IsNotEmpty, IsString } from "class-validator";
import { TypeSafeTool } from "../interfaces/Tool.js"; import { TypeSafeTool } from "../interfaces/Tool.js";
import "reflect-metadata"; import "reflect-metadata";
import type { ToolResponse } from "../interfaces/ToolResponse.js";
import { TextResponse } from "../interfaces/ToolResponse.js";
/** /**
* Arguments class for the HelloWorld tool with validation decorators. * Arguments class for the HelloWorld tool with validation decorators.
@ -41,14 +43,9 @@ export class HelloWorldTool extends TypeSafeTool<HelloWorldArgs> {
* *
* @param args - The validated HelloWorldArgs instance * @param args - The validated HelloWorldArgs instance
*/ */
protected async executeTypeSafe(args: HelloWorldArgs): Promise<{ content: Array<{ type: string; text: string }> }> { protected async executeTypeSafe(args: HelloWorldArgs): Promise<ToolResponse> {
return { return new TextResponse(
content: [ `Hello, ${args.name}! This greeting was generated with full type safety and automatic validation.`
{ );
type: "text",
text: `Hello, ${args.name}! This greeting was generated with full type safety and automatic validation.`,
},
],
};
} }
} }

View File

@ -1,6 +1,8 @@
import { IsString, IsNotEmpty } from "class-validator"; import { IsNotEmpty, IsString } from "class-validator";
import { TypeSafeTool } from "../interfaces/Tool.js"; import { TypeSafeTool } from "../interfaces/Tool.js";
import { PluginTaskPrintText, PluginTaskPrintTextParams } from "../interfaces/PluginTask.js"; import { PluginTaskPrintText, PluginTaskPrintTextParams } from "../interfaces/PluginTask.js";
import type { ToolResponse } from "../interfaces/ToolResponse.js";
import { TextResponse } from "../interfaces/ToolResponse.js";
import "reflect-metadata"; import "reflect-metadata";
/** /**
@ -50,7 +52,7 @@ export class ToolPrintText extends TypeSafeTool<PrintTextArgs> {
* *
* @param args - The validated PrintTextArgs instance * @param args - The validated PrintTextArgs instance
*/ */
protected async executeTypeSafe(args: PrintTextArgs): Promise<{ content: Array<{ type: string; text: string }> }> { protected async executeTypeSafe(args: PrintTextArgs): Promise<ToolResponse> {
try { try {
// Create the plugin task // Create the plugin task
const taskParams = new PluginTaskPrintTextParams(args.text); const taskParams = new PluginTaskPrintTextParams(args.text);
@ -58,14 +60,9 @@ export class ToolPrintText extends TypeSafeTool<PrintTextArgs> {
// Check if there are connected clients // Check if there are connected clients
if (this.connectedClients.size === 0) { if (this.connectedClients.size === 0) {
return { return new TextResponse(
content: [ `No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`
{ );
type: "text",
text: `No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`,
},
],
};
} }
// Send task to all connected clients // Send task to all connected clients
@ -81,34 +78,17 @@ export class ToolPrintText extends TypeSafeTool<PrintTextArgs> {
}); });
if (sentCount === 0) { if (sentCount === 0) {
return { return new TextResponse(
content: [ `All connected plugin instances appear to be disconnected. No text was created.`
{ );
type: "text",
text: `All connected plugin instances appear to be disconnected. No text was created.`,
},
],
};
} }
return { return new TextResponse(
content: [ `Successfully sent text creation task to ${sentCount} connected plugin instance(s). Text "${args.text}" should now appear in Penpot.`
{ );
type: "text",
text: `Successfully sent text creation task to ${sentCount} connected plugin instance(s). Text "${args.text}" should now appear in Penpot.`,
},
],
};
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"; const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { return new TextResponse(`Failed to create text in Penpot: ${errorMessage}`);
content: [
{
type: "text",
text: `Failed to create text in Penpot: ${errorMessage}`,
},
],
};
} }
} }
} }