Skip to content

Commit 2eceb28

Browse files
committed
Add generate mode, strict linting, policy controls, transforms, and CI hardening
1 parent 3c9bd6a commit 2eceb28

16 files changed

Lines changed: 801 additions & 38 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ on:
88
jobs:
99
test:
1010
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
node-version: [20, 22]
1115
steps:
1216
- uses: actions/checkout@v4
1317
- uses: actions/setup-node@v4
1418
with:
15-
node-version: 20
19+
node-version: ${{ matrix.node-version }}
1620
cache: npm
1721
- run: npm ci
1822
- run: npm run check
23+
- run: npm run build
1924
- run: npm test
2025
- run: npm run smoke

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ jobs:
1717
node-version: 20
1818
cache: npm
1919
- run: npm ci
20+
- run: npm run check
2021
- run: npm run build
2122
- run: npm test
23+
- run: npm run smoke
2224
- run: npm pack
2325
- name: Create GitHub Release
2426
uses: softprops/action-gh-release@v2

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@ OpenAPI 3.x to MCP server bridge in TypeScript.
1818
- AJV JSON Schema validation
1919
- Response schema validation by HTTP status
2020
- Typed TypeScript implementation
21+
- Strict lint mode for OpenAPI quality gates (`--strict`)
22+
- Configurable tool naming template (`--tool-name-template`)
23+
- Policy engine:
24+
- allow/deny tool patterns
25+
- allow methods/path prefixes
26+
- allow hosts
27+
- Optional response transform hook (`--response-transform <module>`)
2128
- Multiple transports:
2229
- `stdio`
2330
- `streamable-http` (Hono)
2431
- `sse` (legacy compatibility transport)
32+
- Transport hardening:
33+
- graceful shutdown
34+
- SSE session caps/TTL
35+
- Observability:
36+
- Prometheus metrics
37+
- status counters
38+
- latency histogram buckets
2539
- Built-in browser test clients:
2640
- `/test/streamable`
2741
- `/test/sse`
@@ -79,12 +93,16 @@ Endpoints:
7993
```bash
8094
mcp-openapi --spec <openapi-file> [options]
8195
mcp-openapi init [dir]
96+
mcp-openapi generate --spec <openapi-file> [--out-dir ./generated]
8297
```
8398

8499
Options:
85100

86101
- `--server-url <url>`
87102
- `--cache-path <file>`
103+
- `--out-dir <dir>`
104+
- `--strict`
105+
- `--tool-name-template <template>`
88106
- `--print-tools`
89107
- `--validate-spec`
90108
- `--transport stdio|streamable-http|sse`
@@ -96,6 +114,27 @@ Options:
96114
- `--max-response-bytes <n>`
97115
- `--max-concurrency <n>`
98116
- `--allow-hosts host1,host2`
117+
- `--allow-tools pattern1,pattern2`
118+
- `--deny-tools pattern1,pattern2`
119+
- `--allow-methods GET,POST`
120+
- `--allow-path-prefixes /v1,/public`
121+
- `--response-transform <module-path>`
122+
- `--sse-max-sessions <n>`
123+
- `--sse-session-ttl-ms <ms>`
124+
125+
Template placeholders for `--tool-name-template`:
126+
- `{operationId}`
127+
- `{method}`
128+
- `{path}`
129+
- `{tag}`
130+
131+
Response transform module example:
132+
133+
```js
134+
export default function transform({ operation, response }) {
135+
return { ...response.body, transformedBy: operation.operationId };
136+
}
137+
```
99138

100139
## Auth env vars
101140

src/compile-cache.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
33
import { dirname, resolve } from "node:path";
44
import { compileOperations } from "./compiler.js";
55
import { loadOpenApiDocument } from "./openapi.js";
6-
import type { OperationModel } from "./types.js";
6+
import type { CompileOptions, OperationModel } from "./types.js";
77

88
interface CacheEntry {
99
hash: string;
1010
operations: OperationModel[];
1111
}
1212

13-
export async function loadFromCompileCache(specPath: string, serverUrl: string | undefined, cachePath: string): Promise<Map<string, OperationModel> | null> {
13+
export async function loadFromCompileCache(
14+
specPath: string,
15+
serverUrl: string | undefined,
16+
cachePath: string,
17+
options: CompileOptions = {}
18+
): Promise<Map<string, OperationModel> | null> {
1419
try {
1520
const doc = await loadOpenApiDocument(specPath);
16-
const hash = computeHash({ doc, serverUrl });
21+
const hash = computeHash({ doc, serverUrl, options });
1722
const raw = await readFile(resolve(cachePath), "utf8");
1823
const parsed = JSON.parse(raw) as CacheEntry;
1924
if (parsed.hash !== hash) {
@@ -26,16 +31,21 @@ export async function loadFromCompileCache(specPath: string, serverUrl: string |
2631
}
2732
}
2833

29-
export async function compileWithCache(specPath: string, serverUrl: string | undefined, cachePath: string): Promise<Map<string, OperationModel>> {
30-
const cached = await loadFromCompileCache(specPath, serverUrl, cachePath);
34+
export async function compileWithCache(
35+
specPath: string,
36+
serverUrl: string | undefined,
37+
cachePath: string,
38+
options: CompileOptions = {}
39+
): Promise<Map<string, OperationModel>> {
40+
const cached = await loadFromCompileCache(specPath, serverUrl, cachePath, options);
3141
if (cached) {
3242
return cached;
3343
}
3444

3545
const doc = await loadOpenApiDocument(specPath);
36-
const operations = compileOperations(doc, serverUrl);
46+
const operations = compileOperations(doc, serverUrl, options);
3747
const entry: CacheEntry = {
38-
hash: computeHash({ doc, serverUrl }),
48+
hash: computeHash({ doc, serverUrl, options }),
3949
operations: [...operations.values()]
4050
};
4151

src/compiler.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { AuthRequirement, JsonSchema, OperationModel, ParameterSpec, SecurityScheme, ToolAnnotations } from "./types.js";
1+
import type { AuthRequirement, CompileOptions, JsonSchema, OperationModel, ParameterSpec, SecurityScheme, ToolAnnotations } from "./types.js";
22

33
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
44

5-
export function compileOperations(doc: Record<string, unknown>, serverOverride?: string): Map<string, OperationModel> {
5+
export function compileOperations(doc: Record<string, unknown>, serverOverride?: string, options: CompileOptions = {}): Map<string, OperationModel> {
66
const rootServers = getServerUrls(doc, serverOverride);
77
const paths = (doc.paths ?? {}) as Record<string, unknown>;
88
const globalSecurity = normalizeSecurity(doc.security);
@@ -25,7 +25,7 @@ export function compileOperations(doc: Record<string, unknown>, serverOverride?:
2525
}
2626

2727
const operation = rawOperation as Record<string, unknown>;
28-
const operationId = getOperationId(operation, method, pathTemplate, operations);
28+
const operationId = getOperationId(operation, method, pathTemplate, operations, options.toolNameTemplate);
2929
const operationParameters = normalizeParameters(operation.parameters);
3030
const mergedParameters = mergeParameters(pathParameters, operationParameters);
3131
const requestBody = normalizeRequestBody(operation.requestBody);
@@ -40,6 +40,7 @@ export function compileOperations(doc: Record<string, unknown>, serverOverride?:
4040
const model: OperationModel = {
4141
operationId,
4242
title: typeof operation.summary === "string" ? operation.summary : undefined,
43+
tags: Array.isArray(operation.tags) ? operation.tags.filter((x): x is string => typeof x === "string") : undefined,
4344
method: method.toUpperCase(),
4445
pathTemplate,
4546
description: String(operation.description ?? operation.summary ?? `${method.toUpperCase()} ${pathTemplate}`),
@@ -222,15 +223,29 @@ function getOperationId(
222223
operation: Record<string, unknown>,
223224
method: string,
224225
pathTemplate: string,
225-
existing: Map<string, OperationModel>
226+
existing: Map<string, OperationModel>,
227+
toolNameTemplate?: string
226228
): string {
227229
const rawId = typeof operation.operationId === "string" ? operation.operationId : undefined;
228-
const fallback = `${method}_${pathTemplate}`
230+
const pathName = `${method}_${pathTemplate}`
229231
.replace(/[{}]/g, "")
230232
.replace(/[^a-zA-Z0-9_-]+/g, "_")
231233
.replace(/^_+|_+$/g, "");
232-
233-
let id = normalizeToolName(rawId ?? (fallback || "operation"));
234+
const tagName =
235+
Array.isArray(operation.tags) && typeof operation.tags[0] === "string"
236+
? String(operation.tags[0]).replace(/[^a-zA-Z0-9_-]+/g, "_")
237+
: "";
238+
const fallback = pathName || "operation";
239+
240+
const template = typeof toolNameTemplate === "string" && toolNameTemplate.trim() ? toolNameTemplate : "{operationId}";
241+
const renderedTemplate = template
242+
.replaceAll("{operationId}", rawId ?? "")
243+
.replaceAll("{method}", method)
244+
.replaceAll("{path}", pathName)
245+
.replaceAll("{tag}", tagName)
246+
.trim();
247+
248+
let id = normalizeToolName(renderedTemplate || rawId || fallback);
234249
let i = 1;
235250
while (existing.has(id)) {
236251
i += 1;

src/http.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,14 @@ function withRuntimeDefaults(runtime: RuntimeOptions | Partial<RuntimeOptions>):
669669
retryDelayMs: runtime.retryDelayMs ?? 500,
670670
maxResponseBytes: runtime.maxResponseBytes ?? 2_000_000,
671671
allowedHosts: runtime.allowedHosts ?? [],
672-
maxConcurrency: runtime.maxConcurrency ?? 8
672+
maxConcurrency: runtime.maxConcurrency ?? 8,
673+
allowToolPatterns: runtime.allowToolPatterns ?? [],
674+
denyToolPatterns: runtime.denyToolPatterns ?? [],
675+
allowedMethods: runtime.allowedMethods ?? [],
676+
allowedPathPrefixes: runtime.allowedPathPrefixes ?? [],
677+
responseTransformModule: runtime.responseTransformModule,
678+
sseMaxSessions: runtime.sseMaxSessions ?? 100,
679+
sseSessionTtlMs: runtime.sseSessionTtlMs ?? 300_000
673680
};
674681
}
675682

src/lint.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { CompileOptions, LintDiagnostic } from "./types.js";
2+
3+
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
4+
const SUPPORTED_REQUEST_MEDIA_TYPES = new Set([
5+
"application/json",
6+
"application/*+json",
7+
"multipart/form-data",
8+
"application/x-www-form-urlencoded",
9+
"application/octet-stream",
10+
"text/plain"
11+
]);
12+
13+
export function lintOpenApiDocument(doc: Record<string, unknown>, options: CompileOptions = {}): LintDiagnostic[] {
14+
const diagnostics: LintDiagnostic[] = [];
15+
const strict = Boolean(options.strict);
16+
17+
const version = String(doc.openapi ?? "");
18+
if (!/^3\./.test(version)) {
19+
diagnostics.push({
20+
level: strict ? "error" : "warning",
21+
code: "OPENAPI_VERSION",
22+
message: `Expected OpenAPI 3.x, got: ${version || "<missing>"}`,
23+
location: "openapi"
24+
});
25+
}
26+
27+
const servers = Array.isArray(doc.servers) ? doc.servers : [];
28+
if (servers.length === 0) {
29+
diagnostics.push({
30+
level: strict ? "error" : "warning",
31+
code: "SERVERS_MISSING",
32+
message: "Document has no servers[]; use --server-url override.",
33+
location: "servers"
34+
});
35+
} else {
36+
for (let i = 0; i < servers.length; i += 1) {
37+
const server = servers[i] as Record<string, unknown>;
38+
if (!isObject(server) || typeof server["url"] !== "string" || !String(server["url"]).trim()) {
39+
diagnostics.push({
40+
level: strict ? "error" : "warning",
41+
code: "SERVER_URL_INVALID",
42+
message: "Server entry is missing a valid url.",
43+
location: `servers[${i}]`
44+
});
45+
}
46+
}
47+
}
48+
49+
if (isObject(doc.webhooks) && Object.keys(doc.webhooks).length > 0) {
50+
diagnostics.push({
51+
level: "warning",
52+
code: "WEBHOOKS_PRESENT",
53+
message: "webhooks are present; they are not auto-exposed as tools by default.",
54+
location: "webhooks"
55+
});
56+
}
57+
58+
const seenOperationIds = new Set<string>();
59+
const paths = isObject(doc.paths) ? (doc.paths as Record<string, unknown>) : {};
60+
for (const [path, rawPathItem] of Object.entries(paths)) {
61+
if (!isObject(rawPathItem)) continue;
62+
const pathItem = rawPathItem as Record<string, unknown>;
63+
64+
if (isObject(pathItem.callbacks) && Object.keys(pathItem.callbacks).length > 0) {
65+
diagnostics.push({
66+
level: "warning",
67+
code: "CALLBACKS_PRESENT",
68+
message: "Path callbacks are present; callback operations are not auto-exposed as tools.",
69+
location: `paths.${path}.callbacks`
70+
});
71+
}
72+
73+
for (const [method, rawOperation] of Object.entries(pathItem)) {
74+
if (!HTTP_METHODS.has(method) || !isObject(rawOperation)) continue;
75+
const operation = rawOperation as Record<string, unknown>;
76+
const opLocation = `paths.${path}.${method}`;
77+
78+
if (typeof operation.operationId !== "string" || !operation.operationId.trim()) {
79+
diagnostics.push({
80+
level: strict ? "error" : "warning",
81+
code: "OPERATION_ID_MISSING",
82+
message: `Missing operationId for ${method.toUpperCase()} ${path}.`,
83+
location: opLocation
84+
});
85+
} else {
86+
const id = operation.operationId.trim();
87+
if (seenOperationIds.has(id)) {
88+
diagnostics.push({
89+
level: strict ? "error" : "warning",
90+
code: "OPERATION_ID_DUPLICATE",
91+
message: `Duplicate operationId: ${id}`,
92+
location: `${opLocation}.operationId`
93+
});
94+
}
95+
seenOperationIds.add(id);
96+
}
97+
98+
const requestBody = operation.requestBody;
99+
if (isObject(requestBody) && isObject((requestBody as Record<string, unknown>)["content"])) {
100+
const content = (requestBody as Record<string, unknown>)["content"] as Record<string, unknown>;
101+
const mediaTypes = Object.keys(content);
102+
if (mediaTypes.length > 0 && !mediaTypes.some((x) => SUPPORTED_REQUEST_MEDIA_TYPES.has(x))) {
103+
diagnostics.push({
104+
level: strict ? "error" : "warning",
105+
code: "REQUEST_MEDIA_TYPE_UNSUPPORTED",
106+
message: `No supported request media type for ${method.toUpperCase()} ${path}.`,
107+
location: `${opLocation}.requestBody.content`
108+
});
109+
}
110+
}
111+
112+
const responses = isObject(operation.responses) ? (operation.responses as Record<string, unknown>) : {};
113+
for (const [status, response] of Object.entries(responses)) {
114+
if (isObject(response) && isObject((response as Record<string, unknown>)["links"])) {
115+
diagnostics.push({
116+
level: "warning",
117+
code: "LINKS_PRESENT",
118+
message: "Response links are present; link relations are not translated into MCP tool semantics.",
119+
location: `${opLocation}.responses.${status}.links`
120+
});
121+
}
122+
}
123+
124+
const bodySchema = extractRequestSchema(operation);
125+
if (isObject(bodySchema) && Array.isArray((bodySchema as Record<string, unknown>)["oneOf"])) {
126+
const discriminator = (bodySchema as Record<string, unknown>)["discriminator"];
127+
if (!isObject(discriminator)) {
128+
diagnostics.push({
129+
level: "warning",
130+
code: "ONEOF_NO_DISCRIMINATOR",
131+
message: "oneOf schema without discriminator can be ambiguous for runtime validation.",
132+
location: `${opLocation}.requestBody`
133+
});
134+
}
135+
}
136+
}
137+
}
138+
139+
return diagnostics;
140+
}
141+
142+
function isObject(value: unknown): value is object {
143+
return value !== null && typeof value === "object";
144+
}
145+
146+
function extractRequestSchema(operation: Record<string, unknown>): unknown {
147+
const requestBody = operation["requestBody"];
148+
if (!isObject(requestBody)) return undefined;
149+
const content = (requestBody as Record<string, unknown>)["content"];
150+
if (!isObject(content)) return undefined;
151+
const first = Object.values(content as Record<string, unknown>)[0];
152+
if (!isObject(first)) return undefined;
153+
return (first as Record<string, unknown>)["schema"];
154+
}

0 commit comments

Comments
 (0)