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,207 +1,220 @@
1
- import { readFileSync } from "fs";
2
- import { out } from "../output.js";
3
- import { parseArgs, parseKV } from "../args.js";
4
- import { createMcpClient } from "../mcp-client.js";
5
- import { resolveSpec, resolveConfig } from "../resolve.js";
6
-
7
- const HTTP_TIMEOUT = parseInt(process.env.SPEC_HTTP_TIMEOUT ?? "30000");
8
-
9
- export async function callOperation(args) {
10
- const { flags, positional } = parseArgs(args);
11
- const target = positional[0];
12
- if (!target) throw new Error(
13
- "Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}' | --data -] [--var k=v] [--header k=v]"
14
- );
15
-
16
- if (flags["data-file"] && !flags.data) {
17
- flags.data = readFileSync(flags["data-file"], "utf-8").trim();
18
- }
19
-
20
- // Read from stdin only when --data - is explicitly passed.
21
- // Agents run in non-TTY environments auto-detecting stdin would add latency on every call.
22
- if (flags.data === "-") {
23
- const chunks = [];
24
- for await (const chunk of process.stdin) chunks.push(chunk);
25
- flags.data = Buffer.concat(chunks).toString("utf-8").trim();
26
- }
27
-
28
- const { spec, entry } = await resolveSpec(flags);
29
- const config = resolveConfig(flags, entry);
30
-
31
- if (spec.type === "openapi") {
32
- await callOpenAPI(spec, config, target, flags);
33
- } else if (spec.type === "mcp") {
34
- await callMCP(spec, entry, target, flags);
35
- } else {
36
- await callGraphQL(spec, config, target, flags);
37
- }
38
- }
39
-
40
- async function callMCP(spec, entry, target, flags) {
41
- const tool = spec.tools.find((t) => t.name.toLowerCase() === target.toLowerCase());
42
- if (!tool) throw new Error(`Tool not found: ${target}. Run 'spec list' to see available tools.`);
43
-
44
- let toolArgs = {};
45
- if (flags.data) {
46
- try {
47
- toolArgs = JSON.parse(flags.data);
48
- } catch {
49
- throw new Error("--data must be valid JSON when calling an MCP tool");
50
- }
51
- }
52
- const varOverrides = parseKV(flags.var);
53
- toolArgs = { ...toolArgs, ...varOverrides };
54
-
55
- // Re-connect using the original entry (which holds transport config + headers/env)
56
- const client = await createMcpClient(entry);
57
- try {
58
- const result = await client.callTool({ name: tool.name, arguments: toolArgs });
59
- // Normalize MCP result: expose isError and content at the top level
60
- const isError = result.isError === true;
61
- out({ tool: tool.name, arguments: toolArgs, isError, content: result.content, result });
62
- if (isError) process.exit(1);
63
- } finally {
64
- await client.close();
65
- }
66
- }
67
-
68
- async function callOpenAPI(spec, config, target, flags) {
69
- const lower = target.toLowerCase();
70
-
71
- const op = spec.operations.find((o) =>
72
- o.id.toLowerCase() === lower ||
73
- o.path.toLowerCase() === lower ||
74
- `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
75
- );
76
-
77
- if (!op) throw new Error(`Operation not found: ${target}`);
78
-
79
- const baseUrl = config.baseUrl || spec.servers?.[0]?.url || "";
80
- let path = op.path;
81
-
82
- const vars = parseKV(flags.var);
83
- for (const [key, val] of Object.entries(vars)) {
84
- path = path.replaceAll(`{${key}}`, encodeURIComponent(val));
85
- }
86
-
87
- // Detect unreplaced path parameters and error clearly
88
- const missing = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
89
- if (missing.length > 0) {
90
- throw new Error(`Missing required path parameters: ${missing.join(", ")}. Pass --var ${missing[0]}=<value>`);
91
- }
92
-
93
- const queryParams = parseKV(flags.query);
94
- const qs = new URLSearchParams(queryParams).toString();
95
- const url = `${baseUrl}${path}${qs ? "?" + qs : ""}`;
96
-
97
- const method = (flags.method || op.method).toUpperCase();
98
- const headers = { ...config.headers };
99
-
100
- let body = undefined;
101
- if (flags.data) {
102
- body = flags.data;
103
- if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
104
- }
105
-
106
- const res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
107
- const contentType = res.headers.get("content-type") || "";
108
- const responseBody = contentType.includes("json") ? await res.json() : await res.text();
109
-
110
- out({
111
- status: res.status,
112
- statusText: res.statusText,
113
- headers: Object.fromEntries(res.headers.entries()),
114
- body: responseBody,
115
- });
116
- }
117
-
118
- async function callGraphQL(spec, config, target, flags) {
119
- const lower = target.toLowerCase();
120
-
121
- const op = spec.operations.find((o) => o.name.toLowerCase() === lower);
122
- if (!op) throw new Error(`Operation not found: ${target}`);
123
-
124
- const endpoint = config.baseUrl || spec.endpoint;
125
- if (!endpoint) throw new Error("No GraphQL endpoint. Set --base-url or register with --graphql <url>.");
126
-
127
- let query;
128
- let dataVariables;
129
- if (flags.data) {
130
- try {
131
- const parsed = JSON.parse(flags.data);
132
- query = parsed.query || flags.data;
133
- dataVariables = parsed.variables;
134
- } catch {
135
- query = flags.data;
136
- }
137
- } else {
138
- query = buildGraphQLQuery(op, spec.types);
139
- }
140
-
141
- const varOverrides = parseKV(flags.var);
142
- const variables = { ...dataVariables, ...varOverrides };
143
-
144
- const headers = {
145
- "Content-Type": "application/json",
146
- ...config.headers,
147
- };
148
-
149
- const body = JSON.stringify({
150
- query,
151
- variables: Object.keys(variables).length > 0 ? variables : undefined,
152
- });
153
-
154
- const res = await fetch(endpoint, { method: "POST", headers, body, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
155
- const contentType = res.headers.get("content-type") || "";
156
- const responseBody = contentType.includes("json") ? await res.json() : await res.text();
157
-
158
- out({
159
- status: res.status,
160
- query,
161
- variables: Object.keys(variables).length > 0 ? variables : undefined,
162
- data: responseBody?.data || null,
163
- errors: responseBody?.errors || null,
164
- });
165
- }
166
-
167
- function buildGraphQLQuery(op, types) {
168
- const args = op.args || [];
169
- const argsStr = args.length > 0
170
- ? `(${args.map((a) => `$${a.name}: ${flattenType(a.type)}`).join(", ")})`
171
- : "";
172
- const passArgs = args.length > 0
173
- ? `(${args.map((a) => `${a.name}: $${a.name}`).join(", ")})`
174
- : "";
175
-
176
- const returnTypeName = op.returnType?.replace(/[[\]!]/g, "");
177
- const returnType = types?.find((t) => t.name === returnTypeName);
178
- let fields = "";
179
-
180
- if (returnType?.fields) {
181
- const scalarFields = returnType.fields
182
- .filter((f) => {
183
- const typeName = flattenType(f.type)?.replace(/[[\]!]/g, "");
184
- const t = types?.find((tt) => tt.name === typeName);
185
- return !t || t.kind === "SCALAR" || t.kind === "ENUM";
186
- })
187
- .map((f) => f.name);
188
-
189
- if (scalarFields.length > 0) fields = ` { ${scalarFields.join(" ")} }`;
190
- }
191
-
192
- const keyword = op.kind === "mutation" ? "mutation" : "query";
193
- return `${keyword}${argsStr} { ${op.name}${passArgs}${fields} }`;
194
- }
195
-
196
- function flattenType(t) {
197
- if (!t) return null;
198
- if (typeof t === "string") return t;
199
- if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
200
- if (t.ofType) {
201
- const inner = flattenType(t.ofType);
202
- if (t.kind === "LIST") return `[${inner}]`;
203
- if (t.kind === "NON_NULL") return `${inner}!`;
204
- return inner;
205
- }
206
- return t.kind;
207
- }
1
+ import { readFileSync } from "fs";
2
+ import { out } from "../output.js";
3
+ import { parseArgs, parseKV } from "../args.js";
4
+ import { createMcpClient } from "../mcp-client.js";
5
+ import { resolveSpec, resolveConfig } from "../resolve.js";
6
+
7
+ const HTTP_TIMEOUT = parseInt(process.env.SPEC_HTTP_TIMEOUT ?? "30000");
8
+
9
+ export async function callOperation(args) {
10
+ const { flags, positional } = parseArgs(args);
11
+ const target = positional[0];
12
+ if (!target)
13
+ throw new Error(
14
+ "Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}' | --data -] [--var k=v] [--header k=v]"
15
+ );
16
+
17
+ if (flags["data-file"] && !flags.data) {
18
+ flags.data = readFileSync(flags["data-file"], "utf-8").trim();
19
+ }
20
+
21
+ // Read from stdin only when --data - is explicitly passed.
22
+ // Agents run in non-TTY environments — auto-detecting stdin would add latency on every call.
23
+ if (flags.data === "-") {
24
+ const chunks = [];
25
+ for await (const chunk of process.stdin) chunks.push(chunk);
26
+ flags.data = Buffer.concat(chunks).toString("utf-8").trim();
27
+ }
28
+
29
+ const { spec, entry } = await resolveSpec(flags);
30
+ const config = resolveConfig(flags, entry);
31
+
32
+ if (spec.type === "openapi") {
33
+ await callOpenAPI(spec, config, target, flags);
34
+ } else if (spec.type === "mcp") {
35
+ await callMCP(spec, entry, target, flags);
36
+ } else {
37
+ await callGraphQL(spec, config, target, flags);
38
+ }
39
+ }
40
+
41
+ async function callMCP(spec, entry, target, flags) {
42
+ const tool = spec.tools.find((t) => t.name.toLowerCase() === target.toLowerCase());
43
+ if (!tool) throw new Error(`Tool not found: ${target}. Run 'spec list' to see available tools.`);
44
+
45
+ let toolArgs = {};
46
+ if (flags.data) {
47
+ try {
48
+ toolArgs = JSON.parse(flags.data);
49
+ } catch {
50
+ throw new Error("--data must be valid JSON when calling an MCP tool");
51
+ }
52
+ }
53
+ const varOverrides = parseKV(flags.var);
54
+ toolArgs = { ...toolArgs, ...varOverrides };
55
+
56
+ // Re-connect using the original entry (which holds transport config + headers/env)
57
+ const client = await createMcpClient(entry);
58
+ try {
59
+ const result = await client.callTool({ name: tool.name, arguments: toolArgs });
60
+ // Normalize MCP result: expose isError and content at the top level
61
+ const isError = result.isError === true;
62
+ out({ tool: tool.name, arguments: toolArgs, isError, content: result.content, result });
63
+ if (isError) process.exit(1);
64
+ } finally {
65
+ await client.close();
66
+ }
67
+ }
68
+
69
+ async function callOpenAPI(spec, config, target, flags) {
70
+ const lower = target.toLowerCase();
71
+
72
+ const op = spec.operations.find(
73
+ (o) =>
74
+ o.id.toLowerCase() === lower ||
75
+ o.path.toLowerCase() === lower ||
76
+ `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
77
+ );
78
+
79
+ if (!op) throw new Error(`Operation not found: ${target}`);
80
+
81
+ const baseUrl = config.baseUrl || spec.servers?.[0]?.url || "";
82
+ let path = op.path;
83
+
84
+ const vars = parseKV(flags.var);
85
+ for (const [key, val] of Object.entries(vars)) {
86
+ path = path.replaceAll(`{${key}}`, encodeURIComponent(val));
87
+ }
88
+
89
+ // Detect unreplaced path parameters and error clearly
90
+ const missing = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
91
+ if (missing.length > 0) {
92
+ throw new Error(
93
+ `Missing required path parameters: ${missing.join(", ")}. Pass --var ${missing[0]}=<value>`
94
+ );
95
+ }
96
+
97
+ const queryParams = parseKV(flags.query);
98
+ const qs = new URLSearchParams(queryParams).toString();
99
+ const url = `${baseUrl}${path}${qs ? "?" + qs : ""}`;
100
+
101
+ const method = (flags.method || op.method).toUpperCase();
102
+ const headers = { ...config.headers };
103
+
104
+ let body = undefined;
105
+ if (flags.data) {
106
+ body = flags.data;
107
+ if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
108
+ }
109
+
110
+ const res = await fetch(url, {
111
+ method,
112
+ headers,
113
+ body,
114
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
115
+ });
116
+ const contentType = res.headers.get("content-type") || "";
117
+ const responseBody = contentType.includes("json") ? await res.json() : await res.text();
118
+
119
+ out({
120
+ status: res.status,
121
+ statusText: res.statusText,
122
+ headers: Object.fromEntries(res.headers.entries()),
123
+ body: responseBody,
124
+ });
125
+ }
126
+
127
+ async function callGraphQL(spec, config, target, flags) {
128
+ const lower = target.toLowerCase();
129
+
130
+ const op = spec.operations.find((o) => o.name.toLowerCase() === lower);
131
+ if (!op) throw new Error(`Operation not found: ${target}`);
132
+
133
+ const endpoint = config.baseUrl || spec.endpoint;
134
+ if (!endpoint)
135
+ throw new Error("No GraphQL endpoint. Set --base-url or register with --graphql <url>.");
136
+
137
+ let query;
138
+ let dataVariables;
139
+ if (flags.data) {
140
+ try {
141
+ const parsed = JSON.parse(flags.data);
142
+ query = parsed.query || flags.data;
143
+ dataVariables = parsed.variables;
144
+ } catch {
145
+ query = flags.data;
146
+ }
147
+ } else {
148
+ query = buildGraphQLQuery(op, spec.types);
149
+ }
150
+
151
+ const varOverrides = parseKV(flags.var);
152
+ const variables = { ...dataVariables, ...varOverrides };
153
+
154
+ const headers = {
155
+ "Content-Type": "application/json",
156
+ ...config.headers,
157
+ };
158
+
159
+ const body = JSON.stringify({
160
+ query,
161
+ variables: Object.keys(variables).length > 0 ? variables : undefined,
162
+ });
163
+
164
+ const res = await fetch(endpoint, {
165
+ method: "POST",
166
+ headers,
167
+ body,
168
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
169
+ });
170
+ const contentType = res.headers.get("content-type") || "";
171
+ const responseBody = contentType.includes("json") ? await res.json() : await res.text();
172
+
173
+ out({
174
+ status: res.status,
175
+ query,
176
+ variables: Object.keys(variables).length > 0 ? variables : undefined,
177
+ data: responseBody?.data || null,
178
+ errors: responseBody?.errors || null,
179
+ });
180
+ }
181
+
182
+ function buildGraphQLQuery(op, types) {
183
+ const args = op.args || [];
184
+ const argsStr =
185
+ args.length > 0 ? `(${args.map((a) => `$${a.name}: ${flattenType(a.type)}`).join(", ")})` : "";
186
+ const passArgs =
187
+ args.length > 0 ? `(${args.map((a) => `${a.name}: $${a.name}`).join(", ")})` : "";
188
+
189
+ const returnTypeName = op.returnType?.replace(/[[\]!]/g, "");
190
+ const returnType = types?.find((t) => t.name === returnTypeName);
191
+ let fields = "";
192
+
193
+ if (returnType?.fields) {
194
+ const scalarFields = returnType.fields
195
+ .filter((f) => {
196
+ const typeName = flattenType(f.type)?.replace(/[[\]!]/g, "");
197
+ const t = types?.find((tt) => tt.name === typeName);
198
+ return !t || t.kind === "SCALAR" || t.kind === "ENUM";
199
+ })
200
+ .map((f) => f.name);
201
+
202
+ if (scalarFields.length > 0) fields = ` { ${scalarFields.join(" ")} }`;
203
+ }
204
+
205
+ const keyword = op.kind === "mutation" ? "mutation" : "query";
206
+ return `${keyword}${argsStr} { ${op.name}${passArgs}${fields} }`;
207
+ }
208
+
209
+ function flattenType(t) {
210
+ if (!t) return null;
211
+ if (typeof t === "string") return t;
212
+ if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
213
+ if (t.ofType) {
214
+ const inner = flattenType(t.ofType);
215
+ if (t.kind === "LIST") return `[${inner}]`;
216
+ if (t.kind === "NON_NULL") return `${inner}!`;
217
+ return inner;
218
+ }
219
+ return t.kind;
220
+ }