api-spec-cli 0.2.1 → 0.2.3

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 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,38 @@ 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
- Headers are sent on every request. For stdio MCP, use `--env` to pass environment variables to the subprocess.
190
+ All options are repeatable where it makes sense (`--header`, `--env`). `--auth` adds `Authorization: Bearer <token>` unless the header is already set.
191
+
192
+ Operation filtering works for all spec types — MCP, OpenAPI, and GraphQL:
193
+
194
+ ```bash
195
+ # MCP: allow only read/list tools
196
+ spec add <name> --mcp-http <url> \
197
+ --allow-tool "read_*" --allow-tool "list_*" \
198
+ --disable-tool "delete_*"
199
+
200
+ # OpenAPI: allow only GET operations (by operationId)
201
+ spec add <name> --openapi <url> \
202
+ --allow-tool "get*" --allow-tool "find*"
203
+
204
+ # GraphQL: allow specific operations by exact name
205
+ spec add <name> --graphql <url> \
206
+ --allow-tool "me" --allow-tool "publication"
207
+ ```
208
+
209
+ `--allow-tool` keeps only matching operations. `--disable-tool` removes matching operations (applied after allow). Both are repeatable.
210
+
211
+ **Matching rules:**
212
+ - Plain text → **exact match** (case-insensitive): `"me"` matches only `me`
213
+ - Glob patterns → anchored match: `"get*"` matches `getPetById`, `"*post*"` matches `createPost`
214
+
215
+ Use `grep` for search (substring) — `--allow-tool` / `--disable-tool` for precise whitelists (exact or glob).
174
216
 
175
217
  ---
176
218
 
@@ -214,6 +256,23 @@ spec list --spec petstore --format=json # equals syntax also works
214
256
  - `--limit` / `--offset` paginate large APIs
215
257
  - `--filter` and `--tag` narrow results before output
216
258
 
259
+ ## MCP Options
260
+
261
+ ```bash
262
+ # Retry on connection failure (useful for stdio servers that take time to start)
263
+ MCP_MAX_RETRIES=3 # Attempts (default: 3)
264
+ MCP_RETRY_DELAY=1000 # Base delay in ms, doubles each attempt, capped at 5s (default: 1000)
265
+
266
+ # HTTP timeout for OpenAPI/GraphQL calls
267
+ SPEC_HTTP_TIMEOUT=30000 # ms (default: 30000)
268
+ ```
269
+
270
+ Stdio env vars support `${VAR}` expansion from the host environment:
271
+
272
+ ```bash
273
+ spec add fs --mcp-stdio "npx -y server /tmp" --env "TOKEN=${MY_SECRET}"
274
+ ```
275
+
217
276
  ## Storage
218
277
 
219
278
  | Path | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-spec-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Agent-friendly CLI for exploring and calling OpenAPI and GraphQL APIs",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -31,8 +31,8 @@ REGISTRY (register once, use anywhere):
31
31
  Options: --description <text> --base-url <url> --auth <token>
32
32
  --header k=v (repeatable) --env KEY=VAL (repeatable, stdio only)
33
33
  --cwd <path> (stdio only)
34
- --allow-tool <glob> (repeatable, MCP only)
35
- --disable-tool <glob> (repeatable, MCP only)
34
+ --allow-tool <glob> (repeatable)
35
+ --disable-tool <glob> (repeatable)
36
36
 
37
37
  spec specs List all registered specs
38
38
  spec specs --compact false Show full entry config
@@ -61,7 +61,7 @@ CALL:
61
61
  spec call --spec <name> <op> --query status=available Query params
62
62
  spec call --spec <name> <op> --data '{"name":"Rex"}' JSON body / MCP args
63
63
  spec call --spec <name> <op> --data-file args.json Body from file
64
- echo '{"query":"foo"}' | spec call --spec <name> <op> Pipe JSON from stdin
64
+ spec call --spec <name> <op> --data - Read JSON body from stdin (pipe)
65
65
  spec call --spec <name> <op> --header X-Custom=val Extra headers
66
66
  spec call --spec <name> <op> --method PUT Override HTTP method
67
67
 
@@ -50,25 +50,27 @@ export async function addCmd(args) {
50
50
  entry.type = "mcp";
51
51
  entry.transport = "sse";
52
52
  entry.url = flags["mcp-sse"];
53
- entry.config = { headers: parseKV(flags.header) };
53
+ const headers = parseKV(flags.header);
54
+ if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
55
+ entry.config = { headers };
54
56
  } else if (flags["mcp-http"]) {
55
57
  entry.type = "mcp";
56
58
  entry.transport = "streamable-http";
57
59
  entry.url = flags["mcp-http"];
58
- entry.config = { headers: parseKV(flags.header) };
60
+ const headers = parseKV(flags.header);
61
+ if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
62
+ entry.config = { headers };
59
63
  } else {
60
64
  throw new Error(
61
65
  "Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio \"<cmd>\""
62
66
  );
63
67
  }
64
68
 
65
- // Tool filtering (MCP only)
66
- if (entry.type === "mcp") {
67
- const allowed = flags["allow-tool"];
68
- const disabled = flags["disable-tool"];
69
- if (allowed?.length) entry.config.allowedTools = allowed;
70
- if (disabled?.length) entry.config.disabledTools = disabled;
71
- }
69
+ // Operation filtering (all types)
70
+ const allowed = flags["allow-tool"];
71
+ const disabled = flags["disable-tool"];
72
+ if (allowed?.length) entry.config.allowedTools = allowed;
73
+ if (disabled?.length) entry.config.disabledTools = disabled;
72
74
 
73
75
  registry.push(entry);
74
76
  saveRegistry(registry);
@@ -2,34 +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 { resolveActiveSpec, resolveConfig } from "../resolve.js";
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
- // Read from stdin when piped and no --data/--data-file provided.
19
- // isTTY is true in a terminal, undefined when piped so !isTTY catches piped input.
20
- // Wrapped in try-catch so test runners with closed stdin don't crash.
21
- if (!flags.data && !process.stdin.isTTY) {
22
- try {
23
- const chunks = [];
24
- for await (const chunk of process.stdin) chunks.push(chunk);
25
- const piped = Buffer.concat(chunks).toString("utf-8").trim();
26
- if (piped) flags.data = piped;
27
- } catch {
28
- // stdin unavailable (test runner, closed pipe) — ignore
29
- }
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();
30
26
  }
31
27
 
32
- const { spec, entry } = await resolveActiveSpec(flags);
28
+ const { spec, entry } = await resolveSpec(flags);
33
29
  const config = resolveConfig(flags, entry);
34
30
 
35
31
  if (spec.type === "openapi") {
@@ -60,7 +56,10 @@ async function callMCP(spec, entry, target, flags) {
60
56
  const client = await createMcpClient(entry);
61
57
  try {
62
58
  const result = await client.callTool({ name: tool.name, arguments: toolArgs });
63
- out({ tool: tool.name, arguments: toolArgs, result });
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);
64
63
  } finally {
65
64
  await client.close();
66
65
  }
@@ -82,7 +81,13 @@ async function callOpenAPI(spec, config, target, flags) {
82
81
 
83
82
  const vars = parseKV(flags.var);
84
83
  for (const [key, val] of Object.entries(vars)) {
85
- path = path.replace(`{${key}}`, encodeURIComponent(val));
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>`);
86
91
  }
87
92
 
88
93
  const queryParams = parseKV(flags.query);
@@ -98,7 +103,7 @@ async function callOpenAPI(spec, config, target, flags) {
98
103
  if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
99
104
  }
100
105
 
101
- const res = await fetch(url, { method, headers, body });
106
+ const res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
102
107
  const contentType = res.headers.get("content-type") || "";
103
108
  const responseBody = contentType.includes("json") ? await res.json() : await res.text();
104
109
 
@@ -146,15 +151,16 @@ async function callGraphQL(spec, config, target, flags) {
146
151
  variables: Object.keys(variables).length > 0 ? variables : undefined,
147
152
  });
148
153
 
149
- const res = await fetch(endpoint, { method: "POST", headers, body });
150
- const responseBody = await res.json();
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();
151
157
 
152
158
  out({
153
159
  status: res.status,
154
160
  query,
155
161
  variables: Object.keys(variables).length > 0 ? variables : undefined,
156
- data: responseBody.data || null,
157
- errors: responseBody.errors || null,
162
+ data: responseBody?.data || null,
163
+ errors: responseBody?.errors || null,
158
164
  });
159
165
  }
160
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 { matchFilter } from "../glob.js";
6
7
 
7
8
  const INTROSPECTION_QUERY = `{
8
9
  __schema {
@@ -59,6 +60,13 @@ fragment TypeRef on __Type {
59
60
  }
60
61
  }`;
61
62
 
63
+ function applyFilter(items, nameFn, allowed, disabled) {
64
+ let result = items;
65
+ if (allowed?.length) result = result.filter((item) => allowed.some((p) => matchFilter(p, nameFn(item))));
66
+ if (disabled?.length) result = result.filter((item) => !disabled.some((p) => matchFilter(p, nameFn(item))));
67
+ return result;
68
+ }
69
+
62
70
  /**
63
71
  * Resolve a spec from a registry entry or inline flags entry.
64
72
  * Entry shape:
@@ -66,12 +74,22 @@ fragment TypeRef on __Type {
66
74
  * { type: "graphql", source: "<url>", config: { headers, auth } }
67
75
  * { type: "mcp", transport: "stdio|sse|streamable-http", url?, command?, args?, config: { headers, env } }
68
76
  */
69
- export async function resolveSpec(entry) {
77
+ export async function fetchSpec(entry) {
70
78
  if (entry.type === "mcp") return await loadMCPFromEntry(entry);
71
- if (entry.type === "graphql") return await loadGraphQL(entry.source, entry.config?.headers);
79
+ if (entry.type === "graphql") {
80
+ const spec = await loadGraphQL(entry.source, entry.config?.headers);
81
+ return {
82
+ ...spec,
83
+ operations: applyFilter(spec.operations, (op) => op.name, entry.config?.allowedTools, entry.config?.disabledTools),
84
+ };
85
+ }
72
86
  // openapi — url or file; skip GraphQL probe since type is explicitly declared
73
87
  const isUrl = entry.source?.startsWith("http://") || entry.source?.startsWith("https://");
74
- return isUrl ? await loadFromUrl(entry.source, true) : loadFromFile(entry.source);
88
+ const spec = isUrl ? await loadFromUrl(entry.source, true) : loadFromFile(entry.source);
89
+ return {
90
+ ...spec,
91
+ operations: applyFilter(spec.operations, (op) => op.id, entry.config?.allowedTools, entry.config?.disabledTools),
92
+ };
75
93
  }
76
94
 
77
95
  /**
@@ -79,6 +97,13 @@ export async function resolveSpec(entry) {
79
97
  * Returns null if no inline source flags present.
80
98
  */
81
99
  export function inlineEntryFromFlags(flags) {
100
+ const allowed = flags["allow-tool"];
101
+ const disabled = flags["disable-tool"];
102
+ const filterConfig = {
103
+ ...(allowed?.length ? { allowedTools: allowed } : {}),
104
+ ...(disabled?.length ? { disabledTools: disabled } : {}),
105
+ };
106
+
82
107
  if (flags["mcp-stdio"]) {
83
108
  const raw = flags["mcp-stdio"];
84
109
  const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
@@ -89,7 +114,7 @@ export function inlineEntryFromFlags(flags) {
89
114
  command: parts[0],
90
115
  args: parts.slice(1),
91
116
  cwd: flags.cwd,
92
- config: { env: parseKV(flags.env) },
117
+ config: { env: parseKV(flags.env), ...filterConfig },
93
118
  };
94
119
  }
95
120
  if (flags["mcp-sse"]) {
@@ -97,7 +122,7 @@ export function inlineEntryFromFlags(flags) {
97
122
  type: "mcp",
98
123
  transport: "sse",
99
124
  url: flags["mcp-sse"],
100
- config: { headers: parseKV(flags.header) },
125
+ config: { headers: parseKV(flags.header), ...filterConfig },
101
126
  };
102
127
  }
103
128
  if (flags["mcp-http"]) {
@@ -105,34 +130,20 @@ export function inlineEntryFromFlags(flags) {
105
130
  type: "mcp",
106
131
  transport: "streamable-http",
107
132
  url: flags["mcp-http"],
108
- config: { headers: parseKV(flags.header) },
133
+ config: { headers: parseKV(flags.header), ...filterConfig },
109
134
  };
110
135
  }
111
136
  if (flags.graphql) {
112
- return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header) } };
137
+ return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header), ...filterConfig } };
113
138
  }
114
139
  if (flags.openapi) {
115
- return { type: "openapi", source: flags.openapi, config: { headers: parseKV(flags.header), baseUrl: flags["base-url"] || null } };
140
+ return { type: "openapi", source: flags.openapi, config: { headers: parseKV(flags.header), baseUrl: flags["base-url"] || null, ...filterConfig } };
116
141
  }
117
142
  return null;
118
143
  }
119
144
 
120
145
  // --- Internal loaders ---
121
146
 
122
- function matchGlob(pattern, str) {
123
- // If no glob chars, use case-insensitive substring match
124
- if (!pattern.includes("*") && !pattern.includes("?")) {
125
- return str.toLowerCase().includes(pattern.toLowerCase());
126
- }
127
- const re = new RegExp(
128
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
129
- "i"
130
- );
131
- return re.test(str);
132
- }
133
-
134
- export { matchGlob };
135
-
136
147
  async function loadMCPFromEntry(entry) {
137
148
  const client = await createMcpClient(entry);
138
149
  try {
@@ -143,10 +154,7 @@ async function loadMCPFromEntry(entry) {
143
154
  inputSchema: t.inputSchema || null,
144
155
  }));
145
156
 
146
- const allowed = entry.config?.allowedTools;
147
- const disabled = entry.config?.disabledTools;
148
- if (allowed?.length) mapped = mapped.filter((t) => allowed.some((p) => matchGlob(p, t.name)));
149
- if (disabled?.length) mapped = mapped.filter((t) => !disabled.some((p) => matchGlob(p, t.name)));
157
+ mapped = applyFilter(mapped, (t) => t.name, entry.config?.allowedTools, entry.config?.disabledTools);
150
158
 
151
159
  return {
152
160
  type: "mcp",
@@ -1,7 +1,8 @@
1
1
  import { out } from "../output.js";
2
2
  import { parseArgs } from "../args.js";
3
3
  import { getRegistry, getEntry, getCachedSpec, saveCachedSpec } from "../registry.js";
4
- import { resolveSpec, matchGlob } from "./load.js";
4
+ import { fetchSpec } from "./fetch.js";
5
+ import { matchGlob } from "../glob.js";
5
6
 
6
7
  export async function grepCmd(args) {
7
8
  const { flags, positional } = parseArgs(args);
@@ -23,7 +24,7 @@ export async function grepCmd(args) {
23
24
  for (const entry of entries) {
24
25
  let spec = getCachedSpec(entry.name);
25
26
  if (!spec) {
26
- spec = await resolveSpec(entry);
27
+ spec = await fetchSpec(entry);
27
28
  saveCachedSpec(entry.name, spec);
28
29
  }
29
30
 
@@ -1,12 +1,12 @@
1
1
  import { out } from "../output.js";
2
2
  import { parseArgs } from "../args.js";
3
- import { resolveActiveSpec } from "../resolve.js";
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 resolveActiveSpec(flags);
9
+ const { spec } = await resolveSpec(flags);
10
10
 
11
11
  const filter = flags.filter?.toLowerCase();
12
12
  const compact = flags.compact !== "false";
@@ -1,13 +1,13 @@
1
1
  import { out } from "../output.js";
2
2
  import { parseArgs } from "../args.js";
3
- import { resolveActiveSpec } from "../resolve.js";
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 resolveActiveSpec(flags);
10
+ const { spec } = await resolveSpec(flags);
11
11
 
12
12
  if (spec.type === "openapi") {
13
13
  showOpenAPI(spec, target);
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "../args.js";
2
2
  import { getRegistry, saveRegistry, getEntry, removeCachedSpec, saveCachedSpec } from "../registry.js";
3
- import { resolveSpec } from "./load.js";
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 resolveSpec(entry);
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 });
@@ -1,10 +1,10 @@
1
1
  import { out } from "../output.js";
2
2
  import { parseArgs } from "../args.js";
3
- import { resolveActiveSpec } from "../resolve.js";
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 resolveActiveSpec(flags);
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,29 @@
1
+ function globToRegex(pattern) {
2
+ return new RegExp(
3
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
4
+ "i"
5
+ );
6
+ }
7
+
8
+ /**
9
+ * Search match: plain text = substring, glob chars (* ?) = anchored glob.
10
+ * Used by grep — broad matching is desirable for search.
11
+ */
12
+ export function matchGlob(pattern, str) {
13
+ if (!pattern.includes("*") && !pattern.includes("?")) {
14
+ return str.toLowerCase().includes(pattern.toLowerCase());
15
+ }
16
+ return globToRegex(pattern).test(str);
17
+ }
18
+
19
+ /**
20
+ * Filter match: plain text = exact (case-insensitive), glob chars (* ?) = anchored glob.
21
+ * Used by --allow-tool / --disable-tool — precision is required for whitelists.
22
+ * Use *pattern* for explicit substring matching.
23
+ */
24
+ export function matchFilter(pattern, str) {
25
+ if (!pattern.includes("*") && !pattern.includes("?")) {
26
+ return str.toLowerCase() === pattern.toLowerCase();
27
+ }
28
+ return globToRegex(pattern).test(str);
29
+ }
package/src/mcp-client.js CHANGED
@@ -55,7 +55,7 @@ export async function createMcpClient(spec) {
55
55
  } catch (e) {
56
56
  lastError = e;
57
57
  if (attempt < MAX_RETRIES) {
58
- await new Promise((r) => setTimeout(r, RETRY_DELAY * Math.pow(2, attempt)));
58
+ await new Promise((r) => setTimeout(r, Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000)));
59
59
  }
60
60
  }
61
61
  }
package/src/resolve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
2
- import { resolveSpec, inlineEntryFromFlags } from "./commands/load.js";
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 resolveActiveSpec(flags) {
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 resolveSpec(entry);
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 resolveSpec(inlineEntry);
26
+ const spec = await fetchSpec(inlineEntry);
27
27
  return { spec, entry: inlineEntry };
28
28
  }
29
29