diff --git a/src/tools/index.ts b/src/tools/index.ts index a86d84f..00e26eb 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -13,24 +13,28 @@ import { projectTools } from "./projects"; import { workPackageTools } from "./work-packages"; import { queryTools } from "./queries"; import { metadataTools } from "./metadata"; +import { openApiTools } from "./openapi"; export * from "./common"; export * from "./projects"; export * from "./work-packages"; export * from "./queries"; export * from "./metadata"; +export * from "./openapi"; export type AnyToolDefinition = | (typeof projectTools)[number] | (typeof workPackageTools)[number] | (typeof queryTools)[number] - | (typeof metadataTools)[number]; + | (typeof metadataTools)[number] + | (typeof openApiTools)[number]; export const allTools: AnyToolDefinition[] = [ ...projectTools, ...workPackageTools, ...queryTools, ...metadataTools, + ...openApiTools, ]; export interface RegisterToolsOptions { diff --git a/src/tools/openapi.ts b/src/tools/openapi.ts new file mode 100644 index 0000000..80bacba --- /dev/null +++ b/src/tools/openapi.ts @@ -0,0 +1,89 @@ +/** + * OpenAPI Tools for OpenProject MCP. + * Implements openproject_get_openapi_spec tool for dynamic API introspection. + */ + +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { getOpenApiSpec } from "../services/openapi"; +import { + formatToolSuccess, + formatToolError, + registerTool, + type ToolDefinition, + type RegisterToolOptions, +} from "./common"; + +export const getOpenApiSpecSchema = { + path: z + .string() + .optional() + .describe( + "Specific endpoint path to inspect (e.g. '/api/v3/work_packages' or 'projects')." + ), + tag: z + .string() + .optional() + .describe( + "Tag or category to filter endpoints by (e.g. 'Work Packages', 'Queries', 'Projects')." + ), + schema: z + .string() + .optional() + .describe( + "Component schema model name to inspect (e.g. 'WorkPackageModel')." + ), + full: z + .boolean() + .optional() + .default(false) + .describe( + "Whether to return the complete raw OpenAPI 3.0 document. Warning: very large (~1.18 MB)." + ), + refresh: z + .boolean() + .optional() + .default(false) + .describe( + "Whether to bypass the in-memory cache and fetch a fresh specification from the server." + ), +}; + +export type GetOpenApiSpecArgs = z.infer< + z.ZodObject +>; + +export const getOpenApiSpecTool: ToolDefinition< + typeof getOpenApiSpecSchema, + GetOpenApiSpecArgs +> = { + name: "openproject_get_openapi_spec", + description: + "Retrieve OpenProject API v3 OpenAPI 3.0 specification (/api/v3/openapi.json) for dynamic endpoint discovery, parameter schemas, and entity models. Supports smart filtering by path, tag, or schema, or returns a token-efficient summary overview when called without parameters.", + readOnly: true, + parameters: getOpenApiSpecSchema, + execute: async (args: GetOpenApiSpecArgs) => { + try { + const data = await getOpenApiSpec(args); + return formatToolSuccess(data); + } catch (error) { + return formatToolError(error); + } + }, +}; + +export const openApiTools: ToolDefinition[] = [ + getOpenApiSpecTool as unknown as ToolDefinition, +]; + +/** + * Registers all OpenAPI tools with an McpServer instance. + */ +export function registerOpenApiTools( + server: McpServer, + options?: RegisterToolOptions +): void { + for (const tool of openApiTools) { + registerTool(server, tool, options); + } +} diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index ad230af..e101f99 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test, beforeEach } from "bun:test"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { getOpenApiSpec, clearOpenApiCache, @@ -7,57 +10,63 @@ import { import { OpenProjectClient } from "../src/client/api-client.ts"; import { OpenProjectNotFoundError } from "../src/client/errors.ts"; import { runWithContext } from "../src/context.ts"; +import { + getOpenApiSpecTool, + registerOpenApiTools, +} from "../src/tools/openapi.ts"; +import { allTools } from "../src/tools/index.ts"; + +const mockSpec = { + openapi: "3.0.3", + info: { title: "OpenProject API V3 (Test)", version: "3" }, + paths: { + "/api/v3/work_packages": { + get: { + summary: "List work packages", + operationId: "list_work_packages", + tags: ["Work Packages"], + parameters: [{ name: "offset", in: "query" }], + responses: { "200": { description: "OK" } }, + }, + post: { + summary: "Create work package", + operationId: "create_work_package", + tags: ["Work Packages"], + responses: { "201": { description: "Created" } }, + }, + }, + "/api/v3/projects": { + get: { + summary: "List projects", + operationId: "list_projects", + tags: ["Projects"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + components: { + schemas: { + WorkPackageModel: { + type: "object", + properties: { id: { type: "integer" }, subject: { type: "string" } }, + }, + }, + }, +}; + +let getCallCount = 0; +const mockClient = { + baseUrl: "http://mock-openproject.local", + get: async (path: string) => { + getCallCount++; + if (path.includes("openapi.json")) { + return mockSpec; + } + throw new Error("Not found"); + }, +} as unknown as OpenProjectClient; describe("OpenApi Service", () => { - const mockSpec = { - openapi: "3.0.3", - info: { title: "OpenProject API V3 (Test)", version: "3" }, - paths: { - "/api/v3/work_packages": { - get: { - summary: "List work packages", - operationId: "list_work_packages", - tags: ["Work Packages"], - parameters: [{ name: "offset", in: "query" }], - responses: { "200": { description: "OK" } }, - }, - post: { - summary: "Create work package", - operationId: "create_work_package", - tags: ["Work Packages"], - responses: { "201": { description: "Created" } }, - }, - }, - "/api/v3/projects": { - get: { - summary: "List projects", - operationId: "list_projects", - tags: ["Projects"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - components: { - schemas: { - WorkPackageModel: { - type: "object", - properties: { id: { type: "integer" }, subject: { type: "string" } }, - }, - }, - }, - }; - - let getCallCount = 0; - const mockClient = { - baseUrl: "http://mock-openproject.local", - get: async (path: string) => { - getCallCount++; - if (path.includes("openapi.json")) { - return mockSpec; - } - throw new Error("Not found"); - }, - } as unknown as OpenProjectClient; beforeEach(() => { clearOpenApiCache(); @@ -159,3 +168,73 @@ describe("OpenApi Service", () => { ); }); }); + +describe("OpenApi MCP Tool Registration & Execution", () => { + beforeEach(() => { + clearOpenApiCache(); + getCallCount = 0; + }); + + test("getOpenApiSpecTool is marked readOnly: true and exposes proper schema", () => { + expect(getOpenApiSpecTool.name).toBe("openproject_get_openapi_spec"); + expect(getOpenApiSpecTool.readOnly).toBe(true); + expect(getOpenApiSpecTool.parameters).toBeDefined(); + }); + + test("executes openproject_get_openapi_spec tool via MCP Client inside RequestContext", async () => { + const server = new McpServer({ name: "openapi-test", version: "1.0.0" }); + registerOpenApiTools(server); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + const client = new Client({ name: "client", version: "1.0.0" }); + await client.connect(clientTransport); + + const res = (await runWithContext({ client: mockClient, isReadOnly: true }, async () => { + return client.callTool({ + name: "openproject_get_openapi_spec", + arguments: { tag: "Work Packages" }, + }); + })) as { content: Array<{ type: string; text: string }>; isError?: boolean }; + + expect(res.isError).toBeFalsy(); + const data = JSON.parse(res.content[0]?.text ?? "{}"); + expect(data.tag).toBe("Work Packages"); + expect(data.totalPaths).toBe(1); + + await client.close(); + await server.close(); + }); + + test("executes openproject_get_openapi_spec tool handling error when target not found", async () => { + const server = new McpServer({ name: "openapi-test", version: "1.0.0" }); + registerOpenApiTools(server); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + const client = new Client({ name: "client", version: "1.0.0" }); + await client.connect(clientTransport); + + const res = (await runWithContext({ client: mockClient, isReadOnly: true }, async () => { + return client.callTool({ + name: "openproject_get_openapi_spec", + arguments: { path: "/api/v3/nonexistent" }, + }); + })) as { content: Array<{ type: string; text: string }>; isError?: boolean }; + + expect(res.isError).toBe(true); + expect(res.content[0]?.text).toContain("OpenAPI path '/api/v3/nonexistent' not found"); + + await client.close(); + await server.close(); + }); + + test("allTools includes openproject_get_openapi_spec (total: 11 tools)", () => { + const toolNames = allTools.map((t) => t.name); + expect(toolNames).toContain("openproject_get_openapi_spec"); + expect(allTools).toHaveLength(11); + }); +}); +