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 +17 -2
- package/package.json +1 -1
- package/src/cli.js +2 -2
- package/src/commands/add.js +5 -7
- package/src/commands/fetch.js +33 -12
- package/src/glob.js +22 -8
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
|
-
|
|
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
|
|
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
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
|
|
35
|
-
--disable-tool <glob> (repeatable
|
|
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
|
package/src/commands/add.js
CHANGED
|
@@ -66,13 +66,11 @@ export async function addCmd(args) {
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
package/src/commands/fetch.js
CHANGED
|
@@ -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 {
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
}
|