@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.
- package/dist/conformance/runtime-suite.js +38 -0
- package/dist/operations.js +5 -1
- package/dist/runtime/manifest.js +6 -2
- package/dist/runtime/references.js +2 -1
- package/dist/runtime/runtime.js +76 -24
- package/dist/runtime/store.d.ts +5 -1
- package/dist/runtime/store.js +63 -4
- package/dist/runtime/strict-json.js +1 -1
- package/dist/runtime/types.d.ts +7 -0
- package/dist/sqlite/catalog-store.d.ts +1 -0
- package/dist/sqlite/catalog-store.js +3 -0
- package/dist/sqlite/generation-store.js +66 -6
- package/dist/stdio/authorizer.js +7 -1
- package/dist/stdio/search.js +4 -0
- package/dist/stdio/server.js +39 -6
- package/package.json +2 -2
|
@@ -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)
|
package/dist/operations.js
CHANGED
|
@@ -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
|
-
|
|
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" ||
|
package/dist/runtime/manifest.js
CHANGED
|
@@ -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" ||
|
|
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
|
}
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/runtime/store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/runtime/store.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
454
|
+
const parsed = parse(id.value);
|
|
427
455
|
totalBytes += textEncoder.encode(parsed).byteLength;
|
|
428
456
|
if (totalBytes > limits.maxSchemaClosureBytes)
|
|
429
|
-
throw input(
|
|
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(
|
|
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 &&
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
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
|
|
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 (!
|
|
371
|
+
if (!isMutexInitializerAlias(name, targetName))
|
|
312
372
|
continue;
|
|
313
373
|
const candidate = join(location.parent, name);
|
|
314
374
|
let metadata;
|
package/dist/stdio/authorizer.js
CHANGED
|
@@ -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
|
|
425
|
+
if (randomIdsEqual(existing.randomId, token)) {
|
|
420
426
|
collision = true;
|
|
421
427
|
break;
|
|
422
428
|
}
|
package/dist/stdio/search.js
CHANGED
|
@@ -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;
|
package/dist/stdio/server.js
CHANGED
|
@@ -138,12 +138,45 @@ function outcome(value, snapshot) {
|
|
|
138
138
|
]
|
|
139
139
|
.filter(Boolean)
|
|
140
140
|
.sort((left, right) => right.length - left.length);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const redact = (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.
|
|
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": "
|
|
76
|
+
"yaml": "2.9.0",
|
|
77
77
|
"zod": "4.5.4"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|