api-spec-cli 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,287 +1,344 @@
1
- import { readFileSync, existsSync } from "fs";
2
- import { resolve } from "path";
3
- import YAML from "yaml";
4
- import { parseKV } from "../args.js";
5
- import { createMcpClient } from "../mcp-client.js";
6
- import { matchGlob } from "../glob.js";
7
-
8
- const INTROSPECTION_QUERY = `{
9
- __schema {
10
- queryType { name }
11
- mutationType { name }
12
- subscriptionType { name }
13
- types {
14
- name
15
- kind
16
- description
17
- fields(includeDeprecated: true) {
18
- name
19
- description
20
- isDeprecated
21
- deprecationReason
22
- args {
23
- name
24
- description
25
- type { ...TypeRef }
26
- defaultValue
27
- }
28
- type { ...TypeRef }
29
- }
30
- inputFields {
31
- name
32
- description
33
- type { ...TypeRef }
34
- defaultValue
35
- }
36
- enumValues(includeDeprecated: true) {
37
- name
38
- description
39
- isDeprecated
40
- deprecationReason
41
- }
42
- }
43
- }
44
- }
45
-
46
- fragment TypeRef on __Type {
47
- kind
48
- name
49
- ofType {
50
- kind
51
- name
52
- ofType {
53
- kind
54
- name
55
- ofType {
56
- kind
57
- name
58
- }
59
- }
60
- }
61
- }`;
62
-
63
- /**
64
- * Resolve a spec from a registry entry or inline flags entry.
65
- * Entry shape:
66
- * { type: "openapi", source: "<url-or-file>", config: { headers, auth } }
67
- * { type: "graphql", source: "<url>", config: { headers, auth } }
68
- * { type: "mcp", transport: "stdio|sse|streamable-http", url?, command?, args?, config: { headers, env } }
69
- */
70
- export async function fetchSpec(entry) {
71
- if (entry.type === "mcp") return await loadMCPFromEntry(entry);
72
- if (entry.type === "graphql") return await loadGraphQL(entry.source, entry.config?.headers);
73
- // openapi url or file; skip GraphQL probe since type is explicitly declared
74
- const isUrl = entry.source?.startsWith("http://") || entry.source?.startsWith("https://");
75
- return isUrl ? await loadFromUrl(entry.source, true) : loadFromFile(entry.source);
76
- }
77
-
78
- /**
79
- * Build an inline entry from flags (for ad-hoc commands like --mcp-http <url>).
80
- * Returns null if no inline source flags present.
81
- */
82
- export function inlineEntryFromFlags(flags) {
83
- if (flags["mcp-stdio"]) {
84
- const raw = flags["mcp-stdio"];
85
- const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
86
- if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
87
- return {
88
- type: "mcp",
89
- transport: "stdio",
90
- command: parts[0],
91
- args: parts.slice(1),
92
- cwd: flags.cwd,
93
- config: { env: parseKV(flags.env) },
94
- };
95
- }
96
- if (flags["mcp-sse"]) {
97
- return {
98
- type: "mcp",
99
- transport: "sse",
100
- url: flags["mcp-sse"],
101
- config: { headers: parseKV(flags.header) },
102
- };
103
- }
104
- if (flags["mcp-http"]) {
105
- return {
106
- type: "mcp",
107
- transport: "streamable-http",
108
- url: flags["mcp-http"],
109
- config: { headers: parseKV(flags.header) },
110
- };
111
- }
112
- if (flags.graphql) {
113
- return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header) } };
114
- }
115
- if (flags.openapi) {
116
- return { type: "openapi", source: flags.openapi, config: { headers: parseKV(flags.header), baseUrl: flags["base-url"] || null } };
117
- }
118
- return null;
119
- }
120
-
121
- // --- Internal loaders ---
122
-
123
- async function loadMCPFromEntry(entry) {
124
- const client = await createMcpClient(entry);
125
- try {
126
- const { tools } = await client.listTools();
127
- let mapped = tools.map((t) => ({
128
- name: t.name,
129
- description: t.description || null,
130
- inputSchema: t.inputSchema || null,
131
- }));
132
-
133
- const allowed = entry.config?.allowedTools;
134
- const disabled = entry.config?.disabledTools;
135
- if (allowed?.length) mapped = mapped.filter((t) => allowed.some((p) => matchGlob(p, t.name)));
136
- if (disabled?.length) mapped = mapped.filter((t) => !disabled.some((p) => matchGlob(p, t.name)));
137
-
138
- return {
139
- type: "mcp",
140
- title: entry.name || "MCP Server",
141
- transport: entry.transport,
142
- url: entry.url,
143
- command: entry.command,
144
- args: entry.args,
145
- cwd: entry.cwd,
146
- config: entry.config,
147
- tools: mapped,
148
- };
149
- } finally {
150
- await client.close();
151
- }
152
- }
153
-
154
- async function loadFromUrl(url, skipGraphQLProbe = false) {
155
- const lowerUrl = url.toLowerCase();
156
- const isLikelyFile =
157
- lowerUrl.endsWith(".json") ||
158
- lowerUrl.endsWith(".yaml") ||
159
- lowerUrl.endsWith(".yml");
160
-
161
- if (!isLikelyFile && !skipGraphQLProbe) {
162
- try {
163
- return await loadGraphQL(url);
164
- } catch {
165
- // Fall through to OpenAPI
166
- }
167
- }
168
-
169
- const res = await fetch(url);
170
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
171
- const text = await res.text();
172
- return parseOpenAPI(text, url);
173
- }
174
-
175
- function loadFromFile(path) {
176
- const abs = resolve(path);
177
- if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
178
- const text = readFileSync(abs, "utf-8");
179
- return parseOpenAPI(text, path);
180
- }
181
-
182
- async function loadGraphQL(url, extraHeaders = {}) {
183
- const res = await fetch(url, {
184
- method: "POST",
185
- headers: { "Content-Type": "application/json", ...extraHeaders },
186
- body: JSON.stringify({ query: INTROSPECTION_QUERY }),
187
- });
188
-
189
- if (!res.ok) throw new Error(`GraphQL introspection failed: HTTP ${res.status}`);
190
-
191
- const json = await res.json();
192
- if (json.errors && !json.data) {
193
- throw new Error(`GraphQL error: ${json.errors[0].message}`);
194
- }
195
-
196
- const schema = json.data.__schema;
197
- const operations = [];
198
- const typeMap = {};
199
- for (const t of schema.types) {
200
- typeMap[t.name] = t;
201
- }
202
-
203
- for (const [kind, rootType] of [
204
- ["query", schema.queryType],
205
- ["mutation", schema.mutationType],
206
- ["subscription", schema.subscriptionType],
207
- ]) {
208
- if (!rootType) continue;
209
- const type = typeMap[rootType.name];
210
- if (!type || !type.fields) continue;
211
- for (const field of type.fields) {
212
- operations.push({
213
- kind,
214
- name: field.name,
215
- description: field.description,
216
- args: field.args,
217
- returnType: flattenType(field.type),
218
- isDeprecated: field.isDeprecated,
219
- deprecationReason: field.deprecationReason,
220
- });
221
- }
222
- }
223
-
224
- return {
225
- type: "graphql",
226
- title: null,
227
- endpoint: url,
228
- operations,
229
- types: schema.types.filter((t) => !t.name.startsWith("__")),
230
- raw: schema,
231
- };
232
- }
233
-
234
- function parseOpenAPI(text, source) {
235
- let doc;
236
- try {
237
- doc = JSON.parse(text);
238
- } catch {
239
- doc = YAML.parse(text);
240
- }
241
-
242
- const version = doc.openapi || doc.swagger;
243
- if (!version) throw new Error("Not a valid OpenAPI/Swagger spec");
244
-
245
- const operations = [];
246
-
247
- for (const [path, methods] of Object.entries(doc.paths || {})) {
248
- for (const [method, op] of Object.entries(methods)) {
249
- if (method.startsWith("x-") || method === "parameters") continue;
250
- operations.push({
251
- id: op.operationId || `${method.toUpperCase()} ${path}`,
252
- method: method.toUpperCase(),
253
- path,
254
- summary: op.summary || null,
255
- description: op.description || null,
256
- parameters: op.parameters || [],
257
- requestBody: op.requestBody || null,
258
- responses: op.responses || {},
259
- tags: op.tags || [],
260
- deprecated: op.deprecated || false,
261
- });
262
- }
263
- }
264
-
265
- return {
266
- type: "openapi",
267
- version,
268
- title: doc.info?.title || null,
269
- description: doc.info?.description || null,
270
- servers: doc.servers || (doc.host ? [{ url: `${doc.schemes?.[0] || "https"}://${doc.host}${doc.basePath || ""}` }] : []),
271
- operations,
272
- components: doc.components || doc.definitions || {},
273
- raw: doc,
274
- };
275
- }
276
-
277
- function flattenType(t) {
278
- if (!t) return null;
279
- if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
280
- if (t.ofType) {
281
- const inner = flattenType(t.ofType);
282
- if (t.kind === "LIST") return `[${inner}]`;
283
- if (t.kind === "NON_NULL") return `${inner}!`;
284
- return inner;
285
- }
286
- return t.kind;
287
- }
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import YAML from "yaml";
4
+ import { parseKV } from "../args.js";
5
+ import { createMcpClient } from "../mcp-client.js";
6
+ import { matchFilter } from "../glob.js";
7
+
8
+ const INTROSPECTION_QUERY = `{
9
+ __schema {
10
+ queryType { name }
11
+ mutationType { name }
12
+ subscriptionType { name }
13
+ types {
14
+ name
15
+ kind
16
+ description
17
+ fields(includeDeprecated: true) {
18
+ name
19
+ description
20
+ isDeprecated
21
+ deprecationReason
22
+ args {
23
+ name
24
+ description
25
+ type { ...TypeRef }
26
+ defaultValue
27
+ }
28
+ type { ...TypeRef }
29
+ }
30
+ inputFields {
31
+ name
32
+ description
33
+ type { ...TypeRef }
34
+ defaultValue
35
+ }
36
+ enumValues(includeDeprecated: true) {
37
+ name
38
+ description
39
+ isDeprecated
40
+ deprecationReason
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ fragment TypeRef on __Type {
47
+ kind
48
+ name
49
+ ofType {
50
+ kind
51
+ name
52
+ ofType {
53
+ kind
54
+ name
55
+ ofType {
56
+ kind
57
+ name
58
+ }
59
+ }
60
+ }
61
+ }`;
62
+
63
+ function applyFilter(items, nameFn, allowed, disabled) {
64
+ let result = items;
65
+ if (allowed?.length)
66
+ result = result.filter((item) => allowed.some((p) => matchFilter(p, nameFn(item))));
67
+ if (disabled?.length)
68
+ result = result.filter((item) => !disabled.some((p) => matchFilter(p, nameFn(item))));
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Resolve a spec from a registry entry or inline flags entry.
74
+ * Entry shape:
75
+ * { type: "openapi", source: "<url-or-file>", config: { headers, auth } }
76
+ * { type: "graphql", source: "<url>", config: { headers, auth } }
77
+ * { _section: "mcp", type: "stdio"|"sse"|"http", url?, command?, args?, env?, headers? }
78
+ */
79
+ export async function fetchSpec(entry) {
80
+ if (entry._section === "mcp") return await loadMCPFromEntry(entry);
81
+ if (entry._section === "graphql") {
82
+ const spec = await loadGraphQL(entry.source, entry.config?.headers);
83
+ return {
84
+ ...spec,
85
+ operations: applyFilter(
86
+ spec.operations,
87
+ (op) => op.name,
88
+ entry.config?.allowedTools,
89
+ entry.config?.disabledTools
90
+ ),
91
+ };
92
+ }
93
+ // openapi
94
+ const isUrl = entry.source?.startsWith("http://") || entry.source?.startsWith("https://");
95
+ const spec = isUrl ? await loadFromUrl(entry.source, true) : loadFromFile(entry.source);
96
+ return {
97
+ ...spec,
98
+ operations: applyFilter(
99
+ spec.operations,
100
+ (op) => op.id,
101
+ entry.config?.allowedTools,
102
+ entry.config?.disabledTools
103
+ ),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Build an inline entry from flags (for ad-hoc commands like --mcp-http <url>).
109
+ * Returns null if no inline source flags present.
110
+ */
111
+ export function inlineEntryFromFlags(flags) {
112
+ const allowed = flags["allow-tool"];
113
+ const disabled = flags["disable-tool"];
114
+ const filterConfig = {
115
+ ...(allowed?.length ? { allowedTools: allowed } : {}),
116
+ ...(disabled?.length ? { disabledTools: disabled } : {}),
117
+ };
118
+
119
+ if (flags["mcp-stdio"]) {
120
+ const raw = flags["mcp-stdio"];
121
+ const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) =>
122
+ p.replace(/^"|"$/g, "")
123
+ );
124
+ if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
125
+ return {
126
+ _section: "mcp",
127
+ type: "stdio",
128
+ command: parts[0],
129
+ args: parts.slice(1),
130
+ cwd: flags.cwd,
131
+ env: parseKV(flags.env),
132
+ ...filterConfig,
133
+ };
134
+ }
135
+ if (flags["mcp-sse"]) {
136
+ const headers = parseKV(flags.header);
137
+ if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
138
+ return {
139
+ _section: "mcp",
140
+ type: "sse",
141
+ url: flags["mcp-sse"],
142
+ ...(Object.keys(headers).length ? { headers } : {}),
143
+ ...filterConfig,
144
+ };
145
+ }
146
+ if (flags["mcp-http"]) {
147
+ const headers = parseKV(flags.header);
148
+ if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
149
+ return {
150
+ _section: "mcp",
151
+ type: "http",
152
+ url: flags["mcp-http"],
153
+ ...(Object.keys(headers).length ? { headers } : {}),
154
+ ...filterConfig,
155
+ };
156
+ }
157
+ if (flags.graphql) {
158
+ return {
159
+ _section: "graphql",
160
+ type: "graphql",
161
+ source: flags.graphql,
162
+ config: { headers: parseKV(flags.header), ...filterConfig },
163
+ };
164
+ }
165
+ if (flags.openapi) {
166
+ return {
167
+ _section: "openapi",
168
+ type: "openapi",
169
+ source: flags.openapi,
170
+ config: {
171
+ headers: parseKV(flags.header),
172
+ baseUrl: flags["base-url"] || null,
173
+ ...filterConfig,
174
+ },
175
+ };
176
+ }
177
+ return null;
178
+ }
179
+
180
+ // --- Internal loaders ---
181
+
182
+ async function loadMCPFromEntry(entry) {
183
+ const client = await createMcpClient(entry);
184
+ try {
185
+ const { tools } = await client.listTools();
186
+ let mapped = tools.map((t) => ({
187
+ name: t.name,
188
+ description: t.description || null,
189
+ inputSchema: t.inputSchema || null,
190
+ }));
191
+
192
+ mapped = applyFilter(mapped, (t) => t.name, entry.allowedTools, entry.disabledTools);
193
+
194
+ return {
195
+ type: "mcp",
196
+ title: entry.name || "MCP Server",
197
+ transport: entry.type,
198
+ url: entry.url,
199
+ command: entry.command,
200
+ args: entry.args,
201
+ cwd: entry.cwd,
202
+ tools: mapped,
203
+ };
204
+ } finally {
205
+ await client.close();
206
+ }
207
+ }
208
+
209
+ async function loadFromUrl(url, skipGraphQLProbe = false) {
210
+ const lowerUrl = url.toLowerCase();
211
+ const isLikelyFile =
212
+ lowerUrl.endsWith(".json") || lowerUrl.endsWith(".yaml") || lowerUrl.endsWith(".yml");
213
+
214
+ if (!isLikelyFile && !skipGraphQLProbe) {
215
+ try {
216
+ return await loadGraphQL(url);
217
+ } catch {
218
+ // Fall through to OpenAPI
219
+ }
220
+ }
221
+
222
+ const res = await fetch(url);
223
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
224
+ const text = await res.text();
225
+ return parseOpenAPI(text, url);
226
+ }
227
+
228
+ function loadFromFile(path) {
229
+ const abs = resolve(path);
230
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
231
+ const text = readFileSync(abs, "utf-8");
232
+ return parseOpenAPI(text, path);
233
+ }
234
+
235
+ async function loadGraphQL(url, extraHeaders = {}) {
236
+ const res = await fetch(url, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json", ...extraHeaders },
239
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
240
+ });
241
+
242
+ if (!res.ok) throw new Error(`GraphQL introspection failed: HTTP ${res.status}`);
243
+
244
+ const json = await res.json();
245
+ if (json.errors && !json.data) {
246
+ throw new Error(`GraphQL error: ${json.errors[0].message}`);
247
+ }
248
+
249
+ const schema = json.data.__schema;
250
+ const operations = [];
251
+ const typeMap = {};
252
+ for (const t of schema.types) {
253
+ typeMap[t.name] = t;
254
+ }
255
+
256
+ for (const [kind, rootType] of [
257
+ ["query", schema.queryType],
258
+ ["mutation", schema.mutationType],
259
+ ["subscription", schema.subscriptionType],
260
+ ]) {
261
+ if (!rootType) continue;
262
+ const type = typeMap[rootType.name];
263
+ if (!type || !type.fields) continue;
264
+ for (const field of type.fields) {
265
+ operations.push({
266
+ kind,
267
+ name: field.name,
268
+ description: field.description,
269
+ args: field.args,
270
+ returnType: flattenType(field.type),
271
+ isDeprecated: field.isDeprecated,
272
+ deprecationReason: field.deprecationReason,
273
+ });
274
+ }
275
+ }
276
+
277
+ return {
278
+ type: "graphql",
279
+ title: null,
280
+ endpoint: url,
281
+ operations,
282
+ types: schema.types.filter((t) => !t.name.startsWith("__")),
283
+ raw: schema,
284
+ };
285
+ }
286
+
287
+ function parseOpenAPI(text, source) {
288
+ let doc;
289
+ try {
290
+ doc = JSON.parse(text);
291
+ } catch {
292
+ doc = YAML.parse(text);
293
+ }
294
+
295
+ const version = doc.openapi || doc.swagger;
296
+ if (!version) throw new Error("Not a valid OpenAPI/Swagger spec");
297
+
298
+ const operations = [];
299
+
300
+ for (const [path, methods] of Object.entries(doc.paths || {})) {
301
+ for (const [method, op] of Object.entries(methods)) {
302
+ if (method.startsWith("x-") || method === "parameters") continue;
303
+ operations.push({
304
+ id: op.operationId || `${method.toUpperCase()} ${path}`,
305
+ method: method.toUpperCase(),
306
+ path,
307
+ summary: op.summary || null,
308
+ description: op.description || null,
309
+ parameters: op.parameters || [],
310
+ requestBody: op.requestBody || null,
311
+ responses: op.responses || {},
312
+ tags: op.tags || [],
313
+ deprecated: op.deprecated || false,
314
+ });
315
+ }
316
+ }
317
+
318
+ return {
319
+ type: "openapi",
320
+ version,
321
+ title: doc.info?.title || null,
322
+ description: doc.info?.description || null,
323
+ servers:
324
+ doc.servers ||
325
+ (doc.host
326
+ ? [{ url: `${doc.schemes?.[0] || "https"}://${doc.host}${doc.basePath || ""}` }]
327
+ : []),
328
+ operations,
329
+ components: doc.components || doc.definitions || {},
330
+ raw: doc,
331
+ };
332
+ }
333
+
334
+ function flattenType(t) {
335
+ if (!t) return null;
336
+ if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
337
+ if (t.ofType) {
338
+ const inner = flattenType(t.ofType);
339
+ if (t.kind === "LIST") return `[${inner}]`;
340
+ if (t.kind === "NON_NULL") return `${inner}!`;
341
+ return inner;
342
+ }
343
+ return t.kind;
344
+ }