Files
openproject-mcp/docs/plans/2026-09-06-openapi-tool-plan.md
T

23 KiB

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:

    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<unknown>;
    
    export function clearOpenApiCache(): void;
    
  • Step 1: Write failing service tests in tests/openapi.test.ts

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<string, unknown>;
    };
    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<string, unknown>;
    };
    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<string, unknown>;
    };
    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<string, unknown> };
    };
    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:

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<string, Record<string, { tags?: string[]; summary?: string; operationId?: string; [key: string]: unknown }>>;
  components?: {
    schemas?: Record<string, unknown>;
    [key: string]: unknown;
  };
  [key: string]: unknown;
}

// In-memory cache keyed by baseUrl
const openApiCache = new Map<string, RawOpenApiDoc>();

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<unknown> {
  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<RawOpenApiDoc>("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<string, unknown> = {};

    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<string, number>();
  for (const methods of Object.values(doc.paths || {})) {
    const seenInPath = new Set<string>();
    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
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:

    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

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:

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<typeof getOpenApiSpecSchema> = {
  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
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

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<string, { summary?: string; parameters?: Array<{ name: string }> }>;
    };

    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
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"