Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export default function transform({ operation, response }) {

## Observability

`/metrics` serves Prometheus text format: `mcp_openapi_tool_calls_total`, `_failed_total`, `_cancelled_total`, `_in_flight`, `_by_status_total{status}`, `mcp_openapi_retries_total`, `mcp_openapi_tool_call_latency_avg_ms`, and a latency histogram `mcp_openapi_tool_call_latency_ms_bucket`. Tool call start/completion and retry events are also emitted as MCP logging notifications.
`/metrics` serves Prometheus text format: `mcp_openapi_build_info{version}`, `mcp_openapi_uptime_seconds`, `mcp_openapi_tool_calls_total`, `_failed_total`, `_cancelled_total`, `_in_flight`, `_by_status_total{status}`, `mcp_openapi_retries_total`, `mcp_openapi_tool_call_latency_avg_ms`, and a latency histogram `mcp_openapi_tool_call_latency_ms_bucket`. Tool call start/completion and retry events are also emitted as MCP logging notifications.

## Library usage

Expand Down
11 changes: 8 additions & 3 deletions src/compile-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ export async function compileDocumentWithCache(
operations: [...operations.values()]
};

const abs = resolve(cachePath);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, JSON.stringify(entry), "utf8");
// The cache is an optimization; a spec that cannot be cached must still serve.
try {
const abs = resolve(cachePath);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, JSON.stringify(entry), "utf8");
} catch (error) {
process.stderr.write(`Compile cache write skipped: ${error instanceof Error ? error.message : String(error)}\n`);
}

return operations;
}
Expand Down
59 changes: 37 additions & 22 deletions src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ function buildInputSchema(parameters: ParameterSpec[], bodySchema?: JsonSchema):
continue;
}

const groupProperties: Record<string, JsonSchema> = {};
const groupProperties: Record<string, JsonSchema> = Object.create(null);
const groupRequired: string[] = [];
for (const param of items) {
groupProperties[param.name] = param.schema ?? { type: "string" };
Expand Down Expand Up @@ -537,43 +537,58 @@ function pickContentWithSchema(content: Record<string, unknown>): { contentType:
}

function normalizeJsonSchema(schema: JsonSchema): JsonSchema {
return normalizeNode(schema, new WeakMap<object, unknown>()) as JsonSchema;
return normalizeNode(schema, new WeakMap<object, unknown>(), new WeakSet<object>()) as JsonSchema;
}

function normalizeNode<T>(node: T, seen: WeakMap<object, unknown>): T {
function normalizeNode<T>(node: T, seen: WeakMap<object, unknown>, ancestors: WeakSet<object>): T {
if (!isObject(node)) {
return node;
}

// A node that contains itself (recursive $ref after dereferencing) cannot be
// JSON-serialized or compiled by AJV; cut the back-reference to the
// permissive empty schema. Non-cyclic reuse of shared subschemas is kept.
if (ancestors.has(node)) {
return {} as T;
}

if (seen.has(node)) {
return seen.get(node) as T;
}

if (Array.isArray(node)) {
const arr: unknown[] = [];
seen.set(node, arr);
for (const item of node) {
arr.push(normalizeNode(item, seen));
ancestors.add(node);
try {
if (Array.isArray(node)) {
const arr: unknown[] = [];
for (const item of node) {
arr.push(normalizeNode(item, seen, ancestors));
}
seen.set(node, arr);
return arr as T;
}
return arr as T;
}

const source = node as Record<string, unknown>;
const out: Record<string, unknown> = {};
seen.set(node, out);
const source = node as Record<string, unknown>;
// Null prototype: schema keys are attacker-controlled and a "__proto__"
// key assigned to a plain object mutates its prototype instead of
// defining a property.
const out: Record<string, unknown> = Object.create(null);

for (const [key, value] of Object.entries(source)) {
if (key === "nullable") {
continue;
for (const [key, value] of Object.entries(source)) {
if (key === "nullable") {
continue;
}
out[key] = normalizeNode(value, seen, ancestors);
}
out[key] = normalizeNode(value, seen);
}

if (source.nullable === true) {
applyNullable(out);
}
if (source.nullable === true) {
applyNullable(out);
}

return out as T;
seen.set(node, out);
return out as T;
} finally {
ancestors.delete(node);
}
}

function applyNullable(schema: Record<string, unknown>): void {
Expand Down
18 changes: 15 additions & 3 deletions src/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,16 @@ function collectBrokenInternalRefDiagnostics(
value: unknown,
root: Record<string, unknown>,
diagnostics: LintDiagnostic[],
location = "$"
location = "$",
visited: WeakSet<object> = new WeakSet()
): void {
if (Array.isArray(value)) {
if (visited.has(value)) {
return;
}
visited.add(value);
for (let index = 0; index < value.length; index += 1) {
collectBrokenInternalRefDiagnostics(value[index], root, diagnostics, `${location}[${index}]`);
collectBrokenInternalRefDiagnostics(value[index], root, diagnostics, `${location}[${index}]`, visited);
}
return;
}
Expand All @@ -158,6 +163,13 @@ function collectBrokenInternalRefDiagnostics(
return;
}

// Dereferenced documents with recursive $refs are cyclic; each node is
// visited once.
if (visited.has(value)) {
return;
}
visited.add(value);

const record = value as Record<string, unknown>;
const ref = typeof record["$ref"] === "string" ? record["$ref"] : undefined;
if (ref?.startsWith("#/") && resolveJsonPointer(root, ref) === undefined) {
Expand All @@ -170,7 +182,7 @@ function collectBrokenInternalRefDiagnostics(
}

for (const [key, child] of Object.entries(record)) {
collectBrokenInternalRefDiagnostics(child, root, diagnostics, `${location}.${key}`);
collectBrokenInternalRefDiagnostics(child, root, diagnostics, `${location}.${key}`, visited);
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,20 @@ export function observeLatency(ms: number): void {
metrics.latencyBuckets.le_inf += 1;
}

let buildVersion = "0.0.0";
const startedAtMs = Date.now();

export function setBuildInfo(version: string): void {
buildVersion = version;
}

export function renderPrometheus(): string {
const avgLatency = metrics.toolCallsTotal > 0 ? metrics.toolCallLatencyMsTotal / metrics.toolCallsTotal : 0;
return [
"# TYPE mcp_openapi_build_info gauge",
`mcp_openapi_build_info{version="${buildVersion}"} 1`,
"# TYPE mcp_openapi_uptime_seconds gauge",
`mcp_openapi_uptime_seconds ${Math.floor((Date.now() - startedAtMs) / 1000)}`,
"# TYPE mcp_openapi_tool_calls_total counter",
`mcp_openapi_tool_calls_total ${metrics.toolCallsTotal}`,
"# TYPE mcp_openapi_tool_calls_failed_total counter",
Expand Down
28 changes: 26 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
import { executeOperation } from "./http.js";
import { STREAMABLE_TEST_HTML, SSE_TEST_HTML } from "./html.js";
import { paginateWithCursor } from "./pagination.js";
import { observeLatency, observeStatus, renderPrometheus, metrics } from "./metrics.js";
import { observeLatency, observeStatus, renderPrometheus, setBuildInfo, metrics } from "./metrics.js";
import type { CompileOptions, OperationModel, RuntimeOptions } from "./types.js";
import { zodFromJsonSchema } from "./zod-schema.js";
import { compileDocumentWithCache } from "./compile-cache.js";
Expand Down Expand Up @@ -128,6 +128,7 @@ let inFlightCalls = 0;
let responseTransform: ((ctx: { operation: OperationModel; response: { body: unknown; status: number } }) => unknown | Promise<unknown>) | undefined;

async function main(): Promise<void> {
setBuildInfo(PKG_VERSION);
const cli = parseArgs(process.argv.slice(2));

if (cli.command === "init") {
Expand Down Expand Up @@ -214,7 +215,7 @@ function createMcpServer(state: RuntimeState, cli: CliOptions): Server {
for (const spec of state.specDocs) {
if (uri === `openapi://${spec.name}/spec`) {
return {
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(spec.doc, null, 2) }]
contents: [{ uri, mimeType: "application/json", text: safeJsonStringify(spec.doc, 2) }]
};
}
if (uri === `openapi://${spec.name}/tools`) {
Expand Down Expand Up @@ -1510,6 +1511,29 @@ function isObject(value: unknown): value is object {
return value !== null && typeof value === "object";
}

// Dereferenced documents with recursive $refs contain object cycles;
// back-references are rendered as the string "[Circular]".
function safeJsonStringify(value: unknown, indent?: number): string {
const ancestors: object[] = [];
return JSON.stringify(
value,
function (this: unknown, _key: string, val: unknown) {
if (typeof val !== "object" || val === null) {
return val;
}
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}
if (ancestors.includes(val)) {
return "[Circular]";
}
ancestors.push(val);
return val;
},
indent
);
}

main().catch((error) => {
process.stderr.write(`Fatal: ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
Expand Down
4 changes: 3 additions & 1 deletion src/zod-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ function convertSchema(schema: unknown, seen: WeakMap<object, ZodTypeAny>): ZodT
const props = isObject(s.properties) ? (s.properties as Record<string, unknown>) : {};
const required = Array.isArray(s.required) ? new Set(s.required.filter((x): x is string => typeof x === "string")) : new Set<string>();

const shape: Record<string, ZodTypeAny> = {};
// Null prototype: a "__proto__" property name assigned to a plain object
// would replace the shape's prototype and silently skip validation.
const shape: Record<string, ZodTypeAny> = Object.create(null);
for (const [key, value] of Object.entries(props)) {
const inner = convertSchema(value, seen);
shape[key] = required.has(key) ? inner : inner.optional();
Expand Down
Loading
Loading