feat(http): add OpenAPI 3.1.0 specification and REST tool execution endpoint
This commit is contained in:
@@ -274,17 +274,45 @@ In `claude_desktop_config.json` or clients connecting directly via SSE URL:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Open WebUI (Streamable HTTP)
|
### Open WebUI
|
||||||
In Open WebUI (**Admin Panel > Settings > External Tools** or **User Settings > Tools**):
|
|
||||||
1. Click **+** (Add Connection).
|
Open WebUI supports two connection modes for `openproject-mcp`:
|
||||||
2. Select **Type**: `MCP (Streamable HTTP)`.
|
|
||||||
3. Set **URL**:
|
#### Option 1: MCP (Streamable HTTP) — Recommended
|
||||||
- In Docker Compose on the same network: `http://openproject-mcp:3000/mcp` (or `http://openproject-mcp:3000/sse`)
|
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`
|
- From host machine: `http://localhost:3000/mcp`
|
||||||
4. Set **API Key / Auth Header** (optional):
|
5. Set **Auth** (optional): `Bearer <your-openproject-api-key>` if multi-user, or leave blank if `OPENPROJECT_API_KEY` is configured in the container.
|
||||||
- If `OPENPROJECT_API_KEY` is configured in the server container environment, no key is needed in the client.
|
6. Click **Verify Connection**.
|
||||||
- For multi-user environments, pass `Bearer <your-openproject-api-key>` or append `?apiKey=<your-key>`.
|
|
||||||
5. Click **Verify** to test connectivity and discover tools.
|
#### 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 <your-openproject-api-key>`.
|
||||||
|
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" }` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ openproject-mcp/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # Dual-transport CLI entry point (stdio & HTTP)
|
│ ├── index.ts # Dual-transport CLI entry point (stdio & HTTP)
|
||||||
│ ├── server.ts # Stdio MCP server factory & tool registration
|
│ ├── 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
|
│ ├── context.ts # RequestContext & AsyncLocalStorage
|
||||||
│ ├── client/
|
│ ├── client/
|
||||||
│ │ ├── api-client.ts # OpenProject REST API v3 HTTP client
|
│ │ ├── api-client.ts # OpenProject REST API v3 HTTP client
|
||||||
|
|||||||
@@ -400,3 +400,34 @@ Key requirements for hosted operation:
|
|||||||
- **Negative**:
|
- **Negative**:
|
||||||
- Requires network infrastructure (e.g. reverse proxy with TLS termination) in production to protect credentials in transit.
|
- 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`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,14 @@
|
|||||||
- [x] Record [ADR-016](DECISIONS.md#adr-016-hosted-remote-mcp-server-architecture-httpsse-transport--multi-tenant-credential-scoping).
|
- [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] 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
|
## 3. Backlog & Future Phases
|
||||||
|
|||||||
+145
-2
@@ -17,13 +17,15 @@ import {
|
|||||||
type JSONRPCMessage,
|
type JSONRPCMessage,
|
||||||
type MessageExtraInfo,
|
type MessageExtraInfo,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { z } from "zod";
|
||||||
import type { AppConfig } from "./config/index.ts";
|
import type { AppConfig } from "./config/index.ts";
|
||||||
import { OpenProjectClient } from "./client/api-client.ts";
|
import { OpenProjectClient } from "./client/api-client.ts";
|
||||||
import { runWithContext } from "./context.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 { formatToolError } from "./tools/common.ts";
|
||||||
import { OpenProjectError } from "./client/errors.ts";
|
import { OpenProjectError } from "./client/errors.ts";
|
||||||
import { SERVER_NAME, SERVER_VERSION } from "./server.ts";
|
import { SERVER_NAME, SERVER_VERSION } from "./server.ts";
|
||||||
|
import { generateOpenApiSpec } from "./openapi-spec.ts";
|
||||||
|
|
||||||
export interface HttpServerInstance {
|
export interface HttpServerInstance {
|
||||||
server: Server<unknown>;
|
server: Server<unknown>;
|
||||||
@@ -494,7 +496,148 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Classic MCP SSE endpoint: GET /sse without Mcp-Session-Id header
|
// 2. OpenAPI specification endpoint
|
||||||
|
if (url.pathname === "/openapi.json" || url.pathname === "/swagger.json") {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return withCors(
|
||||||
|
new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const spec = generateOpenApiSpec(config);
|
||||||
|
return withCors(
|
||||||
|
new Response(JSON.stringify(spec, null, 2), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. REST tool execution endpoint (OpenAPI callers)
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith("/api/tools/") ||
|
||||||
|
url.pathname.startsWith("/tools/")
|
||||||
|
) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return withCors(
|
||||||
|
new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = url.pathname.replace(/^\/(api\/)?tools\//, "");
|
||||||
|
const tool = allTools.find((t) => 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<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
} else if (typeof body === "object" && body !== null) {
|
||||||
|
args = body as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
url.pathname === "/sse" &&
|
url.pathname === "/sse" &&
|
||||||
req.method === "GET" &&
|
req.method === "GET" &&
|
||||||
|
|||||||
@@ -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<string, Record<string, unknown>>;
|
||||||
|
components?: {
|
||||||
|
securitySchemes?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
security?: Array<Record<string, unknown[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -511,6 +511,128 @@ describe("Hosted Remote MCP Server (HTTP/SSE)", () => {
|
|||||||
expect(res.status).toBe(404);
|
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<string, { post: { operationId: string; description: string } }>;
|
||||||
|
};
|
||||||
|
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 () => {
|
test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => {
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user