api-spec-cli 0.2.2 → 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
@@ -189,15 +189,30 @@ spec add <name> --mcp-stdio "<cmd args>" [--env KEY=VAL] [--cwd <path>]
189
189
 
190
190
  All options are repeatable where it makes sense (`--header`, `--env`). `--auth` adds `Authorization: Bearer <token>` unless the header is already set.
191
191
 
192
- For MCP entries, tool filtering is available:
192
+ Operation filtering works for all spec types — MCP, OpenAPI, and GraphQL:
193
193
 
194
194
  ```bash
195
+ # MCP: allow only read/list tools
195
196
  spec add <name> --mcp-http <url> \
196
197
  --allow-tool "read_*" --allow-tool "list_*" \
197
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"
198
207
  ```
199
208
 
200
- `--allow-tool` keeps only matching tools. `--disable-tool` removes matching tools (applied after allow). Both accept glob patterns (`*`, `?`) or plain substrings.
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).
201
216
 
202
217
  ---
203
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-spec-cli",
3
- "version": "0.2.2",
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
@@ -66,13 +66,11 @@ export async function addCmd(args) {
66
66
  );
67
67
  }
68
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
- }
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;
76
74
 
77
75
  registry.push(entry);
78
76
  saveRegistry(registry);
@@ -3,7 +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
+ import { matchFilter } from "../glob.js";
7
7
 
8
8
  const INTROSPECTION_QUERY = `{
9
9
  __schema {
@@ -60,6 +60,13 @@ fragment TypeRef on __Type {
60
60
  }
61
61
  }`;
62
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
+
63
70
  /**
64
71
  * Resolve a spec from a registry entry or inline flags entry.
65
72
  * Entry shape:
@@ -69,10 +76,20 @@ fragment TypeRef on __Type {
69
76
  */
70
77
  export async function fetchSpec(entry) {
71
78
  if (entry.type === "mcp") return await loadMCPFromEntry(entry);
72
- 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
+ }
73
86
  // openapi — url or file; skip GraphQL probe since type is explicitly declared
74
87
  const isUrl = entry.source?.startsWith("http://") || entry.source?.startsWith("https://");
75
- 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
+ };
76
93
  }
77
94
 
78
95
  /**
@@ -80,6 +97,13 @@ export async function fetchSpec(entry) {
80
97
  * Returns null if no inline source flags present.
81
98
  */
82
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
+
83
107
  if (flags["mcp-stdio"]) {
84
108
  const raw = flags["mcp-stdio"];
85
109
  const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
@@ -90,7 +114,7 @@ export function inlineEntryFromFlags(flags) {
90
114
  command: parts[0],
91
115
  args: parts.slice(1),
92
116
  cwd: flags.cwd,
93
- config: { env: parseKV(flags.env) },
117
+ config: { env: parseKV(flags.env), ...filterConfig },
94
118
  };
95
119
  }
96
120
  if (flags["mcp-sse"]) {
@@ -98,7 +122,7 @@ export function inlineEntryFromFlags(flags) {
98
122
  type: "mcp",
99
123
  transport: "sse",
100
124
  url: flags["mcp-sse"],
101
- config: { headers: parseKV(flags.header) },
125
+ config: { headers: parseKV(flags.header), ...filterConfig },
102
126
  };
103
127
  }
104
128
  if (flags["mcp-http"]) {
@@ -106,14 +130,14 @@ export function inlineEntryFromFlags(flags) {
106
130
  type: "mcp",
107
131
  transport: "streamable-http",
108
132
  url: flags["mcp-http"],
109
- config: { headers: parseKV(flags.header) },
133
+ config: { headers: parseKV(flags.header), ...filterConfig },
110
134
  };
111
135
  }
112
136
  if (flags.graphql) {
113
- return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header) } };
137
+ return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header), ...filterConfig } };
114
138
  }
115
139
  if (flags.openapi) {
116
- 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 } };
117
141
  }
118
142
  return null;
119
143
  }
@@ -130,10 +154,7 @@ async function loadMCPFromEntry(entry) {
130
154
  inputSchema: t.inputSchema || null,
131
155
  }));
132
156
 
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)));
157
+ mapped = applyFilter(mapped, (t) => t.name, entry.config?.allowedTools, entry.config?.disabledTools);
137
158
 
138
159
  return {
139
160
  type: "mcp",
package/src/glob.js CHANGED
@@ -1,15 +1,29 @@
1
+ function globToRegex(pattern) {
2
+ return new RegExp(
3
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
4
+ "i"
5
+ );
6
+ }
7
+
1
8
  /**
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
9
+ * Search match: plain text = substring, glob chars (* ?) = anchored glob.
10
+ * Used by grep broad matching is desirable for search.
5
11
  */
6
12
  export function matchGlob(pattern, str) {
7
13
  if (!pattern.includes("*") && !pattern.includes("?")) {
8
14
  return str.toLowerCase().includes(pattern.toLowerCase());
9
15
  }
10
- const re = new RegExp(
11
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
12
- "i"
13
- );
14
- return re.test(str);
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);
15
29
  }