From bb321bfdbe204e7225ef702f90d7f8792040d140 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 00:48:25 +0200 Subject: [PATCH] feat(tools): add common tool utilities, types, and error formatting --- src/tools/common.ts | 77 +++++++++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/tools/common.ts create mode 100644 tests/tools.test.ts diff --git a/src/tools/common.ts b/src/tools/common.ts new file mode 100644 index 0000000..123d33b --- /dev/null +++ b/src/tools/common.ts @@ -0,0 +1,77 @@ +/** + * Common Model Context Protocol (MCP) Tool Utilities and Types. + */ + +import type { ZodRawShape } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { OpenProjectError } from "../client/errors"; + +export interface McpToolResponse { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +} + +/** + * Formats data into a standard MCP tool success text response. + */ +export function formatToolSuccess(data: unknown): McpToolResponse { + return { + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +/** + * Formats an error into a standard MCP tool error text response with error code. + */ +export function formatToolError(error: unknown): McpToolResponse { + let message: string; + + if (error instanceof OpenProjectError) { + message = `Error [${error.code}]: ${error.message}`; + } else if (error instanceof Error) { + message = `Error: ${error.message}`; + } else { + message = `Error: ${String(error)}`; + } + + return { + content: [ + { + type: "text", + text: message, + }, + ], + isError: true, + }; +} + +/** + * Definition interface for OpenProject MCP tools. + */ +export interface ToolDefinition { + name: string; + description: string; + parameters?: TShape; + readOnly: boolean; + execute: (args: any) => Promise; +} + +/** + * Registers a ToolDefinition instance on an McpServer instance. + */ +export function registerTool(server: McpServer, tool: ToolDefinition): void { + if (tool.parameters) { + server.tool(tool.name, tool.description, tool.parameters, async (args: any) => { + return tool.execute(args); + }); + } else { + server.tool(tool.name, tool.description, async () => { + return tool.execute({}); + }); + } +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts new file mode 100644 index 0000000..0ecf039 --- /dev/null +++ b/tests/tools.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { + formatToolSuccess, + formatToolError, + registerTool, + type ToolDefinition, +} from "../src/tools/common"; +import { OpenProjectNotFoundError } from "../src/client/errors"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +describe("Tool Utilities", () => { + test("formatToolSuccess formats data into MCP text response", () => { + const data = { id: 1, name: "Test" }; + const response = formatToolSuccess(data); + expect(response.isError).toBeUndefined(); + expect(response.content).toHaveLength(1); + expect(response.content[0]?.type).toBe("text"); + expect(JSON.parse(response.content[0]?.text ?? "{}")).toEqual(data); + }); + + test("formatToolError formats error into MCP isError response", () => { + const error = new OpenProjectNotFoundError("Project 99 not found"); + const response = formatToolError(error); + expect(response.isError).toBe(true); + expect(response.content).toHaveLength(1); + expect(response.content[0]?.type).toBe("text"); + expect(response.content[0]?.text).toContain("Project 99 not found"); + }); + + test("formatToolError handles non-Error unknown values", () => { + const response = formatToolError("unexpected failure string"); + expect(response.isError).toBe(true); + expect(response.content).toHaveLength(1); + expect(response.content[0]?.type).toBe("text"); + expect(response.content[0]?.text).toContain("unexpected failure string"); + }); + + test("registerTool registers tool with parameters on McpServer", () => { + const server = new McpServer({ name: "test-server", version: "1.0.0" }); + const tool: ToolDefinition<{ id: z.ZodNumber }> = { + name: "test_tool_with_params", + description: "A test tool with parameters", + parameters: { id: z.number() }, + readOnly: true, + execute: async (args: { id: number }) => formatToolSuccess({ doubled: args.id * 2 }), + }; + + registerTool(server, tool); + expect(tool.name).toBe("test_tool_with_params"); + }); + + test("registerTool registers parameterless tool on McpServer", () => { + const server = new McpServer({ name: "test-server", version: "1.0.0" }); + const tool: ToolDefinition = { + name: "test_tool_no_params", + description: "A test tool without parameters", + readOnly: true, + execute: async () => formatToolSuccess({ ok: true }), + }; + + registerTool(server, tool); + expect(tool.name).toBe("test_tool_no_params"); + }); +});