feat(tools): add openproject_get_openapi_spec tool definition and registration

This commit is contained in:
Lucas Winther
2026-09-06 19:56:34 +02:00
parent 1039a6aa7a
commit a5f4dcc2ca
3 changed files with 222 additions and 50 deletions
+5 -1
View File
@@ -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 {
+89
View File
@@ -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<typeof getOpenApiSpecSchema>
>;
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);
}
}
+128 -49
View File
@@ -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);
});
});