@knitli/openapi-mcp 1.3.1 → 1.4.1

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.
@@ -111,6 +111,14 @@ export function runRuntimeConformanceSuite(factory, options) {
111
111
  useFault("catalog rejects duplicate operation transport rows", { fault: "duplicate-operation" }, async ({ store, fixture }) => {
112
112
  await assertPublicError(adapter, () => store.getOperation(fixture.catalogId, fixture.releaseA, fixture.operationId), "RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.operationRowAmbiguous);
113
113
  });
114
+ useFault("catalog rejects duplicate batched operation transport rows", { fault: "duplicate-operation" }, async ({ store, fixture }) => {
115
+ const getOperations = store.getOperations?.bind(store);
116
+ if (getOperations === undefined)
117
+ return;
118
+ await assertPublicError(adapter, () => getOperations(fixture.catalogId, fixture.releaseA, [
119
+ fixture.operationId,
120
+ ]), "RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.operationTransportTooManyRows);
121
+ });
114
122
  useFault("catalog rejects duplicate schema transport rows", { fault: "duplicate-schemas" }, async ({ store, fixture }) => {
115
123
  await assertPublicError(adapter, () => store.getSchemas(fixture.catalogId, fixture.releaseA, fixture.schemaIds), "RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.schemaTransportTooManyRows);
116
124
  });
@@ -128,6 +136,14 @@ export function runRuntimeConformanceSuite(factory, options) {
128
136
  useFault("catalog redacts operation driver failures", { fault: "driver-operation", injectedDriverError }, async ({ store, fixture }) => {
129
137
  await assertPublicError(adapter, () => store.getOperation(fixture.catalogId, fixture.releaseA, fixture.operationId), "RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.recordTransportUnavailable, injectedDriverError);
130
138
  });
139
+ useFault("catalog redacts batched operation driver failures", { fault: "driver-operation", injectedDriverError }, async ({ store, fixture }) => {
140
+ const getOperations = store.getOperations?.bind(store);
141
+ if (getOperations === undefined)
142
+ return;
143
+ await assertPublicError(adapter, () => getOperations(fixture.catalogId, fixture.releaseA, [
144
+ fixture.operationId,
145
+ ]), "RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.recordTransportUnavailable, injectedDriverError);
146
+ });
131
147
  useFault("catalog redacts schema driver failures", { fault: "driver-schemas", injectedDriverError }, async ({ store, fixture }) => {
132
148
  await assertPublicError(adapter, () => store.getSchemas(fixture.catalogId, fixture.releaseA, fixture.schemaIds), "RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.recordTransportUnavailable, injectedDriverError);
133
149
  });
@@ -137,6 +153,28 @@ export function runRuntimeConformanceSuite(factory, options) {
137
153
  adapter.equal(await store.getOperation(fixture.catalogId, "missing-release", fixture.operationId), null);
138
154
  adapter.equal(await store.getOperation(fixture.catalogId, fixture.releaseA, fixture.missingOperationId), null);
139
155
  });
156
+ use("catalog batched operations match single reads and omit absent IDs", async ({ store, fixture }) => {
157
+ const getOperations = store.getOperations?.bind(store);
158
+ if (getOperations === undefined)
159
+ return;
160
+ adapter.deepEqual(await getOperations(fixture.catalogId, fixture.releaseA, [
161
+ fixture.missingOperationId,
162
+ fixture.operationId,
163
+ fixture.operationId,
164
+ ]), [fixture.operationA]);
165
+ adapter.deepEqual(await getOperations(fixture.catalogId, fixture.releaseB, [
166
+ fixture.operationId,
167
+ ]), [fixture.operationB]);
168
+ adapter.deepEqual(await getOperations(fixture.catalogId, "missing-release", [
169
+ fixture.operationId,
170
+ ]), []);
171
+ adapter.deepEqual(await getOperations(fixture.catalogId, fixture.releaseA, []), []);
172
+ await assertPublicError(adapter, () => getOperations(fixture.catalogId, fixture.releaseA, [
173
+ "schema:conformance:not-an-operation",
174
+ ]), "INPUT_INVALID", CATALOG_STORE_PUBLIC_MESSAGES.operationIdentityInvalid);
175
+ const sparse = new Array(1);
176
+ await assertPublicError(adapter, () => getOperations(fixture.catalogId, fixture.releaseA, sparse), "INPUT_INVALID", CATALOG_STORE_PUBLIC_MESSAGES.operationRequestInvalid);
177
+ });
140
178
  use("catalog schemas are batched, unique, ordered, and empty-safe", async ({ store, fixture }) => {
141
179
  const firstSchemaId = fixture.schemaIds[0];
142
180
  if (firstSchemaId === undefined)
@@ -90,7 +90,11 @@ export function resolveCanonicalPointer(doc, pointer) {
90
90
  !Object.hasOwn(node, index)) {
91
91
  throw new Error("JSON Pointer array index was not found");
92
92
  }
93
- node = node[index];
93
+ const element = Object.getOwnPropertyDescriptor(node, index);
94
+ if (element === undefined || !("value" in element)) {
95
+ throw new Error("JSON Pointer array index was not found");
96
+ }
97
+ node = element.value;
94
98
  continue;
95
99
  }
96
100
  if (typeof node !== "object" ||
@@ -90,7 +90,8 @@ function requireString(value, label, maximum, pattern) {
90
90
  if (typeof value !== "string" ||
91
91
  value.length === 0 ||
92
92
  value.length > maximum ||
93
- (pattern && !pattern.test(value))) {
93
+ (pattern && !pattern.test(value)) // nosemgrep -- anchored linear-time literal; length capped by maximum above
94
+ ) {
94
95
  throw manifestInvalid(`${label} is invalid`);
95
96
  }
96
97
  return value;
@@ -107,7 +108,10 @@ function requireInteger(value, label) {
107
108
  return value;
108
109
  }
109
110
  function requireDigest(value, label) {
110
- if (typeof value !== "string" || !digestPattern.test(value))
111
+ if (typeof value !== "string" ||
112
+ value.length !== 64 ||
113
+ !digestPattern.test(value) // nosemgrep -- anchored fixed-length linear literal; length pinned to 64 above
114
+ )
111
115
  throw manifestInvalid(`${label} is invalid`);
112
116
  return value;
113
117
  }
@@ -17,7 +17,8 @@ function assertSegment(value, maximumLength, pattern, label) {
17
17
  value.length > maximumLength ||
18
18
  value === "." ||
19
19
  value === ".." ||
20
- !pattern.test(value)) {
20
+ !pattern.test(value) // nosemgrep -- anchored linear-time literal; length capped by maximumLength above
21
+ ) {
21
22
  throw invalidReference(`${label} is invalid`);
22
23
  }
23
24
  }
@@ -27,6 +27,12 @@ function manifestKey(catalogId, releaseId) {
27
27
  }
28
28
  const maximumSearchReleases = 8;
29
29
  const maximumInventorySchemaBatch = 128;
30
+ // Stores null record_json over maxRecordBytes, so one operation batch carries
31
+ // at most 16 MiB of raw row text; rows are then verified one at a time, so
32
+ // only one record's working copies are live at once. Sized for a Worker
33
+ // isolate. Schema batches keep the maxSchemaClosureBytes-derived bound
34
+ // because they serve closure resolution.
35
+ const OPERATION_BATCH_BYTE_BUDGET = 16 * 1024 * 1024;
30
36
  // State churn may require reproof, but one search may spend no more than the
31
37
  // same eight-release compatibility envelope used for manifest authentication.
32
38
  const maximumCompleteReleaseProofs = maximumSearchReleases;
@@ -140,40 +146,86 @@ async function verifyCompleteRelease(store, authenticated, limits, searchBudget)
140
146
  }
141
147
  return bytes;
142
148
  };
143
- const operationRoots = [];
144
- for (const id of operationIds) {
145
- chargeCompleteVerification(searchBudget, { work: 1, storeCalls: 1 });
146
- const row = await store.getOperation(authenticated.manifest.catalogId, authenticated.manifest.releaseId, id);
147
- if (row === null) {
148
- throw new OpenApiMcpError("RECORD_NOT_ADMITTED", "Release inventory operation is missing");
149
+ // Each response is bounded by its batch size times maxRecordBytes, and each
150
+ // request by the store's ID byte limit; one fixed JSON parameter keeps D1
151
+ // bindings flat.
152
+ const batchSizeFor = (byteBudget) => Math.max(1, Math.min(maximumInventorySchemaBatch, Math.floor(byteBudget / limits.maxRecordBytes)));
153
+ const operationBatchSize = batchSizeFor(OPERATION_BATCH_BYTE_BUDGET);
154
+ const schemaBatchSize = batchSizeFor(limits.maxSchemaClosureBytes);
155
+ const nextBatch = (ids, start, kind) => {
156
+ const batchSize = kind === "operation" ? operationBatchSize : schemaBatchSize;
157
+ const batch = [];
158
+ let requestBytes = 0;
159
+ for (let index = start; index < ids.length; index += 1) {
160
+ if (batch.length >= batchSize)
161
+ break;
162
+ const idBytes = encoder.encode(ids[index]).byteLength;
163
+ if (idBytes > limits.maxSchemaClosureBytes ||
164
+ (batch.length > 0 &&
165
+ requestBytes + idBytes > limits.maxSchemaClosureBytes))
166
+ break;
167
+ batch.push(ids[index]);
168
+ requestBytes += idBytes;
149
169
  }
150
- const operation = await verifyStoredRecord(authenticated, row, limits);
170
+ if (batch.length === 0) {
171
+ throw new OpenApiMcpError("RECORD_NOT_ADMITTED", `Release inventory ${kind} request exceeds its byte limit`);
172
+ }
173
+ return batch;
174
+ };
175
+ const operationRoots = [];
176
+ const admitOperation = (id, operation) => {
151
177
  if (!operation.id.startsWith("operation:") || operation.id !== id) {
152
178
  throw new OpenApiMcpError("RECORD_DIGEST_MISMATCH", "Release inventory operation identity does not match");
153
179
  }
154
180
  accountRecord(operation);
155
181
  operationRoots.push([...new Set(operation.schemaIds)].sort());
182
+ };
183
+ const missingOperation = () => new OpenApiMcpError("RECORD_NOT_ADMITTED", "Release inventory operation is missing");
184
+ if (store.getOperations === undefined) {
185
+ for (const id of operationIds) {
186
+ chargeCompleteVerification(searchBudget, { work: 1, storeCalls: 1 });
187
+ const row = await store.getOperation(authenticated.manifest.catalogId, authenticated.manifest.releaseId, id);
188
+ if (row === null)
189
+ throw missingOperation();
190
+ admitOperation(id, await verifyStoredRecord(authenticated, row, limits));
191
+ }
192
+ }
193
+ else {
194
+ let operationOffset = 0;
195
+ while (operationOffset < operationIds.length) {
196
+ const ids = nextBatch(operationIds, operationOffset, "operation");
197
+ operationOffset += ids.length;
198
+ chargeCompleteVerification(searchBudget, {
199
+ work: ids.length,
200
+ storeCalls: 1,
201
+ });
202
+ const result = await store.getOperations(authenticated.manifest.catalogId, authenticated.manifest.releaseId, ids);
203
+ const rows = snapshotRows(result, ids.length);
204
+ // Duplicate or unrequested rows are integrity failures, like an identity
205
+ // mismatch on the per-row path; absent rows are missing. Verify one row
206
+ // at a time: batching saves round-trips, not hashing.
207
+ const requested = new Set(ids);
208
+ const returned = new Map();
209
+ for (const row of rows) {
210
+ const record = await verifyStoredRecord(authenticated, row, limits);
211
+ if (!requested.has(record.id) || returned.has(record.id)) {
212
+ throw new OpenApiMcpError("RECORD_DIGEST_MISMATCH", "Release inventory operation rows are ambiguous");
213
+ }
214
+ returned.set(record.id, record);
215
+ }
216
+ for (const id of ids) {
217
+ const operation = returned.get(id);
218
+ if (operation === undefined)
219
+ throw missingOperation();
220
+ admitOperation(id, operation);
221
+ }
222
+ }
156
223
  }
157
- const schemaBatchSize = Math.max(1, Math.min(maximumInventorySchemaBatch, Math.floor(limits.maxSchemaClosureBytes / limits.maxRecordBytes)));
158
224
  const schemas = new Map();
159
225
  let offset = 0;
160
226
  while (offset < schemaIds.length) {
161
- const ids = [];
162
- let requestBytes = 0;
163
- while (offset < schemaIds.length && ids.length < schemaBatchSize) {
164
- const id = schemaIds[offset];
165
- const idBytes = encoder.encode(id).byteLength;
166
- if (idBytes > limits.maxSchemaClosureBytes ||
167
- (ids.length > 0 &&
168
- requestBytes + idBytes > limits.maxSchemaClosureBytes))
169
- break;
170
- ids.push(id);
171
- requestBytes += idBytes;
172
- offset += 1;
173
- }
174
- if (ids.length === 0) {
175
- throw new OpenApiMcpError("RECORD_NOT_ADMITTED", "Release inventory schema request exceeds its byte limit");
176
- }
227
+ const ids = nextBatch(schemaIds, offset, "schema");
228
+ offset += ids.length;
177
229
  chargeCompleteVerification(searchBudget, {
178
230
  work: ids.length,
179
231
  storeCalls: 1,
@@ -28,6 +28,10 @@ export declare const CATALOG_STORE_PUBLIC_MESSAGES: Readonly<{
28
28
  recordJsonInvalid: "Stored record JSON is invalid";
29
29
  operationRowAmbiguous: "Stored operation row is ambiguous";
30
30
  operationIdentifierMismatch: "Stored operation identifier does not match request";
31
+ operationRequestLimitExceeded: "Operation request exceeds its limit";
32
+ operationRequestInvalid: "Operation request is invalid";
33
+ operationTransportTooManyRows: "Stored operation transport returned too many rows";
34
+ operationRowsInvalid: "Stored operation rows are invalid";
31
35
  schemaRequestLimitExceeded: "Schema request exceeds its limit";
32
36
  schemaRequestInvalid: "Schema request is invalid";
33
37
  schemaTransportTooManyRows: "Stored schema transport returned too many rows";
@@ -49,4 +53,4 @@ export interface D1CatalogDatabase {
49
53
  prepare(sql: string): D1CatalogPreparedStatement;
50
54
  }
51
55
  /** Construct the Worker-safe D1 implementation using only fixed SQL statements. */
52
- export declare function createD1CatalogStore(binding: D1CatalogDatabase, limits?: Partial<RuntimeLimits>): CatalogStore;
56
+ export declare function createD1CatalogStore(binding: D1CatalogDatabase, limits?: Partial<RuntimeLimits>): Required<CatalogStore>;
@@ -64,6 +64,24 @@ WHERE s.catalog_id = ? AND s.release_id = ?
64
64
  AND r.format IN (4, 5) AND r.contract = 1
65
65
  ORDER BY s.record_id COLLATE BINARY
66
66
  LIMIT ?;`;
67
+ const GET_OPERATIONS_SQL = `WITH requested(record_id) AS (
68
+ SELECT DISTINCT value FROM json_each(?) WHERE typeof(value) = 'text'
69
+ )
70
+ SELECT
71
+ CASE WHEN typeof(o.record_id) = 'text' AND length(CAST(o.record_id AS BLOB)) <= ? THEN o.record_id ELSE NULL END AS record_id,
72
+ length(CAST(o.record_id AS BLOB)) AS record_id_bytes,
73
+ CASE WHEN typeof(o.logical_digest) = 'text' AND length(CAST(o.logical_digest AS BLOB)) <= ? THEN o.logical_digest ELSE NULL END AS logical_digest,
74
+ length(CAST(o.logical_digest AS BLOB)) AS logical_digest_bytes,
75
+ CASE WHEN typeof(o.record_json) = 'text' AND length(CAST(o.record_json AS BLOB)) <= ? THEN o.record_json ELSE NULL END AS record_json,
76
+ length(CAST(o.record_json AS BLOB)) AS record_json_bytes
77
+ FROM requested
78
+ JOIN operations AS o ON o.record_id = requested.record_id
79
+ JOIN release_metadata AS r
80
+ ON r.catalog_id = o.catalog_id AND r.release_id = o.release_id
81
+ WHERE o.catalog_id = ? AND o.release_id = ?
82
+ AND r.format IN (4, 5) AND r.contract = 1
83
+ ORDER BY o.record_id COLLATE BINARY
84
+ LIMIT ?;`;
67
85
  const digestPattern = /^[0-9a-f]{64}$/;
68
86
  const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
69
87
  const signaturePattern = /^[A-Za-z0-9_-]+$/;
@@ -104,6 +122,10 @@ export const CATALOG_STORE_PUBLIC_MESSAGES = Object.freeze({
104
122
  recordJsonInvalid: "Stored record JSON is invalid",
105
123
  operationRowAmbiguous: "Stored operation row is ambiguous",
106
124
  operationIdentifierMismatch: "Stored operation identifier does not match request",
125
+ operationRequestLimitExceeded: "Operation request exceeds its limit",
126
+ operationRequestInvalid: "Operation request is invalid",
127
+ operationTransportTooManyRows: "Stored operation transport returned too many rows",
128
+ operationRowsInvalid: "Stored operation rows are invalid",
107
129
  schemaRequestLimitExceeded: "Schema request exceeds its limit",
108
130
  schemaRequestInvalid: "Schema request is invalid",
109
131
  schemaTransportTooManyRows: "Stored schema transport returned too many rows",
@@ -400,6 +422,12 @@ function snapshotSearchQuery(value, limits) {
400
422
  }
401
423
  }
402
424
  function snapshotSchemaIds(value, limits) {
425
+ return snapshotRecordIds(value, limits, requestedSchemaId, CATALOG_STORE_PUBLIC_MESSAGES.schemaRequestLimitExceeded, CATALOG_STORE_PUBLIC_MESSAGES.schemaRequestInvalid);
426
+ }
427
+ function snapshotOperationIds(value, limits) {
428
+ return snapshotRecordIds(value, limits, requestedOperationId, CATALOG_STORE_PUBLIC_MESSAGES.operationRequestLimitExceeded, CATALOG_STORE_PUBLIC_MESSAGES.operationRequestInvalid);
429
+ }
430
+ function snapshotRecordIds(value, limits, parse, limitMessage, invalidMessage) {
403
431
  try {
404
432
  if (!Array.isArray(value) ||
405
433
  Object.getPrototypeOf(value) !== Array.prototype)
@@ -413,7 +441,7 @@ function snapshotSchemaIds(value, limits) {
413
441
  length.value < 0)
414
442
  throw new Error();
415
443
  if (length.value > limits.maxManifestRecords)
416
- throw input(CATALOG_STORE_PUBLIC_MESSAGES.schemaRequestLimitExceeded);
444
+ throw input(limitMessage);
417
445
  const names = Object.getOwnPropertyNames(value);
418
446
  if (names.length !== length.value + 1 || !names.includes("length"))
419
447
  throw new Error();
@@ -423,10 +451,10 @@ function snapshotSchemaIds(value, limits) {
423
451
  const id = Object.getOwnPropertyDescriptor(value, String(index));
424
452
  if (!id?.enumerable || !("value" in id))
425
453
  throw new Error();
426
- const parsed = requestedSchemaId(id.value);
454
+ const parsed = parse(id.value);
427
455
  totalBytes += textEncoder.encode(parsed).byteLength;
428
456
  if (totalBytes > limits.maxSchemaClosureBytes)
429
- throw input(CATALOG_STORE_PUBLIC_MESSAGES.schemaRequestLimitExceeded);
457
+ throw input(limitMessage);
430
458
  ids.push(parsed);
431
459
  }
432
460
  return ids;
@@ -434,7 +462,7 @@ function snapshotSchemaIds(value, limits) {
434
462
  catch (error) {
435
463
  if (error instanceof OpenApiMcpError)
436
464
  throw error;
437
- throw input(CATALOG_STORE_PUBLIC_MESSAGES.schemaRequestInvalid);
465
+ throw input(invalidMessage);
438
466
  }
439
467
  }
440
468
  async function d1All(database, sql, values, errorCode) {
@@ -540,6 +568,37 @@ class D1CatalogStore {
540
568
  throw failure("RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.operationIdentifierMismatch);
541
569
  return row;
542
570
  }
571
+ async getOperations(catalog, release, ids) {
572
+ const catalogIdValue = catalogId(catalog);
573
+ const releaseIdValue = releaseId(release);
574
+ const operationIds = snapshotOperationIds(ids, this.limits);
575
+ if (operationIds.length === 0)
576
+ return [];
577
+ const requested = new Set(operationIds);
578
+ const rows = await d1All(this.database, GET_OPERATIONS_SQL, [
579
+ canonicalJson([...requested].sort()),
580
+ operationIdBytes,
581
+ digestBytes,
582
+ this.limits.maxRecordBytes,
583
+ catalogIdValue,
584
+ releaseIdValue,
585
+ requested.size + 1,
586
+ ], "RECORD_DIGEST_MISMATCH");
587
+ if (rows.length > requested.size)
588
+ throw failure("RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.operationTransportTooManyRows);
589
+ // Unlike getSchemas, absent IDs are omitted rather than rejected, matching
590
+ // getOperation's null; callers decide whether absence is fatal.
591
+ const records = [];
592
+ let previous = "";
593
+ for (const value of rows) {
594
+ const row = decodeRecord(value, "operation", this.limits);
595
+ if (!requested.has(row.id) || row.id <= previous)
596
+ throw failure("RECORD_DIGEST_MISMATCH", CATALOG_STORE_PUBLIC_MESSAGES.operationRowsInvalid);
597
+ previous = row.id;
598
+ records.push(row);
599
+ }
600
+ return records;
601
+ }
543
602
  async getSchemas(catalog, release, ids) {
544
603
  const catalogIdValue = catalogId(catalog);
545
604
  const releaseIdValue = releaseId(release);
@@ -530,7 +530,7 @@ function serializeCanonical(value, ancestors) {
530
530
  }
531
531
  function isCanonicalArrayIndex(key, length) {
532
532
  if (!/^(?:0|[1-9]\d*)$/.test(key))
533
- return false;
533
+ return false; // nosemgrep -- anchored literal, disjoint alternation, single star: linear-time
534
534
  const index = Number(key);
535
535
  return (Number.isSafeInteger(index) &&
536
536
  index >= 0 &&
@@ -167,6 +167,13 @@ export interface CatalogStore {
167
167
  getManifest(catalogId: CatalogId, releaseId: ReleaseId): Promise<ManifestEnvelope>;
168
168
  searchCandidates(query: SearchQuery): Promise<readonly CandidateRef[]>;
169
169
  getOperation(catalogId: CatalogId, releaseId: ReleaseId, id: TypedOperationId): Promise<StoredRecord<OperationRecordV4> | null>;
170
+ /**
171
+ * Optional batched operation read used by complete-release verification.
172
+ * Returns the stored rows for the requested IDs; a missing ID is omitted
173
+ * (as `getOperation` returns null). Stores without it are read row by row.
174
+ * It has no response-size bound of its own; callers bound the batch.
175
+ */
176
+ getOperations?(catalogId: CatalogId, releaseId: ReleaseId, ids: readonly TypedOperationId[]): Promise<readonly StoredRecord<OperationRecordV4>[]>;
170
177
  getSchemas(catalogId: CatalogId, releaseId: ReleaseId, ids: readonly TypedSchemaId[]): Promise<readonly StoredRecord<SchemaRecordV4>[]>;
171
178
  }
172
179
  /** Immutable high-water data plus the release currently admitted for use. */
@@ -16,5 +16,6 @@ export declare class SqliteCatalogStore implements CatalogStore {
16
16
  getManifest(catalog: CatalogId, release: ReleaseId): Promise<ManifestEnvelope>;
17
17
  searchCandidates(query: SearchQuery): Promise<readonly import("../runtime/types.ts").CandidateRef[]>;
18
18
  getOperation(catalog: CatalogId, release: ReleaseId, id: TypedOperationId): Promise<StoredRecord<OperationRecordV4> | null>;
19
+ getOperations(catalog: CatalogId, release: ReleaseId, ids: readonly TypedOperationId[]): Promise<readonly StoredRecord<OperationRecordV4>[]>;
19
20
  getSchemas(catalog: CatalogId, release: ReleaseId, ids: readonly TypedSchemaId[]): Promise<readonly StoredRecord<SchemaRecordV4>[]>;
20
21
  }
@@ -364,6 +364,9 @@ export class SqliteCatalogStore {
364
364
  async getOperation(catalog, release, id) {
365
365
  return await this.#v4Store().getOperation(catalog, release, id);
366
366
  }
367
+ async getOperations(catalog, release, ids) {
368
+ return await this.#v4Store().getOperations(catalog, release, ids);
369
+ }
367
370
  async getSchemas(catalog, release, ids) {
368
371
  return await this.#v4Store().getSchemas(catalog, release, ids);
369
372
  }
@@ -70,7 +70,7 @@ function requireIdentity(catalogId, issuer) {
70
70
  catalogId.length > 128 ||
71
71
  catalogId === "." ||
72
72
  catalogId === ".." ||
73
- !identityPattern.test(catalogId) ||
73
+ !identityPattern.test(catalogId) || // nosemgrep -- anchored linear-time literal; input length is capped at 128 above
74
74
  typeof issuer !== "string" ||
75
75
  issuer.length === 0 ||
76
76
  issuer.length > 256 ||
@@ -299,16 +299,76 @@ async function validateMutexMetadata(mutex) {
299
299
  }
300
300
  return before;
301
301
  }
302
- function regexEscape(value) {
303
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
+ const mutexInitSuffix = ".mutex-init";
303
+ const mutexInitUuidSegments = [8, 4, 4, 4, 12];
304
+ function isAsciiHexDigit(code) {
305
+ return ((code >= 0x30 && code <= 0x39) ||
306
+ (code >= 0x41 && code <= 0x46) ||
307
+ (code >= 0x61 && code <= 0x66));
308
+ }
309
+ function isMutexInitializerUuid(candidate) {
310
+ // 8-4-4-4-12 lowercase/uppercase hex with version 1-5 and variant 8/9/a/b.
311
+ if (candidate.length !== 36)
312
+ return false;
313
+ let position = 0;
314
+ for (let segment = 0; segment < mutexInitUuidSegments.length; segment += 1) {
315
+ if (segment > 0) {
316
+ if (candidate[position] !== "-")
317
+ return false;
318
+ position += 1;
319
+ }
320
+ const length = mutexInitUuidSegments[segment];
321
+ for (let index = 0; index < length; index += 1) {
322
+ const code = candidate.charCodeAt(position);
323
+ if (segment === 2 && index === 0) {
324
+ if (code < 0x31 || code > 0x35)
325
+ return false;
326
+ }
327
+ else if (segment === 3 && index === 0) {
328
+ if (code !== 0x38 &&
329
+ code !== 0x39 &&
330
+ code !== 0x41 &&
331
+ code !== 0x42 &&
332
+ code !== 0x61 &&
333
+ code !== 0x62)
334
+ return false;
335
+ }
336
+ else if (!isAsciiHexDigit(code)) {
337
+ return false;
338
+ }
339
+ position += 1;
340
+ }
341
+ }
342
+ return true;
343
+ }
344
+ function isMutexInitializerAlias(name, targetName) {
345
+ // Matches `.<target>.<digits>.<uuid>.mutex-init` case-insensitively,
346
+ // without building a RegExp from the target name.
347
+ const prefix = `.${targetName}.`;
348
+ if (name.length < prefix.length + 1 + 1 + 36 + mutexInitSuffix.length)
349
+ return false;
350
+ if (name.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase())
351
+ return false;
352
+ if (name.slice(-mutexInitSuffix.length).toLowerCase() !== mutexInitSuffix)
353
+ return false;
354
+ const middle = name.slice(prefix.length, -mutexInitSuffix.length);
355
+ const separator = middle.indexOf(".");
356
+ if (separator <= 0)
357
+ return false;
358
+ const counter = middle.slice(0, separator);
359
+ for (let index = 0; index < counter.length; index += 1) {
360
+ const code = counter.charCodeAt(index);
361
+ if (code < 0x30 || code > 0x39)
362
+ return false;
363
+ }
364
+ return isMutexInitializerUuid(middle.slice(separator + 1));
304
365
  }
305
366
  async function clearMutexInitializerAlias(location) {
306
367
  const final = await lstat(location.mutex);
307
- const namePattern = new RegExp(`^\\.${regexEscape(basename(location.target))}\\.\\d+\\.` +
308
- "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.mutex-init$", "i");
368
+ const targetName = basename(location.target);
309
369
  let removed = false;
310
370
  for (const name of await readdir(location.parent)) {
311
- if (!namePattern.test(name))
371
+ if (!isMutexInitializerAlias(name, targetName))
312
372
  continue;
313
373
  const candidate = join(location.parent, name);
314
374
  let metadata;
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import { acceptedContent, inputResponse, } from "@modelcontextprotocol/server";
2
3
  import { z } from "zod";
3
4
  import { snapshotCredentialAuthorizationBinding } from "../runtime/credential-binding.js";
@@ -54,6 +55,11 @@ function hexRandom(randomBytes) {
54
55
  }
55
56
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
56
57
  }
58
+ function randomIdsEqual(left, right) {
59
+ if (left.length !== right.length)
60
+ return false;
61
+ return timingSafeEqual(Buffer.from(left), Buffer.from(right));
62
+ }
57
63
  function exactPayloadMatch(payload, pending) {
58
64
  return (payload.pendingId === pending.pendingId &&
59
65
  payload.callDigest === pending.callDigest &&
@@ -416,7 +422,7 @@ class LedgerActionAuthorizer {
416
422
  const authorizationId = Object.freeze(Object.create(null));
417
423
  let collision = false;
418
424
  for (const existing of this.#receipts.values()) {
419
- if (existing.randomId === token) {
425
+ if (randomIdsEqual(existing.randomId, token)) {
420
426
  collision = true;
421
427
  break;
422
428
  }
@@ -30,6 +30,10 @@ export function createStdioSearchRuntime(catalogs, options) {
30
30
  throw new OpenApiMcpError("UPSTREAM_ERROR");
31
31
  },
32
32
  };
33
+ // Batch only when every source store can; otherwise keep per-row reads.
34
+ // Every source was just checked, so `?? []` only fails closed as missing.
35
+ if (ordered.every((entry) => entry.store.getOperations !== undefined))
36
+ store.getOperations = async (catalog, release, ids) => (await storeFor(catalog, release).getOperations?.(catalog, release, ids)) ?? [];
33
37
  return createRuntimeWithCandidateLookup({ ...options, limits, store }, async (query, tryChargeSource) => {
34
38
  const batches = [];
35
39
  let remaining = query.limit;
@@ -138,12 +138,45 @@ function outcome(value, snapshot) {
138
138
  ]
139
139
  .filter(Boolean)
140
140
  .sort((left, right) => right.length - left.length);
141
- const pattern = variants.length
142
- ? new RegExp(variants
143
- .map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
144
- .join("|"), "g")
145
- : undefined;
146
- const redact = (text) => pattern ? text.replace(pattern, "[REDACTED]") : text;
141
+ // Literal redaction without a dynamic RegExp: find the earliest occurrence
142
+ // across variants (ties broken longest-first, matching the previous
143
+ // longest-first alternation), redact it, and resume after the match.
144
+ // indexOf skips non-matching regions in native code, so adversarial
145
+ // long-prefix inputs stay linear instead of quadratic per offset.
146
+ const redact = (text) => {
147
+ if (variants.length === 0 || text.length === 0)
148
+ return text;
149
+ const parts = [];
150
+ let cursor = 0;
151
+ let redacted = false;
152
+ for (;;) {
153
+ let at = -1;
154
+ let match = "";
155
+ for (const variant of variants) {
156
+ const found = text.indexOf(variant, cursor);
157
+ if (found === -1)
158
+ continue;
159
+ if (at === -1 ||
160
+ found < at ||
161
+ (found === at && variant.length > match.length)) {
162
+ at = found;
163
+ match = variant;
164
+ }
165
+ }
166
+ if (at === -1)
167
+ break;
168
+ if (at > cursor)
169
+ parts.push(text.slice(cursor, at));
170
+ parts.push("[REDACTED]");
171
+ cursor = at + match.length;
172
+ redacted = true;
173
+ }
174
+ if (!redacted)
175
+ return text;
176
+ if (cursor < text.length)
177
+ parts.push(text.slice(cursor));
178
+ return parts.join("");
179
+ };
147
180
  // Only upstream values are untrusted. Keep protocol fields and opaque tokens intact.
148
181
  if (value.kind === "success") {
149
182
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@knitli/openapi-mcp",
3
3
  "private": false,
4
- "version": "1.3.1",
4
+ "version": "1.4.1",
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",
@@ -73,7 +73,7 @@
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/server": "2.0.0",
75
75
  "undici": "8.10.2",
76
- "yaml": "^2.9.0",
76
+ "yaml": "2.9.0",
77
77
  "zod": "4.5.4"
78
78
  },
79
79
  "devDependencies": {