@knitli/openapi-mcp 1.1.0 → 1.2.0

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
@@ -75,6 +75,48 @@ base64url SPKI public key for the config with:
75
75
  node --input-type=module -e 'import {readFileSync} from "node:fs"; import {createPublicKey} from "node:crypto"; console.log(createPublicKey(readFileSync("operator/keys/openapi-mcp.pub")).export({type:"spki",format:"der"}).toString("base64url"))'
76
76
  ```
77
77
 
78
+ ### Slice a large document down to one deployment's catalog
79
+
80
+ `compile-release` enforces `maxDocumentKeys` (default 250,000) and
81
+ `maxDocumentNodes` (default 1,000,000) on the whole spec. A document like
82
+ Microsoft Graph's full v1.0 (over 1M nodes, served from a single `/v1.0`) will
83
+ never fit, no matter how few operations one deployment actually exposes. `slice`
84
+ filters an OpenAPI document down to the operations you select by `--tag` and/or
85
+ `--operation`, prunes every `components` entry nothing kept still references,
86
+ and rewrites `servers` to the bare origin `compile-release` requires (it rejects
87
+ any `servers[0].url` with a path component):
88
+
89
+ ```sh
90
+ ./node_modules/.bin/openapi-mcp slice \
91
+ --spec ./graph-v1.0.yaml --out ./sliced/mail.json \
92
+ --tag me.message --operation me.sendMail \
93
+ --max-document-keys 5000000 --max-document-nodes 20000000
94
+ ./node_modules/.bin/openapi-mcp compile-release \
95
+ --spec ./sliced/mail.json --source-label graph-mail --source-revision revision-1 \
96
+ --catalog mail --release mail-1 --generation 1 \
97
+ --issuer example-operator --key-id signing-1 --policy-id local-policy-1 \
98
+ --allowed-origin https://graph.microsoft.com \
99
+ --out ./operator/releases --sign-key ./operator/keys/openapi-mcp.key
100
+ ```
101
+
102
+ `--max-document-keys` and `--max-document-nodes` only raise the limits `slice`
103
+ itself loads the source document under (`loadSpecV4`'s `CompilerLimits`); they
104
+ have no effect on the sliced output's own size, which is whatever fits under
105
+ `compile-release`'s (unraised) defaults. On success `slice` prints a summary
106
+ line, for example:
107
+
108
+ ```
109
+ paths=23 operations=33 schemas=30 keys=3027 (fits compile-release's default maxDocumentKeys=250000)
110
+ ```
111
+
112
+ `keys=` is the sliced document's own key count, counted the same way the
113
+ compiler counts `maxDocumentKeys`; the number in parentheses is
114
+ `compile-release`'s own (unraised) default, so you can tell before running it
115
+ whether the selection is small enough. An `--operation` or `--tag` that matched
116
+ nothing is an error, not a silently smaller release — fix the typo it names.
117
+ OpenAPI 3.1 `webhooks` are not part of `paths` and are left untouched, dangling
118
+ refs and all; Graph's v1.0 document (3.0) has none.
119
+
78
120
  ## Configure and start the local MCP server
79
121
 
80
122
  Save this as `operator/config.json`, substituting absolute paths, the public key,
package/dist/cli.js CHANGED
@@ -4,8 +4,10 @@ import { join } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { compile } from "./compile.js";
6
6
  import { compileRelease } from "./release/compile-release.js";
7
+ import { DEFAULT_COMPILER_LIMITS, loadSpecV4 } from "./release/load-v4.js";
7
8
  import { publishRelease } from "./release/publish.js";
8
9
  import { generateKeypair, signArtifact, verifyArtifact } from "./sign.js";
10
+ import { countKeys, HTTP_METHODS, keyBudgetNote, sliceSpec } from "./slice.js";
9
11
  const USAGE = `openapi-mcp — compile OpenAPI documents into signed MCP artifacts
10
12
 
11
13
  compile --spec <path> --api <name> --out <path> [--append] [--permissions <path>] [--sign-key <path>] (legacy v3)
@@ -13,6 +15,7 @@ const USAGE = `openapi-mcp — compile OpenAPI documents into signed MCP artifac
13
15
  --catalog <id> --release <id> --generation <n> --issuer <id> --key-id <id>
14
16
  --policy-id <id> --allowed-origin <https-origin> --out <directory> --sign-key <path>
15
17
  [--permissions <path>] [--reference-root <path> --reference-map <path>]
18
+ slice --spec <openapi.yaml|json> --out <sliced.json> [--tag <tag>]... [--operation <operationId>]... [--max-document-keys <n>] [--max-document-nodes <n>]
16
19
  verify --artifact <path> --sig <path> --pub <path> (legacy v3 exact-file signature)
17
20
  keygen [--out <dir>]
18
21
  serve --config <absolute-config-path>
@@ -203,6 +206,46 @@ if (command === "compile-release") {
203
206
  }
204
207
  process.exit(0);
205
208
  }
209
+ if (command === "slice") {
210
+ const { values } = parseOrFail({
211
+ args: rest,
212
+ options: {
213
+ spec: { type: "string" },
214
+ out: { type: "string" },
215
+ tag: { type: "string", multiple: true },
216
+ operation: { type: "string", multiple: true },
217
+ "max-document-keys": { type: "string" },
218
+ "max-document-nodes": { type: "string" },
219
+ },
220
+ strict: true,
221
+ });
222
+ if (!values.spec)
223
+ fail("--spec is required");
224
+ if (!values.out)
225
+ fail("--out is required");
226
+ try {
227
+ const document = (await loadSpecV4(values.spec, {
228
+ maxDocumentKeys: Number(values["max-document-keys"] ?? 5_000_000),
229
+ maxDocumentNodes: Number(values["max-document-nodes"] ?? 20_000_000),
230
+ }));
231
+ const sliced = sliceSpec(document, {
232
+ tags: values.tag ?? [],
233
+ operations: values.operation ?? [],
234
+ });
235
+ await writeFile(values.out, `${JSON.stringify(sliced, null, 2)}\n`);
236
+ const paths = sliced.paths;
237
+ const operationCount = Object.values(paths).reduce((n, item) => n +
238
+ Object.keys(item).filter((k) => HTTP_METHODS.includes(k)).length, 0);
239
+ const schemaCount = Object.keys((sliced.components.schemas ?? {})).length;
240
+ const keys = countKeys(sliced);
241
+ console.log(`paths=${Object.keys(paths).length} operations=${operationCount} schemas=${schemaCount} ` +
242
+ `keys=${keys} (${keyBudgetNote(keys, DEFAULT_COMPILER_LIMITS.maxDocumentKeys)})`);
243
+ }
244
+ catch (err) {
245
+ fail(err instanceof Error ? err.message : String(err), { usage: false });
246
+ }
247
+ process.exit(0);
248
+ }
206
249
  if (command !== "serve") {
207
250
  if (command !== "compile") {
208
251
  fail(command ? `unknown command "${command}"` : "no command given");
@@ -0,0 +1,14 @@
1
+ export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
2
+ type Json = Record<string, unknown>;
3
+ /** Which operations survive: any of these tags, or any of these operation ids. */
4
+ export interface SliceSelection {
5
+ readonly tags: readonly string[];
6
+ readonly operations: readonly string[];
7
+ }
8
+ /** Number of object keys in a JSON value; compare with the compiler's `maxDocumentKeys` (250,000). */
9
+ export declare function countKeys(value: unknown): number;
10
+ /** Describes a key count against compile-release's default `maxDocumentKeys`, and what to do if it doesn't fit. */
11
+ export declare function keyBudgetNote(keys: number, limit: number): string;
12
+ /** Pure: returns a new document containing only the selected operations and what they reference. */
13
+ export declare function sliceSpec(document: Json, selection: SliceSelection): Json;
14
+ export {};
package/dist/slice.js ADDED
@@ -0,0 +1,220 @@
1
+ // Slice an OpenAPI 3.x document down to the operations a deployment will expose, prune the
2
+ // components nothing left references, and rewrite `servers` to the bare origin `compile-release`
3
+ // requires. This is what lets a document far beyond `maxDocumentKeys` (Microsoft Graph's full
4
+ // v1.0, served from `/v1.0`) be compiled as one catalog per deployment.
5
+ //
6
+ // OpenAPI 3.1 `webhooks` are deliberately left untouched: they are not part of `paths` and are
7
+ // spread through as-is, dangling refs and all, same as any other top-level key. Graph is 3.0 and
8
+ // has none.
9
+ export const HTTP_METHODS = [
10
+ "get",
11
+ "put",
12
+ "post",
13
+ "delete",
14
+ "options",
15
+ "head",
16
+ "patch",
17
+ "trace",
18
+ ];
19
+ /** Number of object keys in a JSON value; compare with the compiler's `maxDocumentKeys` (250,000). */
20
+ export function countKeys(value) {
21
+ if (Array.isArray(value))
22
+ return value.reduce((sum, item) => sum + countKeys(item), 0);
23
+ if (value && typeof value === "object") {
24
+ return Object.entries(value).reduce((sum, [, item]) => sum + 1 + countKeys(item), 0);
25
+ }
26
+ return 0;
27
+ }
28
+ /** Describes a key count against compile-release's default `maxDocumentKeys`, and what to do if it doesn't fit. */
29
+ export function keyBudgetNote(keys, limit) {
30
+ return keys <= limit
31
+ ? `fits compile-release's default maxDocumentKeys=${limit}`
32
+ : `exceeds compile-release's default maxDocumentKeys=${limit}; pass --max-document-keys to compile-release`;
33
+ }
34
+ function collectRefs(value, into) {
35
+ if (Array.isArray(value)) {
36
+ for (const item of value)
37
+ collectRefs(item, into);
38
+ return;
39
+ }
40
+ if (!value || typeof value !== "object")
41
+ return;
42
+ for (const [key, item] of Object.entries(value)) {
43
+ if (key === "$ref" &&
44
+ typeof item === "string" &&
45
+ item.startsWith("#/components/"))
46
+ into.add(item);
47
+ else
48
+ collectRefs(item, into);
49
+ }
50
+ }
51
+ /** A `$ref` path segment is a URI-fragment-encoded JSON pointer token: decode `%xx`, then `~1`
52
+ * (`/`) and `~0` (`~`), in that order, so a component named e.g. `a/b` round-trips. */
53
+ function decodeRefSegment(raw) {
54
+ return decodeURIComponent(raw).replaceAll("~1", "/").replaceAll("~0", "~");
55
+ }
56
+ function refParts(ref) {
57
+ const [, , kind, name] = ref.split("/");
58
+ if (kind === undefined || name === undefined)
59
+ return undefined;
60
+ return { kind, name: decodeRefSegment(name) };
61
+ }
62
+ function securitySchemeNames(requirements, into) {
63
+ if (!Array.isArray(requirements))
64
+ return;
65
+ for (const requirement of requirements) {
66
+ if (!requirement || typeof requirement !== "object")
67
+ continue;
68
+ for (const name of Object.keys(requirement))
69
+ into.add(name);
70
+ }
71
+ }
72
+ /** Walks `document.paths`, keeping only operations matching `selection`, and reports which
73
+ * selectors actually matched something (an unmatched selector is the caller's problem, not
74
+ * this function's — checked by `sliceSpec`). */
75
+ function selectOperations(document, selection) {
76
+ const tags = new Set(selection.tags);
77
+ const operations = new Set(selection.operations);
78
+ const matchedTags = new Set();
79
+ const matchedOperations = new Set();
80
+ const keptOperations = [];
81
+ const paths = {};
82
+ for (const [path, item] of Object.entries((document.paths ?? {}))) {
83
+ const source = item;
84
+ const target = { ...source };
85
+ let keptAny = false;
86
+ for (const method of HTTP_METHODS) {
87
+ const operation = source[method];
88
+ if (!operation)
89
+ continue;
90
+ const operationTags = Array.isArray(operation.tags)
91
+ ? operation.tags
92
+ : [];
93
+ const matchingTags = operationTags.filter((tag) => tags.has(tag));
94
+ const operationId = operation.operationId;
95
+ const matchesOperation = operationId !== undefined && operations.has(operationId);
96
+ if (matchingTags.length === 0 && !matchesOperation) {
97
+ delete target[method];
98
+ continue;
99
+ }
100
+ for (const tag of matchingTags)
101
+ matchedTags.add(tag);
102
+ if (matchesOperation && operationId !== undefined)
103
+ matchedOperations.add(operationId);
104
+ keptAny = true;
105
+ keptOperations.push(operation);
106
+ }
107
+ if (!keptAny)
108
+ continue;
109
+ paths[path] = target;
110
+ }
111
+ if (matchedTags.size === 0 && matchedOperations.size === 0)
112
+ throw new Error("The selection selected no operations.");
113
+ const unmatched = [
114
+ ...[...tags].filter((tag) => !matchedTags.has(tag)),
115
+ ...[...operations].filter((id) => !matchedOperations.has(id)),
116
+ ];
117
+ if (unmatched.length > 0)
118
+ throw new Error(`Selection matched nothing for: ${unmatched.join(", ")}`);
119
+ return { paths, keptOperations };
120
+ }
121
+ /** Transitive closure over `$ref` keys only, starting from the kept paths. Discriminator
122
+ * mappings are plain strings and are followed separately (`pruneDiscriminatorMapping`), so a
123
+ * pruned mapping target can be dropped instead of resolved. */
124
+ function resolveReachableRefs(paths, components) {
125
+ const reachable = new Set();
126
+ const pending = new Set();
127
+ collectRefs(paths, pending);
128
+ while (pending.size) {
129
+ const ref = pending.values().next().value;
130
+ pending.delete(ref);
131
+ if (reachable.has(ref))
132
+ continue;
133
+ reachable.add(ref);
134
+ const parts = refParts(ref);
135
+ const component = parts !== undefined ? components[parts.kind]?.[parts.name] : undefined;
136
+ if (component === undefined)
137
+ throw new Error(`Unresolvable reference ${ref}`);
138
+ const next = new Set();
139
+ collectRefs(component, next);
140
+ for (const item of next)
141
+ if (!reachable.has(item))
142
+ pending.add(item);
143
+ }
144
+ return reachable;
145
+ }
146
+ /** Security schemes are referenced by name in `security` requirements, not by `$ref`, so the
147
+ * closure in `resolveReachableRefs` never finds them; collect the names the kept operations (or
148
+ * the root, when an operation has no override) actually use. */
149
+ function collectUsedSecuritySchemes(document, keptOperations) {
150
+ const usedSchemes = new Set();
151
+ securitySchemeNames(document.security, usedSchemes);
152
+ for (const operation of keptOperations)
153
+ securitySchemeNames(operation.security, usedSchemes);
154
+ return usedSchemes;
155
+ }
156
+ function buildPrunedComponents(components, reachable, usedSchemes) {
157
+ const prunedComponents = {};
158
+ for (const ref of reachable) {
159
+ const parts = refParts(ref);
160
+ if (parts === undefined)
161
+ continue;
162
+ const bucket = components[parts.kind];
163
+ if (bucket === undefined)
164
+ continue;
165
+ let component = bucket[parts.name];
166
+ if (parts.kind === "schemas" &&
167
+ component &&
168
+ typeof component === "object") {
169
+ component = pruneDiscriminatorMapping(component, reachable);
170
+ }
171
+ const target = prunedComponents[parts.kind] ?? {};
172
+ target[parts.name] = component;
173
+ prunedComponents[parts.kind] = target;
174
+ }
175
+ if (usedSchemes.size > 0) {
176
+ const schemes = components.securitySchemes ?? {};
177
+ const target = {};
178
+ for (const name of usedSchemes)
179
+ if (name in schemes)
180
+ target[name] = schemes[name];
181
+ if (Object.keys(target).length > 0)
182
+ prunedComponents.securitySchemes = target;
183
+ }
184
+ return prunedComponents;
185
+ }
186
+ /** Pure: returns a new document containing only the selected operations and what they reference. */
187
+ export function sliceSpec(document, selection) {
188
+ const { paths, keptOperations } = selectOperations(document, selection);
189
+ const components = (document.components ?? {});
190
+ const reachable = resolveReachableRefs(paths, components);
191
+ const usedSchemes = collectUsedSecuritySchemes(document, keptOperations);
192
+ const prunedComponents = buildPrunedComponents(components, reachable, usedSchemes);
193
+ const server = document.servers?.[0]?.url;
194
+ if (!server)
195
+ throw new Error("The document names no server.");
196
+ return {
197
+ ...document,
198
+ servers: [{ url: new URL(server).origin }],
199
+ paths,
200
+ components: prunedComponents,
201
+ };
202
+ }
203
+ /** Drops `discriminator.mapping` entries whose target is not in the kept closure, on a shallow
204
+ * copy — the schema itself is left aliased to the source document when there is nothing to prune. */
205
+ function pruneDiscriminatorMapping(schema, reachable) {
206
+ const discriminator = schema.discriminator;
207
+ const mapping = discriminator?.mapping;
208
+ if (!discriminator || !mapping)
209
+ return schema;
210
+ const prunedMapping = {};
211
+ for (const [key, target] of Object.entries(mapping))
212
+ if (reachable.has(target))
213
+ prunedMapping[key] = target;
214
+ if (Object.keys(prunedMapping).length === Object.keys(mapping).length)
215
+ return schema;
216
+ return {
217
+ ...schema,
218
+ discriminator: { ...discriminator, mapping: prunedMapping },
219
+ };
220
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@knitli/openapi-mcp",
3
3
  "private": false,
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
5
  "type": "module",
6
6
  "description": "Compile signed OpenAPI catalogs and serve verified search and authorized API calls over MCP.",
7
7
  "license": "MIT OR Apache-2.0",