From 635ac88f5ea5a67baaa326a6d593cad40a99fb3e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 19:37:56 +0200 Subject: [PATCH] docs(plan): add OpenAPI tool implementation plan --- docs/plans/2026-09-06-openapi-tool-plan.md | 678 +++++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 docs/plans/2026-09-06-openapi-tool-plan.md diff --git a/docs/plans/2026-09-06-openapi-tool-plan.md b/docs/plans/2026-09-06-openapi-tool-plan.md new file mode 100644 index 0000000..7e686d4 --- /dev/null +++ b/docs/plans/2026-09-06-openapi-tool-plan.md @@ -0,0 +1,678 @@ +# OpenAPI Introspection Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the `openproject_get_openapi_spec` MCP tool and supporting `OpenApiService` to enable dynamic OpenAPI 3.0 introspection with in-memory caching and token-efficient scoping (summary, path, tag, schema, full). + +**Architecture:** A domain service `getOpenApiSpec` fetches `GET /api/v3/openapi.json` using the ambient `OpenProjectClient`, caches the parsed ~1.18 MB document in memory by `baseUrl`, and filters/formats the response into compact JSON structures for LLM consumption. The tool definition in `src/tools/openapi.ts` registers `openproject_get_openapi_spec` with `readOnly: true`, plugged into `allTools`. + +**Tech Stack:** Bun (v1.3.14), TypeScript (strict mode), `@modelcontextprotocol/sdk`, `zod`, `bun:test`. + +**Spec:** `docs/specs/2026-09-06-openapi-tool-design.md` + +## Global Constraints + +- Runtime & Package Manager: Bun (>= 1.2 / 1.3), `bun test` for test execution. +- Strict TypeScript: `strict: true`, no `any` (use explicit types or `unknown` with type guards). +- Stateless execution & RequestContext: Resolve client using `resolveClient(client)` from `src/services/helper.ts`. +- In-memory caching: Cache parsed OpenAPI spec keyed by `client.baseUrl`, invalidated only when `refresh: true`. +- Stdio stream hygiene: All diagnostics and lifecycle logs use `console.error` (never `console.log`). +- Read-only compatibility: Tool marked `readOnly: true`, registered in both read-only and read-write modes. + +--- + +### Task 1: OpenAPI Domain Service & In-Memory Caching + +**Files:** +- Create: `src/services/openapi.ts` +- Modify: `src/services/index.ts` +- Create: `tests/openapi.test.ts` + +**Interfaces:** +- Consumes: `src/services/helper.ts` (`resolveClient`), `src/client/api-client.ts` (`OpenProjectClient`, `OpenProjectNotFoundError`) +- Produces: + ```typescript + export interface OpenApiQueryOptions { + path?: string; + tag?: string; + schema?: string; + full?: boolean; + refresh?: boolean; + } + + export interface OpenApiSummary { + title: string; + version: string; + openapi: string; + totalPaths: number; + tags: Array<{ name: string; endpointCount: number }>; + availablePaths: string[]; + instructions: string; + } + + export async function getOpenApiSpec( + options?: OpenApiQueryOptions, + client?: OpenProjectClient + ): Promise; + + export function clearOpenApiCache(): void; + ``` + +- [ ] **Step 1: Write failing service tests in `tests/openapi.test.ts`** + +```typescript +import { describe, expect, test, beforeEach } from "bun:test"; +import { + getOpenApiSpec, + clearOpenApiCache, + type OpenApiSummary, +} from "../src/services/openapi"; +import { OpenProjectClient } from "../src/client/api-client"; +import { OpenProjectNotFoundError } from "../src/client/errors"; + +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(); + getCallCount = 0; + }); + + test("summary mode returns overview and instructions when no filters passed", async () => { + const result = (await getOpenApiSpec({}, mockClient)) as OpenApiSummary; + expect(result.title).toBe("OpenProject API V3 (Test)"); + expect(result.version).toBe("3"); + expect(result.openapi).toBe("3.0.3"); + expect(result.totalPaths).toBe(2); + expect(result.tags).toHaveLength(2); + expect(result.tags[0]).toEqual({ name: "Projects", endpointCount: 1 }); + expect(result.tags[1]).toEqual({ name: "Work Packages", endpointCount: 1 }); + expect(result.availablePaths).toContain("/api/v3/work_packages"); + expect(result.instructions).toContain("Call openproject_get_openapi_spec"); + }); + + test("caches spec in memory and avoids duplicate HTTP GET calls", async () => { + await getOpenApiSpec({}, mockClient); + expect(getCallCount).toBe(1); + + await getOpenApiSpec({}, mockClient); + expect(getCallCount).toBe(1); // Served from cache + + await getOpenApiSpec({ refresh: true }, mockClient); + expect(getCallCount).toBe(2); // Evicted and re-fetched + }); + + test("path mode resolves exact and normalized path endpoints", async () => { + const res1 = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, mockClient)) as { + path: string; + operations: Record; + }; + expect(res1.path).toBe("/api/v3/work_packages"); + expect(res1.operations.get).toBeDefined(); + expect(res1.operations.post).toBeDefined(); + + // Normalization test without /api/v3 prefix + const res2 = (await getOpenApiSpec({ path: "work_packages" }, mockClient)) as { + path: string; + operations: Record; + }; + expect(res2.path).toBe("/api/v3/work_packages"); + }); + + test("path mode throws OpenProjectNotFoundError when path does not exist", async () => { + expect(getOpenApiSpec({ path: "/api/v3/nonexistent" }, mockClient)).rejects.toThrow( + OpenProjectNotFoundError + ); + }); + + test("tag mode filters paths by functional domain tag", async () => { + const result = (await getOpenApiSpec({ tag: "Work Packages" }, mockClient)) as { + tag: string; + totalPaths: number; + paths: Record; + }; + expect(result.tag).toBe("Work Packages"); + expect(result.totalPaths).toBe(1); + expect(result.paths["/api/v3/work_packages"]).toBeDefined(); + expect(result.paths["/api/v3/projects"]).toBeUndefined(); + }); + + test("tag mode throws OpenProjectNotFoundError when tag does not exist", async () => { + expect(getOpenApiSpec({ tag: "NonExistentTag" }, mockClient)).rejects.toThrow( + OpenProjectNotFoundError + ); + }); + + test("schema mode extracts component model from components.schemas", async () => { + const result = (await getOpenApiSpec({ schema: "WorkPackageModel" }, mockClient)) as { + schemaName: string; + schema: { type: string; properties: Record }; + }; + expect(result.schemaName).toBe("WorkPackageModel"); + expect(result.schema.type).toBe("object"); + expect(result.schema.properties.subject).toBeDefined(); + }); + + test("schema mode throws OpenProjectNotFoundError when schema does not exist", async () => { + expect(getOpenApiSpec({ schema: "UnknownModel" }, mockClient)).rejects.toThrow( + OpenProjectNotFoundError + ); + }); + + test("full mode returns the complete raw specification", async () => { + const result = (await getOpenApiSpec({ full: true }, mockClient)) as typeof mockSpec; + expect(result.openapi).toBe("3.0.3"); + expect(result.paths).toBeDefined(); + expect(result.components).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/openapi.test.ts` +Expected: FAIL (Cannot find module `../src/services/openapi`). + +- [ ] **Step 3: Implement `src/services/openapi.ts` and export from `src/services/index.ts`** + +In `src/services/openapi.ts`: +```typescript +import type { OpenProjectClient } from "../client/api-client"; +import { OpenProjectNotFoundError } from "../client/errors"; +import { resolveClient } from "./helper"; + +export interface OpenApiQueryOptions { + path?: string; + tag?: string; + schema?: string; + full?: boolean; + refresh?: boolean; +} + +export interface OpenApiSummary { + title: string; + version: string; + openapi: string; + totalPaths: number; + tags: Array<{ name: string; endpointCount: number }>; + availablePaths: string[]; + instructions: string; +} + +interface RawOpenApiDoc { + openapi: string; + info: { title: string; version: string; description?: string }; + paths: Record>; + components?: { + schemas?: Record; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +// In-memory cache keyed by baseUrl +const openApiCache = new Map(); + +export function clearOpenApiCache(): void { + openApiCache.clear(); +} + +/** + * Normalizes an API path for flexible querying. + */ +function normalizePath(path: string): string { + let clean = path.trim(); + if (!clean.startsWith("/")) { + clean = `/${clean}`; + } + if (!clean.startsWith("/api/v3")) { + clean = `/api/v3${clean}`; + } + return clean.toLowerCase(); +} + +/** + * Fetches and filters OpenProject OpenAPI 3.0 specification. + */ +export async function getOpenApiSpec( + options?: OpenApiQueryOptions, + client?: OpenProjectClient +): Promise { + const activeClient = resolveClient(client); + const cacheKey = activeClient.baseUrl; + + if (options?.refresh) { + openApiCache.delete(cacheKey); + } + + let doc = openApiCache.get(cacheKey); + if (!doc) { + doc = await activeClient.get("openapi.json"); + openApiCache.set(cacheKey, doc); + } + + // 1. Full mode + if (options?.full) { + return doc; + } + + const allPaths = Object.keys(doc.paths || {}); + + // 2. Specific path lookup + if (options?.path) { + const target = normalizePath(options.path); + const matchedKey = allPaths.find((p) => p.toLowerCase() === target) || + allPaths.find((p) => p.toLowerCase().endsWith(target.replace(/^\/api\/v3/, ""))); + + if (!matchedKey || !doc.paths[matchedKey]) { + const sample = allPaths.slice(0, 10).join(", "); + throw new OpenProjectNotFoundError( + `OpenAPI path '${options.path}' not found. Available paths include: ${sample}...`, + { errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" } + ); + } + + return { + path: matchedKey, + operations: doc.paths[matchedKey], + }; + } + + // 3. Tag filtering + if (options?.tag) { + const targetTag = options.tag.trim().toLowerCase(); + const matchedPaths: Record = {}; + + for (const [pathKey, methods] of Object.entries(doc.paths || {})) { + let pathMatched = false; + for (const op of Object.values(methods)) { + if (op && Array.isArray(op.tags) && op.tags.some((t: string) => t.toLowerCase() === targetTag)) { + pathMatched = true; + break; + } + } + if (pathMatched) { + matchedPaths[pathKey] = methods; + } + } + + const matchedCount = Object.keys(matchedPaths).length; + if (matchedCount === 0) { + const availableTags = Array.from( + new Set( + Object.values(doc.paths || {}).flatMap((methods) => + Object.values(methods).flatMap((op) => (Array.isArray(op?.tags) ? op.tags : [])) + ) + ) + ).sort(); + throw new OpenProjectNotFoundError( + `OpenAPI tag '${options.tag}' not found. Available tags include: ${availableTags.slice(0, 15).join(", ")}...`, + { errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" } + ); + } + + return { + tag: options.tag, + totalPaths: matchedCount, + paths: matchedPaths, + }; + } + + // 4. Schema model lookup + if (options?.schema) { + const targetSchema = options.schema.trim().toLowerCase(); + const schemas = doc.components?.schemas || {}; + const matchedKey = Object.keys(schemas).find((s) => s.toLowerCase() === targetSchema); + + if (!matchedKey || !schemas[matchedKey]) { + const availableSchemas = Object.keys(schemas).slice(0, 15).join(", "); + throw new OpenProjectNotFoundError( + `OpenAPI schema '${options.schema}' not found in components.schemas. Available schemas include: ${availableSchemas}...`, + { errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" } + ); + } + + return { + schemaName: matchedKey, + schema: schemas[matchedKey], + }; + } + + // 5. Default: Summary Overview + const tagCounts = new Map(); + for (const methods of Object.values(doc.paths || {})) { + const seenInPath = new Set(); + for (const op of Object.values(methods)) { + if (op && Array.isArray(op.tags)) { + for (const t of op.tags) { + seenInPath.add(t); + } + } + } + for (const t of seenInPath) { + tagCounts.set(t, (tagCounts.get(t) || 0) + 1); + } + } + + const sortedTags = Array.from(tagCounts.entries()) + .map(([name, endpointCount]) => ({ name, endpointCount })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const summary: OpenApiSummary = { + title: doc.info?.title || "OpenProject API V3", + version: doc.info?.version || "3", + openapi: doc.openapi || "3.0.0", + totalPaths: allPaths.length, + tags: sortedTags, + availablePaths: allPaths, + instructions: + "Call openproject_get_openapi_spec with 'path' (e.g. '/api/v3/work_packages'), 'tag' (e.g. 'Work Packages'), or 'schema' (e.g. 'WorkPackageModel') to inspect detailed schemas and operations without requesting the full payload.", + }; + + return summary; +} +``` + +In `src/services/index.ts`: +Export `getOpenApiSpec`, `clearOpenApiCache`, `OpenApiQueryOptions`, `OpenApiSummary` from `./openapi`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/openapi.test.ts` +Expected: PASS (9 pass). + +- [ ] **Step 5: Commit** + +```bash +git add src/services/openapi.ts src/services/index.ts tests/openapi.test.ts +git commit -m "feat(services): implement OpenAPI specification service and in-memory caching" +``` + +--- + +### Task 2: MCP Tool Definition, Schema & Registration + +**Files:** +- Create: `src/tools/openapi.ts` +- Modify: `src/tools/index.ts` +- Modify: `tests/openapi.test.ts` + +**Interfaces:** +- Consumes: `src/services/openapi.ts` (`getOpenApiSpec`), `src/tools/common.ts` (`formatToolSuccess`, `formatToolError`, `ToolDefinition`) +- Produces: + ```typescript + export const getOpenApiSpecSchema: ZodRawShape; + export const getOpenApiSpecTool: ToolDefinition; + export const openApiTools: ToolDefinition[]; + export function registerOpenApiTools(server: McpServer, options?: RegisterToolOptions): void; + ``` + +- [ ] **Step 1: Add tool tests to `tests/openapi.test.ts`** + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { runWithContext } from "../src/context"; +import { + getOpenApiSpecTool, + registerOpenApiTools, +} from "../src/tools/openapi"; +import { allTools } from "../src/tools"; + +describe("OpenApi MCP Tool Registration & Execution", () => { + 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("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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/openapi.test.ts` +Expected: FAIL (Cannot find module `../src/tools/openapi`). + +- [ ] **Step 3: Implement `src/tools/openapi.ts` and update `src/tools/index.ts`** + +In `src/tools/openapi.ts`: +```typescript +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 const getOpenApiSpecTool: ToolDefinition = { + 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) => { + try { + const data = await getOpenApiSpec(args); + return formatToolSuccess(data); + } catch (error) { + return formatToolError(error); + } + }, +}; + +export const openApiTools: ToolDefinition[] = [ + getOpenApiSpecTool as unknown as ToolDefinition, +]; + +export function registerOpenApiTools( + server: McpServer, + options?: RegisterToolOptions +): void { + for (const tool of openApiTools) { + registerTool(server, tool, options); + } +} +``` + +In `src/tools/index.ts`: +Import `openApiTools` and `registerOpenApiTools` from `./openapi`. +Add `...openApiTools` to `allTools`. +Call `registerOpenApiTools(server, options)` in `registerAllTools`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/openapi.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/openapi.ts src/tools/index.ts tests/openapi.test.ts +git commit -m "feat(tools): add openproject_get_openapi_spec tool definition and registration" +``` + +--- + +### Task 3: Live Container Integration, Suite Regression Updates & Roadmap Checkoff + +**Files:** +- Modify: `tests/openapi.test.ts` +- Modify: `tests/tools.test.ts` +- Modify: `tests/read-only.test.ts` +- Modify: `tests/mcp-server.test.ts` +- Modify: `docs/TODO.md` + +**Interfaces:** +- Consumes: Live OpenProject 17 container on `http://localhost:8080`, `src/server.ts`, `allTools` +- Produces: Updated test suites expecting 11 tools, end-to-end verified live OpenAPI querying, and updated project tracking. + +- [ ] **Step 1: Add live container integration test to `tests/openapi.test.ts`** + +```typescript +describe("Live Container OpenAPI Integration", () => { + const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080"; + const apiKey = process.env.OPENPROJECT_API_KEY || ""; + + test("live: getOpenApiSpec returns live OpenProject 17 summary", async () => { + const liveClient = new OpenProjectClient({ baseUrl, apiKey }); + const summary = (await getOpenApiSpec({}, liveClient)) as OpenApiSummary; + + expect(summary.title).toContain("OpenProject API"); + expect(summary.totalPaths).toBeGreaterThan(200); + expect(summary.tags.length).toBeGreaterThan(30); + expect(summary.availablePaths).toContain("/api/v3/work_packages"); + }); + + test("live: getOpenApiSpec filters by path /api/v3/work_packages", async () => { + const liveClient = new OpenProjectClient({ baseUrl, apiKey }); + const result = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, liveClient)) as { + path: string; + operations: Record }>; + }; + + expect(result.path).toBe("/api/v3/work_packages"); + expect(result.operations.get).toBeDefined(); + const paramNames = result.operations.get?.parameters?.map((p) => p.name); + expect(paramNames).toContain("offset"); + expect(paramNames).toContain("pageSize"); + }); +}); +``` + +- [ ] **Step 2: Update tool count expectations in existing tests** + +- In `tests/tools.test.ts`: + - `allTools contains exactly 10 Phase 1 tools` -> update to `allTools contains exactly 11 tools including OpenAPI spec` and `expect(allTools).toHaveLength(11)`. + - `registerAllTools registers all 10 tools on McpServer` -> update length expectation from 10 to 11. + - `registerAllTools filters out non-readOnly tools when readOnly is true` -> update length expectation from 10 to 11. +- In `tests/read-only.test.ts`: + - `read-only server lists all 10 Phase 1 tools` -> update expectation from 10 to 11. + - `read-only mode filters out mutating tools from tools/list` -> update expectation from 10 to 11. +- In `tests/mcp-server.test.ts`: + - `read-only mode filters tools and maintains read-only status in context` -> update length expectation from 10 to 11. + +- [ ] **Step 3: Run full test suite** + +Run: `bun test` +Expected: All tests pass cleanly across all test files (`smoke`, `client`, `services`, `tools`, `mcp-server`, `read-only`, `openapi`). + +- [ ] **Step 4: Update `docs/TODO.md`** + +Add `openproject_get_openapi_spec` tool under completed roadmap and record OpenAPI tool implementation. + +- [ ] **Step 5: Commit** + +```bash +git add tests/openapi.test.ts tests/tools.test.ts tests/read-only.test.ts tests/mcp-server.test.ts docs/TODO.md +git commit -m "test(openapi): add live container tests, update tool count assertions, and update TODO.md" +```