From 75531eca2dc8eada910840ee26196b740debad53 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 7 Sep 2026 01:38:32 +0200 Subject: [PATCH] feat(http): add OpenAPI 3.1.0 specification and REST tool execution endpoint --- README.md | 48 ++++++++--- docs/ARCHITECTURE.md | 3 +- docs/DECISIONS.md | 31 ++++++++ docs/TODO.md | 8 ++ src/http-server.ts | 147 +++++++++++++++++++++++++++++++++- src/openapi-spec.ts | 163 ++++++++++++++++++++++++++++++++++++++ tests/http-server.test.ts | 122 ++++++++++++++++++++++++++++ 7 files changed, 509 insertions(+), 13 deletions(-) create mode 100644 src/openapi-spec.ts diff --git a/README.md b/README.md index 7635afc..87351c1 100644 --- a/README.md +++ b/README.md @@ -274,17 +274,45 @@ In `claude_desktop_config.json` or clients connecting directly via SSE URL: } ``` -#### Open WebUI (Streamable HTTP) -In Open WebUI (**Admin Panel > Settings > External Tools** or **User Settings > Tools**): -1. Click **+** (Add Connection). -2. Select **Type**: `MCP (Streamable HTTP)`. -3. Set **URL**: - - In Docker Compose on the same network: `http://openproject-mcp:3000/mcp` (or `http://openproject-mcp:3000/sse`) +### Open WebUI + +Open WebUI supports two connection modes for `openproject-mcp`: + +#### Option 1: MCP (Streamable HTTP) — Recommended +1. Navigate to **Admin Panel > Settings > External Tools** (or **User Settings > Tools**). +2. Click **+** (Add Connection). +3. Select **Type**: `MCP (Streamable HTTP)`. +4. Set **URL**: + - In Docker Compose on shared network: `http://openproject-mcp:3000/mcp` (or `http://openproject-mcp:3000/sse`) - From host machine: `http://localhost:3000/mcp` -4. Set **API Key / Auth Header** (optional): - - If `OPENPROJECT_API_KEY` is configured in the server container environment, no key is needed in the client. - - For multi-user environments, pass `Bearer ` or append `?apiKey=`. -5. Click **Verify** to test connectivity and discover tools. +5. Set **Auth** (optional): `Bearer ` if multi-user, or leave blank if `OPENPROJECT_API_KEY` is configured in the container. +6. Click **Verify Connection**. + +#### Option 2: OpenAPI Mode +If your Open WebUI installation connects to tool servers using OpenAPI: +1. Click **+** (Add Connection). +2. Select **Type**: `OpenAPI`. +3. Set **URL**: + - In Docker Compose on shared network: `http://openproject-mcp:3000/openapi.json` + - From host machine: `http://localhost:3000/openapi.json` +4. Set **Auth Header** (optional): `Bearer `. +5. Click **Verify Connection**. + +--- + +### Hosted Server Endpoints Reference + +When running `openproject-mcp` as an HTTP server (`PORT=3000`), the following endpoints are exposed: + +| Endpoint | Method | Protocol / Client | Description | +| :--- | :--- | :--- | :--- | +| `/mcp` | POST, GET, DELETE | MCP Streamable HTTP | Primary endpoint for Open WebUI, Python MCP SDK, and modern Streamable HTTP clients | +| `/sse` | GET, POST, DELETE | MCP SSE & Streamable HTTP | Dual-purpose: `GET` establishes classic SSE stream (Cursor, Claude Desktop); `POST` handles Streamable HTTP | +| `/messages` | POST | Classic MCP SSE | Message posting endpoint for established SSE sessions (`?sessionId=...`) | +| `/openapi.json` | GET | OpenAPI 3.1.0 | OpenAPI specification for Open WebUI (OpenAPI mode), Swagger UI, and REST integrations | +| `/swagger.json` | GET | OpenAPI 3.1.0 | Alias for `/openapi.json` | +| `/api/tools/{name}` | POST | REST Tool Execution | Executes an individual tool with JSON arguments and returns structured JSON output | +| `/health` | GET | Health Check | Service health check returning `{ status: "ok", mode: "remote-mcp" }` | --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a8f1ad..597b2ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -207,7 +207,8 @@ openproject-mcp/ ├── src/ │ ├── index.ts # Dual-transport CLI entry point (stdio & HTTP) │ ├── server.ts # Stdio MCP server factory & tool registration -│ ├── http-server.ts # Hosted HTTP/SSE multi-tenant server (Bun.serve) +│ ├── http-server.ts # Hosted HTTP/SSE/OpenAPI multi-tenant server (Bun.serve) +│ ├── openapi-spec.ts # OpenAPI 3.1.0 specification generator for MCP tools │ ├── context.ts # RequestContext & AsyncLocalStorage │ ├── client/ │ │ ├── api-client.ts # OpenProject REST API v3 HTTP client diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6ce221d..88f9b4b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -400,3 +400,34 @@ Key requirements for hosted operation: - **Negative**: - Requires network infrastructure (e.g. reverse proxy with TLS termination) in production to protect credentials in transit. +--- + +## ADR-017: OpenAPI 3.1.0 Specification & REST Tool Execution Bridge for OpenAPI Clients + +### Status +Accepted + +### Context +While modern AI platforms (such as Open WebUI v0.6.31+) natively support MCP Streamable HTTP, many external tool orchestration platforms, custom user agents, and enterprise portals only integrate with external tools via standard OpenAPI 3.0 / 3.1 REST specifications (`openapi.json` / `swagger.json`). Furthermore, users of Open WebUI who configure connections using the "OpenAPI" tool server type expect a compliant OpenAPI specification endpoint that describes available tools and routes invocations over standard HTTP POST endpoints. + +### Decision +1. **Dynamic OpenAPI Specification Generator (`src/openapi-spec.ts`)**: + - Inspect all registered MCP tools in `allTools`. + - Filter out mutating tools when `config.readOnly` is true. + - Convert tool parameter definitions (Zod schemas) into valid OpenAPI 3.1.0 JSON schemas via `zod-to-json-schema`. + - Expose the specification at `GET /openapi.json` and `GET /swagger.json`. +2. **REST Tool Execution Endpoint (`POST /api/tools/{toolName}`)**: + - Parse and validate tool arguments from the JSON request body against the tool's Zod schema. + - Extract the user's OpenProject API key using standard precedence (`Authorization: Bearer`, `X-OpenProject-Api-Key`, or server default). + - Execute the tool within `runWithContext` using an isolated `OpenProjectClient`. + - Return HTTP 200 containing both formatted MCP text content and structured parsed JSON `data`. + +### Consequences +- **Positive**: + - Open WebUI users can seamlessly connect using either connection type: "MCP (Streamable HTTP)" at `/mcp` or "OpenAPI" at `/openapi.json`. + - Tools are defined once in TypeScript / Zod and automatically exposed across both MCP and OpenAPI protocols with zero duplication. + - Full read-only guard enforcement across both transports. +- **Negative**: + - Introduces `src/openapi-spec.ts` dependency on `zod-to-json-schema`. + + diff --git a/docs/TODO.md b/docs/TODO.md index 8f9a832..8ab3509 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -106,6 +106,14 @@ - [x] Record [ADR-016](DECISIONS.md#adr-016-hosted-remote-mcp-server-architecture-httpsse-transport--multi-tenant-credential-scoping). - [x] Update documentation with hosted deployment guide and remote client setups for Cursor and Claude Desktop in [README.md](../README.md). +- [x] **Task 8: OpenAPI 3.1.0 Specification & REST Tool Execution Bridge** + - [x] Implement dynamic OpenAPI specification generator `src/openapi-spec.ts`. + - [x] Expose `GET /openapi.json` and `GET /swagger.json` in `src/http-server.ts`. + - [x] Expose REST tool execution endpoint `POST /api/tools/:toolName` with Zod parameter validation and `RequestContext` execution. + - [x] Add automated test coverage in `tests/http-server.test.ts`. + - [x] Record [ADR-017](DECISIONS.md#adr-017-openapi-310-specification--rest-tool-execution-bridge-for-openapi-clients) in `docs/DECISIONS.md`. + - [x] Document OpenAPI connection mode and server endpoints reference table in [README.md](../README.md). + --- ## 3. Backlog & Future Phases diff --git a/src/http-server.ts b/src/http-server.ts index 25b4890..498dda4 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -17,13 +17,15 @@ import { type JSONRPCMessage, type MessageExtraInfo, } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; import type { AppConfig } from "./config/index.ts"; import { OpenProjectClient } from "./client/api-client.ts"; import { runWithContext } from "./context.ts"; -import { registerAllTools } from "./tools/index.ts"; +import { registerAllTools, allTools } from "./tools/index.ts"; import { formatToolError } from "./tools/common.ts"; import { OpenProjectError } from "./client/errors.ts"; import { SERVER_NAME, SERVER_VERSION } from "./server.ts"; +import { generateOpenApiSpec } from "./openapi-spec.ts"; export interface HttpServerInstance { server: Server; @@ -494,7 +496,148 @@ export async function startHttpServer(config: AppConfig): Promise t.name === toolName); + if (!tool) { + return withCors( + new Response(JSON.stringify({ error: `Tool '${toolName}' not found` }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + } + + if (config.readOnly && !tool.readOnly) { + return withCors( + new Response( + JSON.stringify({ + error: + "Operation rejected. OpenProject MCP server is running in read-only mode.", + code: "SERVER_READ_ONLY", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + } + ) + ); + } + + const apiKey = extractApiKey(req, url, config.apiKey); + if (!apiKey) { + return withCors( + new Response( + JSON.stringify({ + error: + "Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter.", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ) + ); + } + + let body: unknown = {}; + const text = await req.text(); + if (text.trim().length > 0) { + try { + body = JSON.parse(text); + } catch { + return withCors( + new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + ); + } + } + + let args: Record = {}; + if (tool.parameters) { + const parseResult = z.object(tool.parameters).safeParse(body); + if (!parseResult.success) { + return withCors( + new Response( + JSON.stringify({ + error: "Validation error", + details: parseResult.error.format(), + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ) + ); + } + args = parseResult.data as Record; + } else if (typeof body === "object" && body !== null) { + args = body as Record; + } + + const client = new OpenProjectClient({ baseUrl: config.baseUrl, apiKey }); + const toolResponse = await runWithContext( + { client, isReadOnly: config.readOnly }, + () => tool.execute(args as any) + ); + + let parsedData: unknown; + try { + if (toolResponse.content?.[0]?.text) { + parsedData = JSON.parse(toolResponse.content[0].text); + } + } catch {} + + return withCors( + new Response( + JSON.stringify({ + content: toolResponse.content, + isError: toolResponse.isError ?? false, + ...(parsedData !== undefined ? { data: parsedData } : {}), + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + } + + // 4. Classic MCP SSE endpoint: GET /sse without Mcp-Session-Id header if ( url.pathname === "/sse" && req.method === "GET" && diff --git a/src/openapi-spec.ts b/src/openapi-spec.ts new file mode 100644 index 0000000..7fd9154 --- /dev/null +++ b/src/openapi-spec.ts @@ -0,0 +1,163 @@ +/** + * Dynamic OpenAPI 3.1.0 Specification Generator for OpenProject MCP Tools. + * + * Exposes all registered MCP tools as standard REST operations under /api/tools/{toolName}, + * allowing Open WebUI and OpenAPI clients to discover and invoke tools via standard HTTP. + */ + +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { allTools } from "./tools/index.ts"; +import type { AppConfig } from "./config/index.ts"; +import { SERVER_VERSION } from "./server.ts"; + +export interface OpenApiSpec { + openapi: string; + info: { + title: string; + version: string; + description: string; + }; + servers: Array<{ url: string; description?: string }>; + paths: Record>; + components?: { + securitySchemes?: Record; + }; + security?: Array>; +} + +function determineToolTag(name: string): string { + if (name.includes("project")) return "Projects"; + if (name.includes("work_package")) return "Work Packages"; + if (name.includes("query")) return "Queries"; + if ( + name.includes("type") || + name.includes("status") || + name.includes("priority") || + name.includes("user") + ) { + return "Metadata"; + } + if (name.includes("openapi")) return "OpenAPI"; + return "Tools"; +} + +/** + * Generates an OpenAPI 3.1.0 document from registered MCP tools. + * In read-only mode, mutating tools are excluded from the specification. + */ +export function generateOpenApiSpec(config: AppConfig): OpenApiSpec { + const isReadOnly = config.readOnly ?? false; + const tools = allTools.filter((tool) => !isReadOnly || tool.readOnly); + + const paths: Record> = {}; + + for (const tool of tools) { + const rawShape = tool.parameters ?? {}; + const hasParams = Object.keys(rawShape).length > 0; + const schema = z.object(rawShape); + const jsonSchema = zodToJsonSchema(schema, { + target: "openApi3", + $refStrategy: "none", + }); + + const { $schema: _discard, ...cleanSchema } = jsonSchema as Record; + + const path = `/api/tools/${tool.name}`; + paths[path] = { + post: { + operationId: tool.name, + summary: tool.name.replace(/_/g, " "), + description: tool.description, + tags: [determineToolTag(tool.name)], + requestBody: hasParams + ? { + description: `Parameters for ${tool.name}`, + required: true, + content: { + "application/json": { + schema: cleanSchema, + }, + }, + } + : undefined, + responses: { + "200": { + description: "Successful tool execution", + content: { + "application/json": { + schema: { + type: "object", + properties: { + content: { + type: "array", + items: { + type: "object", + properties: { + type: { type: "string" }, + text: { type: "string" }, + }, + }, + }, + data: { + description: "Parsed JSON response payload if applicable", + }, + isError: { type: "boolean" }, + }, + }, + }, + }, + }, + "400": { + description: "Invalid tool parameters or schema validation error", + }, + "401": { + description: "Missing or invalid OpenProject API key", + }, + "403": { + description: "Operation rejected (e.g. read-only mode guard)", + }, + "404": { + description: "Tool or requested entity not found", + }, + "500": { + description: "Internal server or API error", + }, + }, + }, + }; + } + + return { + openapi: "3.1.0", + info: { + title: "OpenProject MCP Tools API", + version: SERVER_VERSION, + description: + "OpenAPI REST interface for OpenProject MCP tools. Exposes curated OpenProject tools for projects, work packages, queries, metadata, and OpenAPI introspection.", + }, + servers: [ + { + url: "/", + description: "OpenProject MCP Server", + }, + ], + components: { + securitySchemes: { + BearerAuth: { + type: "http", + scheme: "bearer", + description: "OpenProject API Key provided as Bearer token", + }, + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "X-OpenProject-Api-Key", + description: "OpenProject API Key provided in header", + }, + }, + }, + security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }], + paths, + }; +} diff --git a/tests/http-server.test.ts b/tests/http-server.test.ts index 03210b6..7601021 100644 --- a/tests/http-server.test.ts +++ b/tests/http-server.test.ts @@ -511,6 +511,128 @@ describe("Hosted Remote MCP Server (HTTP/SSE)", () => { expect(res.status).toBe(404); }); + test("GET /openapi.json and GET /swagger.json return valid OpenAPI 3.1.0 specification", async () => { + // 1. GET /openapi.json + const res = await fetch(`${serverUrl}/openapi.json`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + + const spec = (await res.json()) as { + openapi: string; + info: { title: string; version: string }; + paths: Record; + }; + expect(spec.openapi).toBe("3.1.0"); + expect(spec.info.title).toContain("OpenProject"); + expect(spec.paths["/api/tools/openproject_list_projects"]).toBeDefined(); + expect(spec.paths["/api/tools/openproject_list_projects"]?.post.operationId).toBe( + "openproject_list_projects" + ); + expect(spec.paths["/api/tools/openproject_get_work_package"]).toBeDefined(); + + // 2. GET /swagger.json returns identical spec + const swaggerRes = await fetch(`${serverUrl}/swagger.json`); + expect(swaggerRes.status).toBe(200); + const swaggerSpec = (await swaggerRes.json()) as { openapi: string }; + expect(swaggerSpec.openapi).toBe("3.1.0"); + }); + + test("POST /api/tools/:toolName executes tool and returns JSON result", async () => { + // 1. Missing API key returns 401 + const noAuthRes = await fetch(`${serverUrl}/api/tools/openproject_list_projects`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pageSize: 10 }), + }); + expect(noAuthRes.status).toBe(401); + + // 2. Non-existent tool returns 404 + const notFoundRes = await fetch(`${serverUrl}/api/tools/non_existent_tool`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(notFoundRes.status).toBe(404); + + // 3. Invalid tool arguments return 400 + const invalidArgRes = await fetch(`${serverUrl}/api/tools/openproject_get_project`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), // Missing required projectId + }); + expect(invalidArgRes.status).toBe(400); + + // 4. Valid invocation executes tool and returns result + const validRes = await fetch(`${serverUrl}/api/tools/openproject_list_projects`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ pageSize: 5 }), + }); + expect(validRes.status).toBe(200); + const result = (await validRes.json()) as { + content: Array<{ type: string; text: string }>; + data?: unknown; + }; + expect(result.content).toBeDefined(); + expect(result.content.length).toBeGreaterThan(0); + expect(result.content[0]?.type).toBe("text"); + + // 5. Successful data execution with mocked fetch + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).includes("/api/v3/projects")) { + return new Response( + JSON.stringify({ + _embedded: { + elements: [ + { id: 1, name: "Test Project", identifier: "test-proj" }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }), + { + status: 200, + headers: { "Content-Type": "application/hal+json" }, + } + ); + } + return originalFetch(url, init); + }) as unknown as typeof fetch; + + const mockRes = await fetch(`${serverUrl}/api/tools/openproject_list_projects`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ pageSize: 5 }), + }); + expect(mockRes.status).toBe(200); + const mockResult = (await mockRes.json()) as { + isError: boolean; + data?: { projects: Array<{ id: number; identifier: string }> }; + }; + expect(mockResult.isError).toBe(false); + expect(mockResult.data?.projects[0]?.identifier).toBe("test-proj"); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => { const proc = Bun.spawn( [