@knitli/openapi-mcp 1.0.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,
@@ -217,6 +259,17 @@ restart with the remaining valid catalogs. Config requires at least one catalog;
217
259
  leave the server stopped if disabling the last one. Removing a file while the
218
260
  process is using it is not a reliable disable procedure.
219
261
 
262
+ ## Complete release admission
263
+
264
+ `admitCatalogRelease(options, catalogId, releaseId)` from
265
+ `@knitli/openapi-mcp/runtime` verifies every signed inventory operation and
266
+ schema before admitting the generation through the configured `GenerationStore`.
267
+ It revalidates the manifest and inventory around generation CAS retries. Use
268
+ this supported portable API when activating an executable catalog; `admitManifest`
269
+ retains its signed-envelope-only contract. Storage must preserve immutable release
270
+ identities. Admission does not guarantee future availability, so runtime reads
271
+ continue verifying records at use time.
272
+
220
273
  ## Recover from a bad release
221
274
 
222
275
  Before release, record the prior **observed package version**, its tarball digest,
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,8 @@
1
+ import type { AdmittedManifest } from "./manifest.ts";
2
+ import { type OpenApiRuntimeOptions } from "./runtime.ts";
3
+ import type { CatalogId, ReleaseId } from "./types.ts";
4
+ /** Verify the complete release inventory before atomically admitting its generation.
5
+ * Storage must preserve immutable release identities; later runtime reads still
6
+ * verify records because this check cannot guarantee future storage availability.
7
+ */
8
+ export declare function admitCatalogRelease(options: OpenApiRuntimeOptions, catalogId: CatalogId, releaseId: ReleaseId): Promise<AdmittedManifest>;
@@ -0,0 +1,9 @@
1
+ import { admitExecutableRelease, verifyExecutableRelease, } from "./runtime.js";
2
+ /** Verify the complete release inventory before atomically admitting its generation.
3
+ * Storage must preserve immutable release identities; later runtime reads still
4
+ * verify records because this check cannot guarantee future storage availability.
5
+ */
6
+ export async function admitCatalogRelease(options, catalogId, releaseId) {
7
+ const preflight = await verifyExecutableRelease(options, catalogId, releaseId);
8
+ return admitExecutableRelease(options, preflight);
9
+ }
@@ -1,4 +1,5 @@
1
1
  export type { ActionDispatchPermit } from "./action-permit.ts";
2
+ export { admitCatalogRelease } from "./admission.ts";
2
3
  export { classifyOperation, type OperationClassification, } from "./classify.ts";
3
4
  export { digestCredentialAuthorizationBinding, snapshotCredentialAuthorizationBinding, } from "./credential-binding.ts";
4
5
  export { sha256, verifyEd25519 } from "./digest.ts";
@@ -1,3 +1,4 @@
1
+ export { admitCatalogRelease } from "./admission.js";
1
2
  export { classifyOperation, } from "./classify.js";
2
3
  export { digestCredentialAuthorizationBinding, snapshotCredentialAuthorizationBinding, } from "./credential-binding.js";
3
4
  export { sha256, verifyEd25519 } from "./digest.js";
@@ -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.0.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",