Skip to content

Commit fd81ced

Browse files
chore: builder proper api route for mcp clients
1 parent a38b739 commit fd81ced

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"docusaurus": "docusaurus",
77
"dev": "docusaurus start",
88
"start": "docusaurus start",
9+
"prebuild": "npm run generate-api-discovery",
910
"build": "docusaurus build",
11+
"generate-api-discovery": "node scripts/generate-api-discovery.js",
1012
"swizzle": "docusaurus swizzle",
1113
"deploy": "docusaurus deploy",
1214
"clear": "docusaurus clear",

redoc.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// Adding an API here also publishes it to static/openapi-specs/apis.json,
2+
// the discovery document used by MCP clients (npm run generate-api-discovery).
13
module.exports.specs = [
24
{
35
layout: { title: 'Access Token API' },

scripts/generate-api-discovery.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Generates static/openapi-specs/apis.json — the discovery document consumed by
3+
* openapi-analyzer-mcp (see docs/architecture/api-first.md).
4+
*
5+
* The API list comes from redoc.config.js so the discovery document always
6+
* matches the published API reference. Each baseURL is read from the spec's own
7+
* `servers` entry rather than being maintained here.
8+
*/
9+
10+
const fs = require('fs');
11+
const path = require('path');
12+
13+
const { specs } = require('../redoc.config');
14+
15+
const OUTPUT_PATH = path.join(__dirname, '..', 'static', 'openapi-specs', 'apis.json');
16+
const CONCURRENCY = 8;
17+
18+
/**
19+
* Reads the first `servers[].url` out of an OpenAPI YAML document.
20+
*
21+
* Avoids a YAML dependency: `servers` is always a top-level key, so scan from it
22+
* until the next top-level key and take the first `url`.
23+
*/
24+
function extractBaseURL(spec) {
25+
const lines = spec.split('\n');
26+
const start = lines.findIndex((line) => /^servers:/.test(line));
27+
28+
if (start === -1) return null;
29+
30+
for (const line of lines.slice(start + 1)) {
31+
// A new top-level key ends the block; sequence items may sit at column 0 too.
32+
if (/^[^\s-]/.test(line)) break;
33+
34+
const match = line.match(/^\s*-?\s*url:\s*(.+?)\s*$/);
35+
36+
if (match) return match[1].replace(/^['"]|['"]$/g, '');
37+
}
38+
39+
return null;
40+
}
41+
42+
async function resolveBaseURL(spec, previousBaseURL) {
43+
const fallback = () => {
44+
if (!previousBaseURL) return null;
45+
console.warn(` keeping previously known baseURL ${previousBaseURL}`);
46+
47+
return previousBaseURL;
48+
};
49+
50+
let response;
51+
52+
try {
53+
response = await fetch(spec.specUrl);
54+
} catch (error) {
55+
console.warn(`✗ ${spec.layout.title}: ${spec.specUrl}${error.message}`);
56+
57+
return fallback();
58+
}
59+
60+
if (!response.ok) {
61+
console.warn(`✗ ${spec.layout.title}: ${spec.specUrl} — HTTP ${response.status}`);
62+
63+
return fallback();
64+
}
65+
66+
const baseURL = extractBaseURL(await response.text());
67+
68+
if (!baseURL) {
69+
console.warn(`✗ ${spec.layout.title}: no servers entry in ${spec.specUrl}`);
70+
71+
return fallback();
72+
}
73+
74+
return baseURL;
75+
}
76+
77+
async function mapWithConcurrency(items, limit, fn) {
78+
const results = new Array(items.length);
79+
let next = 0;
80+
81+
const worker = async () => {
82+
while (next < items.length) {
83+
const index = next++;
84+
results[index] = await fn(items[index]);
85+
}
86+
};
87+
88+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
89+
90+
return results;
91+
}
92+
93+
function readPreviousBaseURLs() {
94+
try {
95+
const previous = JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf8'));
96+
97+
return new Map(previous.apis.map((api) => [api.properties[0].url, api.baseURL]));
98+
} catch {
99+
return new Map();
100+
}
101+
}
102+
103+
async function main() {
104+
const previousBaseURLs = readPreviousBaseURLs();
105+
106+
const apis = (
107+
await mapWithConcurrency(specs, CONCURRENCY, async (spec) => {
108+
const baseURL = await resolveBaseURL(spec, previousBaseURLs.get(spec.specUrl));
109+
110+
if (!baseURL) {
111+
console.warn(` skipping ${spec.layout.title} — no baseURL available`);
112+
113+
return null;
114+
}
115+
116+
return {
117+
name: spec.layout.title,
118+
baseURL,
119+
properties: [{ type: 'Swagger', url: spec.specUrl }],
120+
};
121+
})
122+
).filter(Boolean);
123+
124+
if (!apis.length) {
125+
throw new Error('No APIs could be resolved — refusing to write an empty discovery document');
126+
}
127+
128+
const document = {
129+
name: 'Epilot APIs',
130+
description: 'Collection of Epilot API specifications',
131+
url: 'https://docs.epilot.io',
132+
apis,
133+
};
134+
135+
const contents = `${JSON.stringify(document, null, 2)}\n`;
136+
const unchanged = fs.existsSync(OUTPUT_PATH) && fs.readFileSync(OUTPUT_PATH, 'utf8') === contents;
137+
138+
if (unchanged) {
139+
console.log(`apis.json already up to date (${apis.length} APIs)`);
140+
141+
return;
142+
}
143+
144+
fs.writeFileSync(OUTPUT_PATH, contents);
145+
console.log(`Wrote apis.json with ${apis.length} of ${specs.length} APIs`);
146+
}
147+
148+
main().catch((error) => {
149+
console.error(error.message);
150+
process.exit(1);
151+
});

static/openapi-specs/apis.json

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,86 @@
382382
"url": "https://docs.api.epilot.io/kanban.yaml"
383383
}
384384
]
385+
},
386+
{
387+
"name": "Validation Rules API",
388+
"baseURL": "https://validation-rules.sls.epilot.io",
389+
"properties": [
390+
{
391+
"type": "Swagger",
392+
"url": "https://docs.api.epilot.io/validation-rules.yaml"
393+
}
394+
]
395+
},
396+
{
397+
"name": "Integration Toolkit API",
398+
"baseURL": "https://integration-toolkit.sls.epilot.io",
399+
"properties": [
400+
{
401+
"type": "Swagger",
402+
"url": "https://docs.api.epilot.io/erp-integration.yaml"
403+
}
404+
]
405+
},
406+
{
407+
"name": "Environments API",
408+
"baseURL": "https://environments.sls.epilot.io",
409+
"properties": [
410+
{
411+
"type": "Swagger",
412+
"url": "https://docs.api.epilot.io/environments.yaml"
413+
}
414+
]
415+
},
416+
{
417+
"name": "Event Catalog API",
418+
"baseURL": "https://event-catalog.sls.epilot.io",
419+
"properties": [
420+
{
421+
"type": "Swagger",
422+
"url": "https://docs.api.epilot.io/event-catalog.yaml"
423+
}
424+
]
425+
},
426+
{
427+
"name": "Targeting API",
428+
"baseURL": "https://targeting.sls.epilot.io",
429+
"properties": [
430+
{
431+
"type": "Swagger",
432+
"url": "https://docs.api.epilot.io/targeting.yaml"
433+
}
434+
]
435+
},
436+
{
437+
"name": "App API",
438+
"baseURL": "https://app.sls.epilot.io",
439+
"properties": [
440+
{
441+
"type": "Swagger",
442+
"url": "https://docs.api.epilot.io/app.yaml"
443+
}
444+
]
445+
},
446+
{
447+
"name": "Audit Log API",
448+
"baseURL": "https://audit-logs.sls.epilot.io",
449+
"properties": [
450+
{
451+
"type": "Swagger",
452+
"url": "https://docs.api.epilot.io/audit-log.yaml"
453+
}
454+
]
455+
},
456+
{
457+
"name": "Data Governance API",
458+
"baseURL": "https://data-governance.sls.epilot.io",
459+
"properties": [
460+
{
461+
"type": "Swagger",
462+
"url": "https://docs.api.epilot.io/data-governance.yaml"
463+
}
464+
]
385465
}
386466
]
387467
}

0 commit comments

Comments
 (0)