api-spec-cli 0.2.0 → 0.2.2
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.
- package/README.md +48 -4
- package/package.json +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +14 -0
- package/src/commands/add.js +15 -2
- package/src/commands/call.js +30 -10
- package/src/commands/{load.js → fetch.js} +16 -6
- package/src/commands/grep.js +64 -0
- package/src/commands/list.js +2 -2
- package/src/commands/show.js +2 -2
- package/src/commands/specs.js +2 -2
- package/src/commands/types.js +2 -2
- package/src/glob.js +15 -0
- package/src/mcp-client.js +33 -2
- package/src/resolve.js +4 -4
package/README.md
CHANGED
|
@@ -56,6 +56,19 @@ Inline fetches every call, nothing cached.
|
|
|
56
56
|
|
|
57
57
|
## Discovery
|
|
58
58
|
|
|
59
|
+
### Search across specs
|
|
60
|
+
|
|
61
|
+
`grep` searches operation/tool names and descriptions across all registered specs.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
spec grep search # Substring match across all specs
|
|
65
|
+
spec grep "get*" # Glob: anything starting with "get"
|
|
66
|
+
spec grep "*list*" # Glob: anything containing "list"
|
|
67
|
+
spec grep search --spec agno # Limit to one spec
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Matches on name and description. Case-insensitive. Plain text = substring, `*`/`?` = glob.
|
|
71
|
+
|
|
59
72
|
### List all specs in the registry
|
|
60
73
|
|
|
61
74
|
```bash
|
|
@@ -131,6 +144,10 @@ spec call --spec hashnode publication --var host=blog.hashnode.dev
|
|
|
131
144
|
spec call --spec agno search_agno --var query="how to create an agent"
|
|
132
145
|
spec call --spec agno search_agno --data '{"query":"agents"}'
|
|
133
146
|
|
|
147
|
+
# Read body from stdin (explicit --data -)
|
|
148
|
+
echo '{"query":"agents"}' | spec call --spec agno search_agno --data -
|
|
149
|
+
cat body.json | spec call --spec petstore addPet --data -
|
|
150
|
+
|
|
134
151
|
# Inline (no registration)
|
|
135
152
|
spec call --openapi https://petstore3.swagger.io/api/v3/openapi.json \
|
|
136
153
|
getPetById --var petId=1 --base-url https://petstore3.swagger.io/api/v3
|
|
@@ -164,13 +181,23 @@ spec refresh <name> # Force re-fetch and update cache
|
|
|
164
181
|
```bash
|
|
165
182
|
spec add <name> --openapi <url-or-file> [--base-url <url>] [--auth <token>] [--header k=v]
|
|
166
183
|
spec add <name> --graphql <url> [--auth <token>] [--header k=v]
|
|
167
|
-
spec add <name> --mcp-http <url> [--header k=v]
|
|
168
|
-
spec add <name> --mcp-sse <url> [--header k=v]
|
|
169
|
-
spec add <name> --mcp-stdio "<cmd args>" [--env KEY=VAL]
|
|
184
|
+
spec add <name> --mcp-http <url> [--auth <token>] [--header k=v]
|
|
185
|
+
spec add <name> --mcp-sse <url> [--auth <token>] [--header k=v]
|
|
186
|
+
spec add <name> --mcp-stdio "<cmd args>" [--env KEY=VAL] [--cwd <path>]
|
|
170
187
|
[--description <text>] (all types)
|
|
171
188
|
```
|
|
172
189
|
|
|
173
|
-
|
|
190
|
+
All options are repeatable where it makes sense (`--header`, `--env`). `--auth` adds `Authorization: Bearer <token>` unless the header is already set.
|
|
191
|
+
|
|
192
|
+
For MCP entries, tool filtering is available:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
spec add <name> --mcp-http <url> \
|
|
196
|
+
--allow-tool "read_*" --allow-tool "list_*" \
|
|
197
|
+
--disable-tool "delete_*"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`--allow-tool` keeps only matching tools. `--disable-tool` removes matching tools (applied after allow). Both accept glob patterns (`*`, `?`) or plain substrings.
|
|
174
201
|
|
|
175
202
|
---
|
|
176
203
|
|
|
@@ -214,6 +241,23 @@ spec list --spec petstore --format=json # equals syntax also works
|
|
|
214
241
|
- `--limit` / `--offset` paginate large APIs
|
|
215
242
|
- `--filter` and `--tag` narrow results before output
|
|
216
243
|
|
|
244
|
+
## MCP Options
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
# Retry on connection failure (useful for stdio servers that take time to start)
|
|
248
|
+
MCP_MAX_RETRIES=3 # Attempts (default: 3)
|
|
249
|
+
MCP_RETRY_DELAY=1000 # Base delay in ms, doubles each attempt, capped at 5s (default: 1000)
|
|
250
|
+
|
|
251
|
+
# HTTP timeout for OpenAPI/GraphQL calls
|
|
252
|
+
SPEC_HTTP_TIMEOUT=30000 # ms (default: 30000)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Stdio env vars support `${VAR}` expansion from the host environment:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
spec add fs --mcp-stdio "npx -y server /tmp" --env "TOKEN=${MY_SECRET}"
|
|
259
|
+
```
|
|
260
|
+
|
|
217
261
|
## Storage
|
|
218
262
|
|
|
219
263
|
| Path | Purpose |
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Supports: --flag value, --flag=value, and positional args
|
|
3
3
|
// Repeatable flags (--query, --header, --var) are collected into arrays.
|
|
4
4
|
|
|
5
|
-
const REPEATABLE = new Set(["query", "header", "var", "env"]);
|
|
5
|
+
const REPEATABLE = new Set(["query", "header", "var", "env", "allow-tool", "disable-tool"]);
|
|
6
6
|
|
|
7
7
|
export function parseArgs(args) {
|
|
8
8
|
const flags = {};
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { validateSpec } from "./commands/validate.js";
|
|
|
6
6
|
import { typesCmd } from "./commands/types.js";
|
|
7
7
|
import { addCmd } from "./commands/add.js";
|
|
8
8
|
import { specsCmd, registryMutate } from "./commands/specs.js";
|
|
9
|
+
import { grepCmd } from "./commands/grep.js";
|
|
9
10
|
import { out, err, setFormat } from "./output.js";
|
|
10
11
|
|
|
11
12
|
const HELP = `spec-cli — Explore and call APIs from the command line.
|
|
@@ -29,6 +30,9 @@ REGISTRY (register once, use anywhere):
|
|
|
29
30
|
spec add <name> --mcp-stdio "<cmd>" Register an MCP server (stdio)
|
|
30
31
|
Options: --description <text> --base-url <url> --auth <token>
|
|
31
32
|
--header k=v (repeatable) --env KEY=VAL (repeatable, stdio only)
|
|
33
|
+
--cwd <path> (stdio only)
|
|
34
|
+
--allow-tool <glob> (repeatable, MCP only)
|
|
35
|
+
--disable-tool <glob> (repeatable, MCP only)
|
|
32
36
|
|
|
33
37
|
spec specs List all registered specs
|
|
34
38
|
spec specs --compact false Show full entry config
|
|
@@ -43,6 +47,8 @@ DISCOVER:
|
|
|
43
47
|
spec list --spec <name> --tag pets OpenAPI tag or GraphQL kind
|
|
44
48
|
spec list --spec <name> --limit 10 Paginate
|
|
45
49
|
spec list --mcp-http <url> Inline: no registration needed
|
|
50
|
+
spec grep <pattern> Search across all registered specs
|
|
51
|
+
spec grep <pattern> --spec <name> Search within one spec
|
|
46
52
|
|
|
47
53
|
INSPECT:
|
|
48
54
|
spec show --spec <name> <op> Operation details (params, body, responses)
|
|
@@ -55,6 +61,7 @@ CALL:
|
|
|
55
61
|
spec call --spec <name> <op> --query status=available Query params
|
|
56
62
|
spec call --spec <name> <op> --data '{"name":"Rex"}' JSON body / MCP args
|
|
57
63
|
spec call --spec <name> <op> --data-file args.json Body from file
|
|
64
|
+
spec call --spec <name> <op> --data - Read JSON body from stdin (pipe)
|
|
58
65
|
spec call --spec <name> <op> --header X-Custom=val Extra headers
|
|
59
66
|
spec call --spec <name> <op> --method PUT Override HTTP method
|
|
60
67
|
|
|
@@ -74,6 +81,10 @@ OTHER:
|
|
|
74
81
|
spec validate <file-or-url> Check OpenAPI spec for errors
|
|
75
82
|
--format json|text|yaml Output format (default: json)
|
|
76
83
|
|
|
84
|
+
ENV VARS (MCP):
|
|
85
|
+
MCP_MAX_RETRIES=3 Retry attempts on connection failure (default: 3)
|
|
86
|
+
MCP_RETRY_DELAY=1000 Base retry delay in ms, doubles each attempt (default: 1000)
|
|
87
|
+
|
|
77
88
|
EXAMPLES:
|
|
78
89
|
spec add agno --mcp-http https://docs.agno.com/mcp --description "Agno docs"
|
|
79
90
|
spec add petstore --openapi https://petstore3.swagger.io/api/v3/openapi.json \\
|
|
@@ -148,6 +159,9 @@ export async function run(args) {
|
|
|
148
159
|
case "refresh":
|
|
149
160
|
await registryMutate("refresh", args.slice(1));
|
|
150
161
|
break;
|
|
162
|
+
case "grep":
|
|
163
|
+
await grepCmd(args.slice(1));
|
|
164
|
+
break;
|
|
151
165
|
default:
|
|
152
166
|
err(`Unknown command: ${cmd}. Run 'spec help' for usage.`);
|
|
153
167
|
}
|
package/src/commands/add.js
CHANGED
|
@@ -44,23 +44,36 @@ export async function addCmd(args) {
|
|
|
44
44
|
entry.transport = "stdio";
|
|
45
45
|
entry.command = parts[0];
|
|
46
46
|
entry.args = parts.slice(1);
|
|
47
|
+
if (flags.cwd) entry.cwd = flags.cwd;
|
|
47
48
|
entry.config = { env: parseKV(flags.env) };
|
|
48
49
|
} else if (flags["mcp-sse"]) {
|
|
49
50
|
entry.type = "mcp";
|
|
50
51
|
entry.transport = "sse";
|
|
51
52
|
entry.url = flags["mcp-sse"];
|
|
52
|
-
|
|
53
|
+
const headers = parseKV(flags.header);
|
|
54
|
+
if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
|
|
55
|
+
entry.config = { headers };
|
|
53
56
|
} else if (flags["mcp-http"]) {
|
|
54
57
|
entry.type = "mcp";
|
|
55
58
|
entry.transport = "streamable-http";
|
|
56
59
|
entry.url = flags["mcp-http"];
|
|
57
|
-
|
|
60
|
+
const headers = parseKV(flags.header);
|
|
61
|
+
if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
|
|
62
|
+
entry.config = { headers };
|
|
58
63
|
} else {
|
|
59
64
|
throw new Error(
|
|
60
65
|
"Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio \"<cmd>\""
|
|
61
66
|
);
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
// Tool filtering (MCP only)
|
|
70
|
+
if (entry.type === "mcp") {
|
|
71
|
+
const allowed = flags["allow-tool"];
|
|
72
|
+
const disabled = flags["disable-tool"];
|
|
73
|
+
if (allowed?.length) entry.config.allowedTools = allowed;
|
|
74
|
+
if (disabled?.length) entry.config.disabledTools = disabled;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
registry.push(entry);
|
|
65
78
|
saveRegistry(registry);
|
|
66
79
|
out({ ok: true, name, type: entry.type, transport: entry.transport });
|
package/src/commands/call.js
CHANGED
|
@@ -2,20 +2,30 @@ import { readFileSync } from "fs";
|
|
|
2
2
|
import { out } from "../output.js";
|
|
3
3
|
import { parseArgs, parseKV } from "../args.js";
|
|
4
4
|
import { createMcpClient } from "../mcp-client.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveSpec, resolveConfig } from "../resolve.js";
|
|
6
|
+
|
|
7
|
+
const HTTP_TIMEOUT = parseInt(process.env.SPEC_HTTP_TIMEOUT ?? "30000");
|
|
6
8
|
|
|
7
9
|
export async function callOperation(args) {
|
|
8
10
|
const { flags, positional } = parseArgs(args);
|
|
9
11
|
const target = positional[0];
|
|
10
12
|
if (!target) throw new Error(
|
|
11
|
-
"Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}'] [--var k=v] [--header k=v]"
|
|
13
|
+
"Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}' | --data -] [--var k=v] [--header k=v]"
|
|
12
14
|
);
|
|
13
15
|
|
|
14
16
|
if (flags["data-file"] && !flags.data) {
|
|
15
17
|
flags.data = readFileSync(flags["data-file"], "utf-8").trim();
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
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);
|
|
19
29
|
const config = resolveConfig(flags, entry);
|
|
20
30
|
|
|
21
31
|
if (spec.type === "openapi") {
|
|
@@ -46,7 +56,10 @@ async function callMCP(spec, entry, target, flags) {
|
|
|
46
56
|
const client = await createMcpClient(entry);
|
|
47
57
|
try {
|
|
48
58
|
const result = await client.callTool({ name: tool.name, arguments: toolArgs });
|
|
49
|
-
|
|
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);
|
|
50
63
|
} finally {
|
|
51
64
|
await client.close();
|
|
52
65
|
}
|
|
@@ -68,7 +81,13 @@ async function callOpenAPI(spec, config, target, flags) {
|
|
|
68
81
|
|
|
69
82
|
const vars = parseKV(flags.var);
|
|
70
83
|
for (const [key, val] of Object.entries(vars)) {
|
|
71
|
-
path = path.
|
|
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>`);
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
const queryParams = parseKV(flags.query);
|
|
@@ -84,7 +103,7 @@ async function callOpenAPI(spec, config, target, flags) {
|
|
|
84
103
|
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
|
85
104
|
}
|
|
86
105
|
|
|
87
|
-
const res = await fetch(url, { method, headers, body });
|
|
106
|
+
const res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
|
|
88
107
|
const contentType = res.headers.get("content-type") || "";
|
|
89
108
|
const responseBody = contentType.includes("json") ? await res.json() : await res.text();
|
|
90
109
|
|
|
@@ -132,15 +151,16 @@ async function callGraphQL(spec, config, target, flags) {
|
|
|
132
151
|
variables: Object.keys(variables).length > 0 ? variables : undefined,
|
|
133
152
|
});
|
|
134
153
|
|
|
135
|
-
const res = await fetch(endpoint, { method: "POST", headers, body });
|
|
136
|
-
const
|
|
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();
|
|
137
157
|
|
|
138
158
|
out({
|
|
139
159
|
status: res.status,
|
|
140
160
|
query,
|
|
141
161
|
variables: Object.keys(variables).length > 0 ? variables : undefined,
|
|
142
|
-
data: responseBody
|
|
143
|
-
errors: responseBody
|
|
162
|
+
data: responseBody?.data || null,
|
|
163
|
+
errors: responseBody?.errors || null,
|
|
144
164
|
});
|
|
145
165
|
}
|
|
146
166
|
|
|
@@ -3,6 +3,7 @@ import { resolve } from "path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { parseKV } from "../args.js";
|
|
5
5
|
import { createMcpClient } from "../mcp-client.js";
|
|
6
|
+
import { matchGlob } from "../glob.js";
|
|
6
7
|
|
|
7
8
|
const INTROSPECTION_QUERY = `{
|
|
8
9
|
__schema {
|
|
@@ -66,7 +67,7 @@ fragment TypeRef on __Type {
|
|
|
66
67
|
* { type: "graphql", source: "<url>", config: { headers, auth } }
|
|
67
68
|
* { type: "mcp", transport: "stdio|sse|streamable-http", url?, command?, args?, config: { headers, env } }
|
|
68
69
|
*/
|
|
69
|
-
export async function
|
|
70
|
+
export async function fetchSpec(entry) {
|
|
70
71
|
if (entry.type === "mcp") return await loadMCPFromEntry(entry);
|
|
71
72
|
if (entry.type === "graphql") return await loadGraphQL(entry.source, entry.config?.headers);
|
|
72
73
|
// openapi — url or file; skip GraphQL probe since type is explicitly declared
|
|
@@ -88,6 +89,7 @@ export function inlineEntryFromFlags(flags) {
|
|
|
88
89
|
transport: "stdio",
|
|
89
90
|
command: parts[0],
|
|
90
91
|
args: parts.slice(1),
|
|
92
|
+
cwd: flags.cwd,
|
|
91
93
|
config: { env: parseKV(flags.env) },
|
|
92
94
|
};
|
|
93
95
|
}
|
|
@@ -122,6 +124,17 @@ async function loadMCPFromEntry(entry) {
|
|
|
122
124
|
const client = await createMcpClient(entry);
|
|
123
125
|
try {
|
|
124
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
|
+
|
|
125
138
|
return {
|
|
126
139
|
type: "mcp",
|
|
127
140
|
title: entry.name || "MCP Server",
|
|
@@ -129,12 +142,9 @@ async function loadMCPFromEntry(entry) {
|
|
|
129
142
|
url: entry.url,
|
|
130
143
|
command: entry.command,
|
|
131
144
|
args: entry.args,
|
|
145
|
+
cwd: entry.cwd,
|
|
132
146
|
config: entry.config,
|
|
133
|
-
tools:
|
|
134
|
-
name: t.name,
|
|
135
|
-
description: t.description || null,
|
|
136
|
-
inputSchema: t.inputSchema || null,
|
|
137
|
-
})),
|
|
147
|
+
tools: mapped,
|
|
138
148
|
};
|
|
139
149
|
} finally {
|
|
140
150
|
await client.close();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { out } from "../output.js";
|
|
2
|
+
import { parseArgs } from "../args.js";
|
|
3
|
+
import { getRegistry, getEntry, getCachedSpec, saveCachedSpec } from "../registry.js";
|
|
4
|
+
import { fetchSpec } from "./fetch.js";
|
|
5
|
+
import { matchGlob } from "../glob.js";
|
|
6
|
+
|
|
7
|
+
export async function grepCmd(args) {
|
|
8
|
+
const { flags, positional } = parseArgs(args);
|
|
9
|
+
const pattern = positional[0];
|
|
10
|
+
if (!pattern) throw new Error(
|
|
11
|
+
"Usage: spec grep <pattern> [--spec <name>]\n" +
|
|
12
|
+
" Glob patterns: * matches anything, ? matches one char\n" +
|
|
13
|
+
" Plain text: substring match across name and description"
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const entries = flags.spec
|
|
17
|
+
? [getEntry(flags.spec)]
|
|
18
|
+
: getRegistry().filter((e) => e.enabled);
|
|
19
|
+
|
|
20
|
+
if (entries.length === 0) throw new Error("No registered specs. Run 'spec add' first.");
|
|
21
|
+
|
|
22
|
+
const results = [];
|
|
23
|
+
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
let spec = getCachedSpec(entry.name);
|
|
26
|
+
if (!spec) {
|
|
27
|
+
spec = await fetchSpec(entry);
|
|
28
|
+
saveCachedSpec(entry.name, spec);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const matches = [];
|
|
32
|
+
|
|
33
|
+
if (spec.type === "mcp") {
|
|
34
|
+
for (const tool of spec.tools) {
|
|
35
|
+
const nameMatch = matchGlob(pattern, tool.name);
|
|
36
|
+
const descMatch = tool.description && matchGlob(pattern, tool.description);
|
|
37
|
+
if (nameMatch || descMatch) {
|
|
38
|
+
matches.push({ id: tool.name, description: tool.description });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} else if (spec.type === "openapi") {
|
|
42
|
+
for (const op of spec.operations) {
|
|
43
|
+
if (matchGlob(pattern, op.id) || matchGlob(pattern, op.path) ||
|
|
44
|
+
(op.summary && matchGlob(pattern, op.summary))) {
|
|
45
|
+
matches.push({ id: op.id, method: op.method, path: op.path });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} else if (spec.type === "graphql") {
|
|
49
|
+
for (const op of spec.operations) {
|
|
50
|
+
if (matchGlob(pattern, op.name) ||
|
|
51
|
+
(op.description && matchGlob(pattern, op.description))) {
|
|
52
|
+
matches.push({ id: op.name, kind: op.kind });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (matches.length > 0) {
|
|
58
|
+
results.push({ spec: entry.name, type: spec.type, matches });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const total = results.reduce((s, r) => s + r.matches.length, 0);
|
|
63
|
+
out({ pattern, total, results });
|
|
64
|
+
}
|
package/src/commands/list.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpec } from "../resolve.js";
|
|
4
4
|
|
|
5
5
|
export async function listOperations(args) {
|
|
6
6
|
const opts = parseArgs(args);
|
|
7
7
|
const { flags } = opts;
|
|
8
8
|
|
|
9
|
-
const { spec } = await
|
|
9
|
+
const { spec } = await resolveSpec(flags);
|
|
10
10
|
|
|
11
11
|
const filter = flags.filter?.toLowerCase();
|
|
12
12
|
const compact = flags.compact !== "false";
|
package/src/commands/show.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpec } from "../resolve.js";
|
|
4
4
|
|
|
5
5
|
export async function showOperation(args) {
|
|
6
6
|
const { flags, positional } = parseArgs(args);
|
|
7
7
|
const target = positional[0];
|
|
8
8
|
if (!target) throw new Error("Usage: spec show <operationId-or-path> [--spec <name> | --openapi <url> | ...]");
|
|
9
9
|
|
|
10
|
-
const { spec } = await
|
|
10
|
+
const { spec } = await resolveSpec(flags);
|
|
11
11
|
|
|
12
12
|
if (spec.type === "openapi") {
|
|
13
13
|
showOpenAPI(spec, target);
|
package/src/commands/specs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "../args.js";
|
|
2
2
|
import { getRegistry, saveRegistry, getEntry, removeCachedSpec, saveCachedSpec } from "../registry.js";
|
|
3
|
-
import {
|
|
3
|
+
import { fetchSpec } from "./fetch.js";
|
|
4
4
|
import { out } from "../output.js";
|
|
5
5
|
|
|
6
6
|
export async function specsCmd(args) {
|
|
@@ -58,7 +58,7 @@ export async function registryMutate(action, args) {
|
|
|
58
58
|
if (action === "refresh") {
|
|
59
59
|
const entry = registry[idx];
|
|
60
60
|
if (!entry.enabled) throw new Error(`Spec '${name}' is disabled. Enable it first.`);
|
|
61
|
-
const spec = await
|
|
61
|
+
const spec = await fetchSpec(entry);
|
|
62
62
|
saveCachedSpec(name, spec);
|
|
63
63
|
const count = spec.tools?.length ?? spec.operations?.length ?? 0;
|
|
64
64
|
out({ ok: true, refreshed: name, type: spec.type, count });
|
package/src/commands/types.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpec } from "../resolve.js";
|
|
4
4
|
|
|
5
5
|
export async function typesCmd(args) {
|
|
6
6
|
const { positional, flags } = parseArgs(args);
|
|
7
|
-
const { spec } = await
|
|
7
|
+
const { spec } = await resolveSpec(flags);
|
|
8
8
|
const target = positional[0];
|
|
9
9
|
|
|
10
10
|
if (spec.type === "openapi") {
|
package/src/glob.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match a string against a glob pattern or a plain substring.
|
|
3
|
+
* - Glob chars (* ?) use regex matching
|
|
4
|
+
* - Plain text uses case-insensitive substring matching
|
|
5
|
+
*/
|
|
6
|
+
export function matchGlob(pattern, str) {
|
|
7
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
8
|
+
return str.toLowerCase().includes(pattern.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
const re = new RegExp(
|
|
11
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
|
|
12
|
+
"i"
|
|
13
|
+
);
|
|
14
|
+
return re.test(str);
|
|
15
|
+
}
|
package/src/mcp-client.js
CHANGED
|
@@ -3,15 +3,31 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
3
3
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const MAX_RETRIES = parseInt(process.env.MCP_MAX_RETRIES ?? "3");
|
|
7
|
+
const RETRY_DELAY = parseInt(process.env.MCP_RETRY_DELAY ?? "1000");
|
|
8
|
+
|
|
9
|
+
// Expand ${VAR} placeholders from process.env at call time
|
|
10
|
+
function expandEnv(val) {
|
|
11
|
+
return val.replace(/\$\{([^}]+)\}/g, (_, name) => {
|
|
12
|
+
if (!(name in process.env)) throw new Error(`Environment variable not set: ${name}`);
|
|
13
|
+
return process.env[name];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function connect(spec) {
|
|
7
18
|
const client = new Client({ name: "spec-cli", version: "1.0.0" });
|
|
8
19
|
|
|
9
20
|
let transport;
|
|
10
21
|
if (spec.transport === "stdio") {
|
|
22
|
+
const rawEnv = spec.config?.env || {};
|
|
23
|
+
const expandedEnv = Object.fromEntries(
|
|
24
|
+
Object.entries(rawEnv).map(([k, v]) => [k, expandEnv(v)])
|
|
25
|
+
);
|
|
11
26
|
transport = new StdioClientTransport({
|
|
12
27
|
command: spec.command,
|
|
13
28
|
args: spec.args,
|
|
14
|
-
env:
|
|
29
|
+
env: Object.keys(expandedEnv).length > 0 ? { ...process.env, ...expandedEnv } : undefined,
|
|
30
|
+
cwd: spec.cwd,
|
|
15
31
|
});
|
|
16
32
|
} else if (spec.transport === "sse") {
|
|
17
33
|
const h = spec.config?.headers;
|
|
@@ -30,3 +46,18 @@ export async function createMcpClient(spec) {
|
|
|
30
46
|
await client.connect(transport);
|
|
31
47
|
return client;
|
|
32
48
|
}
|
|
49
|
+
|
|
50
|
+
export async function createMcpClient(spec) {
|
|
51
|
+
let lastError;
|
|
52
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
53
|
+
try {
|
|
54
|
+
return await connect(spec);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
lastError = e;
|
|
57
|
+
if (attempt < MAX_RETRIES) {
|
|
58
|
+
await new Promise((r) => setTimeout(r, Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw lastError;
|
|
63
|
+
}
|
package/src/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
|
|
2
|
-
import {
|
|
2
|
+
import { fetchSpec, inlineEntryFromFlags } from "./commands/fetch.js";
|
|
3
3
|
import { getConfig } from "./store.js";
|
|
4
4
|
import { parseKV } from "./args.js";
|
|
5
5
|
|
|
@@ -10,12 +10,12 @@ import { parseKV } from "./args.js";
|
|
|
10
10
|
* 2. Inline flags → ad-hoc, no caching
|
|
11
11
|
* 3. Error → no spec source given
|
|
12
12
|
*/
|
|
13
|
-
export async function
|
|
13
|
+
export async function resolveSpec(flags) {
|
|
14
14
|
if (flags.spec) {
|
|
15
15
|
const entry = getEntry(flags.spec); // throws if missing or disabled
|
|
16
16
|
let spec = getCachedSpec(flags.spec);
|
|
17
17
|
if (!spec) {
|
|
18
|
-
spec = await
|
|
18
|
+
spec = await fetchSpec(entry);
|
|
19
19
|
saveCachedSpec(flags.spec, spec);
|
|
20
20
|
}
|
|
21
21
|
return { spec, entry };
|
|
@@ -23,7 +23,7 @@ export async function resolveActiveSpec(flags) {
|
|
|
23
23
|
|
|
24
24
|
const inlineEntry = inlineEntryFromFlags(flags);
|
|
25
25
|
if (inlineEntry) {
|
|
26
|
-
const spec = await
|
|
26
|
+
const spec = await fetchSpec(inlineEntry);
|
|
27
27
|
return { spec, entry: inlineEntry };
|
|
28
28
|
}
|
|
29
29
|
|