feat(http): add OpenAPI 3.1.0 specification and REST tool execution endpoint
This commit is contained in:
1 parent
ba1ce2171e
commit
75531eca2d
7 files changed
+509
-13
No files matched your search
+145
-2
@@ -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<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 (
|
||||
url.pathname === "/sse" &&
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user