Skip to content

Commit a2f81b7

Browse files
committed
Tolerate broken internal OpenAPI refs
1 parent 2eceb28 commit a2f81b7

2 files changed

Lines changed: 263 additions & 4 deletions

File tree

src/openapi.ts

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@ export async function loadOpenApiDocument(specPath: string): Promise<Record<stri
88
const raw = await readFile(absPath, "utf8");
99
const parsed = parseByExtension(absPath, raw);
1010

11-
if (!parsed || typeof parsed !== "object") {
11+
if (!isRecord(parsed)) {
1212
throw new Error(`Spec at ${absPath} is not a JSON object`);
1313
}
1414

15-
const dereferenced = await $RefParser.dereference(absPath, parsed as object);
16-
validateShape(dereferenced as Record<string, unknown>, absPath);
17-
return dereferenced as Record<string, unknown>;
15+
const sanitized = sanitizeBrokenInternalRefs(parsed, parsed);
16+
try {
17+
const dereferenced = await $RefParser.dereference(absPath, sanitized as object);
18+
validateShape(dereferenced as Record<string, unknown>, absPath);
19+
return dereferenced as Record<string, unknown>;
20+
} catch (error) {
21+
if (!isMissingPointerError(error)) {
22+
throw error;
23+
}
24+
25+
const resolved = resolveInternalRefs(sanitized, sanitized);
26+
validateShape(resolved, absPath);
27+
return resolved;
28+
}
1829
}
1930

2031
function parseByExtension(path: string, raw: string): unknown {
@@ -45,3 +56,143 @@ function validateShape(doc: Record<string, unknown>, source: string): void {
4556
throw new Error(`Missing or invalid \"paths\" in ${source}`);
4657
}
4758
}
59+
60+
function sanitizeBrokenInternalRefs<T>(value: T, root: Record<string, unknown>): T {
61+
if (Array.isArray(value)) {
62+
return value.map((entry) => sanitizeBrokenInternalRefs(entry, root)) as T;
63+
}
64+
65+
if (!isRecord(value)) {
66+
return value;
67+
}
68+
69+
const ref = typeof value.$ref === "string" ? value.$ref : undefined;
70+
if (ref && isBrokenInternalRef(ref, root)) {
71+
const withoutRef: Record<string, unknown> = {};
72+
for (const [key, child] of Object.entries(value)) {
73+
if (key === "$ref") {
74+
continue;
75+
}
76+
withoutRef[key] = sanitizeBrokenInternalRefs(child, root);
77+
}
78+
return withoutRef as T;
79+
}
80+
81+
const out: Record<string, unknown> = {};
82+
for (const [key, child] of Object.entries(value)) {
83+
out[key] = sanitizeBrokenInternalRefs(child, root);
84+
}
85+
return out as T;
86+
}
87+
88+
function resolveInternalRefs<T>(value: T, root: Record<string, unknown>, stack: string[] = []): T {
89+
if (Array.isArray(value)) {
90+
return value.map((entry) => resolveInternalRefs(entry, root, stack)) as T;
91+
}
92+
93+
if (!isRecord(value)) {
94+
return value;
95+
}
96+
97+
const ref = typeof value.$ref === "string" ? value.$ref : undefined;
98+
if (ref && ref.startsWith("#")) {
99+
if (stack.includes(ref)) {
100+
return {} as T;
101+
}
102+
103+
const resolved = resolveJsonPointer(root, ref);
104+
const siblings = { ...value };
105+
delete siblings.$ref;
106+
107+
if (resolved === undefined) {
108+
return resolveInternalRefs(siblings as T, root, stack);
109+
}
110+
111+
const resolvedClone = structuredClone(resolved);
112+
const resolvedValue = resolveInternalRefs(resolvedClone, root, [...stack, ref]);
113+
if (isRecord(resolvedValue) && Object.keys(siblings).length > 0) {
114+
return resolveInternalRefs(mergeObjects(resolvedValue, siblings) as T, root, [...stack, ref]);
115+
}
116+
117+
return resolvedValue as T;
118+
}
119+
120+
const out: Record<string, unknown> = {};
121+
for (const [key, child] of Object.entries(value)) {
122+
out[key] = resolveInternalRefs(child, root, stack);
123+
}
124+
return out as T;
125+
}
126+
127+
function isBrokenInternalRef(ref: string, root: Record<string, unknown>): boolean {
128+
if (ref === "#") {
129+
return false;
130+
}
131+
132+
if (!ref.startsWith("#/")) {
133+
return false;
134+
}
135+
136+
return resolveJsonPointer(root, ref) === undefined;
137+
}
138+
139+
function resolveJsonPointer(root: unknown, ref: string): unknown {
140+
if (ref === "#") {
141+
return root;
142+
}
143+
144+
const tokens = ref
145+
.slice(2)
146+
.split("/")
147+
.map((token) => decodePointerToken(token));
148+
149+
let current: unknown = root;
150+
for (const token of tokens) {
151+
if (Array.isArray(current)) {
152+
const index = Number.parseInt(token, 10);
153+
if (!Number.isFinite(index)) {
154+
return undefined;
155+
}
156+
current = current[index];
157+
continue;
158+
}
159+
160+
if (!isRecord(current)) {
161+
return undefined;
162+
}
163+
164+
current = current[token];
165+
}
166+
167+
return current;
168+
}
169+
170+
function decodePointerToken(token: string): string {
171+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
172+
}
173+
174+
function mergeObjects(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
175+
const merged: Record<string, unknown> = { ...base };
176+
for (const [key, value] of Object.entries(overlay)) {
177+
const existing = merged[key];
178+
if (isRecord(existing) && isRecord(value)) {
179+
merged[key] = mergeObjects(existing, value);
180+
continue;
181+
}
182+
merged[key] = value;
183+
}
184+
return merged;
185+
}
186+
187+
function isMissingPointerError(error: unknown): boolean {
188+
if (!error || typeof error !== "object") {
189+
return false;
190+
}
191+
192+
const candidate = error as { code?: unknown; name?: unknown };
193+
return candidate.code === "EMISSINGPOINTER" || candidate.name === "MissingPointerError";
194+
}
195+
196+
function isRecord(value: unknown): value is Record<string, unknown> {
197+
return value !== null && typeof value === "object" && !Array.isArray(value);
198+
}

test/openapi.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { compileOperations } from "../src/compiler.js";
7+
import { loadOpenApiDocument } from "../src/openapi.js";
8+
9+
test("loadOpenApiDocument tolerates missing internal refs while preserving valid ones", async (t) => {
10+
const dir = await mkdtemp(join(tmpdir(), "mcp-openapi-"));
11+
t.after(async () => {
12+
await rm(dir, { recursive: true, force: true });
13+
});
14+
15+
const specPath = join(dir, "broken-internal-ref.json");
16+
const spec = {
17+
openapi: "3.0.3",
18+
info: {
19+
title: "Broken internal ref",
20+
version: "1.0.0"
21+
},
22+
servers: [{ url: "https://api.example.com" }],
23+
paths: {
24+
"/widgets": {
25+
get: {
26+
operationId: "listWidgets",
27+
parameters: [
28+
{ $ref: "#/components/parameters/pageSize" },
29+
{ $ref: "#/components/parameters/MissingParam" }
30+
],
31+
responses: {
32+
"200": {
33+
description: "ok",
34+
content: {
35+
"application/json": {
36+
schema: { $ref: "#/components/schemas/WidgetList" }
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
},
44+
components: {
45+
parameters: {
46+
pageSize: {
47+
name: "pageSize",
48+
in: "query",
49+
schema: { type: "integer" }
50+
}
51+
},
52+
schemas: {
53+
WidgetList: {
54+
type: "object",
55+
properties: {
56+
items: {
57+
type: "array",
58+
items: { $ref: "#/components/schemas/Widget" }
59+
}
60+
}
61+
},
62+
Widget: {
63+
allOf: [
64+
{ $ref: "#/components/schemas/MissingBase" },
65+
{
66+
type: "object",
67+
properties: {
68+
id: { type: "string" }
69+
}
70+
}
71+
]
72+
}
73+
}
74+
}
75+
};
76+
77+
await writeFile(specPath, JSON.stringify(spec), "utf8");
78+
79+
const doc = await loadOpenApiDocument(specPath);
80+
81+
const paths = (doc.paths ?? {}) as Record<string, unknown>;
82+
const widgetsPath = (paths["/widgets"] ?? {}) as Record<string, unknown>;
83+
const getOperation = (widgetsPath.get ?? {}) as Record<string, unknown>;
84+
const parameters = (getOperation.parameters ?? []) as unknown[];
85+
assert.equal(parameters.length, 2);
86+
assert.equal((parameters[0] as Record<string, unknown>).name, "pageSize");
87+
assert.deepEqual(parameters[1], {});
88+
89+
const components = (doc.components ?? {}) as Record<string, unknown>;
90+
const schemas = (components.schemas ?? {}) as Record<string, unknown>;
91+
const widget = (schemas.Widget ?? {}) as Record<string, unknown>;
92+
const widgetAllOf = (widget.allOf ?? []) as unknown[];
93+
assert.equal(widgetAllOf.length, 2);
94+
assert.deepEqual(widgetAllOf[0], {});
95+
assert.equal(
96+
(((widgetAllOf[1] as Record<string, unknown>).properties as Record<string, unknown>).id as Record<string, unknown>).type,
97+
"string"
98+
);
99+
100+
const operations = compileOperations(doc);
101+
const op = operations.get("listWidgets");
102+
assert.ok(op);
103+
assert.deepEqual(
104+
op.parameters.map((param) => param.name),
105+
["pageSize"]
106+
);
107+
assert.ok(op.successResponseSchema);
108+
});

0 commit comments

Comments
 (0)