@knitli/openapi-mcp 1.4.0 → 2.0.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 +34 -7
- package/dist/operations.js +5 -1
- package/dist/runtime/manifest.js +6 -2
- package/dist/runtime/references.js +2 -1
- package/dist/runtime/strict-json.js +1 -1
- package/dist/runtime/types.d.ts +1 -1
- package/dist/sqlite/generation-store.js +66 -6
- package/dist/stdio/authorizer.d.ts +12 -3
- package/dist/stdio/authorizer.js +124 -31
- package/dist/stdio/index.d.ts +5 -2
- package/dist/stdio/index.js +4 -3
- package/dist/stdio/server.d.ts +2 -2
- package/dist/stdio/server.js +51 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@ The executable artifact format is **5**, runtime contract **1**, and prepared-ca
|
|
|
8
8
|
format **2**. This package is compiled TypeScript, separate from the Toolshed
|
|
9
9
|
prompt plugins.
|
|
10
10
|
|
|
11
|
+
The standalone `serve` CLI denies actions because it has no independent operator
|
|
12
|
+
approval provider. Embedded hosts can supply one through `serveOpenApiStdio(config, approve)`.
|
|
13
|
+
|
|
11
14
|
## Install and check the runtime
|
|
12
15
|
|
|
13
16
|
Local serving requires Node `>=24.16.0 <25`. Packed consumers exercise Node 24
|
|
@@ -186,7 +189,7 @@ These endpoints and client
|
|
|
186
189
|
registration must be configured by the operator. The server uses authorization
|
|
187
190
|
code PKCE with S256 and a temporary `127.0.0.1` callback listener on an ephemeral
|
|
188
191
|
port at `/oauth/callback`; the identity provider must support that redirect.
|
|
189
|
-
The client needs URL elicitation for OAuth
|
|
192
|
+
The client needs URL elicitation for OAuth.
|
|
190
193
|
Open the offered authorization URL, finish login, then follow the client's resume
|
|
191
194
|
flow. Unsupported elicitation fails closed.
|
|
192
195
|
|
|
@@ -234,10 +237,33 @@ If an action reports `UPSTREAM_OUTCOME_UNKNOWN`, its error includes the exact
|
|
|
234
237
|
the uncertain outcome; the server has consumed the authorization and does not
|
|
235
238
|
automatically retry the action.
|
|
236
239
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
The built-in CLI denies actions with `ACTION_DENIED`: MCP client confirmation
|
|
241
|
+
does not prove independent operator approval. Configured `exactPolicies` cannot
|
|
242
|
+
activate through client responses alone. Search and reads remain available.
|
|
243
|
+
|
|
244
|
+
An embedding can pass the same approval callback as the second argument to
|
|
245
|
+
`serveOpenApiStdio(config, approve)`, provide a host-owned `StatefulActionAuthorizer` through
|
|
246
|
+
`createOpenApiMcpServer`, or use `createStdioActionAuthorizer(templates, approve)`
|
|
247
|
+
from `@knitli/openapi-mcp/stdio`. The built-in elicitation authorizers require an
|
|
248
|
+
`approve(presentation, details)` callback that authenticates operator approval outside
|
|
249
|
+
the MCP requester. Without it they deny. The callback receives the frozen
|
|
250
|
+
presentation bound to the exact prepared-call and credential-binding digests,
|
|
251
|
+
including policy digest and expiry when activating standing authority. It must
|
|
252
|
+
return literal `true` only for that approval; copying the client response or
|
|
253
|
+
always returning `true` defeats this boundary. Requester confirmation is still
|
|
254
|
+
required, but cannot replace the independent approval. Rejection, provider
|
|
255
|
+
errors, expiry, changed bindings, and reused pending state prevent dispatch.
|
|
256
|
+
|
|
257
|
+
The host-only `details` contains deeply frozen normalized argument values,
|
|
258
|
+
the non-secret credential binding, the complete compiled policy when activating
|
|
259
|
+
standing authority, the approval deadline, and an abort signal. These values
|
|
260
|
+
remain untrusted data; the host must display them safely for informed operator
|
|
261
|
+
approval. They are never included in the redacted MCP presentation. Providers
|
|
262
|
+
must honor the signal and release prompts or other work when aborted. Expiry or
|
|
263
|
+
request cancellation denies promptly; providers that ignore cancellation retain
|
|
264
|
+
a bounded slot until they settle, and exhausted capacity fails closed.
|
|
265
|
+
`compileExactPolicy` and its template/constraint types are exported from
|
|
266
|
+
`@knitli/openapi-mcp/stdio` for constructing exact policies in embeddings.
|
|
241
267
|
Destructive operations require the stronger destructive acknowledgment. An operator
|
|
242
268
|
may supply narrowly bound `exactPolicies` for eligible create/update operations;
|
|
243
269
|
they are not broad scopes or a general confirmation bypass. Do not construct policy
|
|
@@ -369,8 +395,9 @@ parent must be a real, owner-only directory. Generic startup errors are delibera
|
|
|
369
395
|
to avoid exposing config secrets; validate with `parseOpenApiStdioConfig` in a
|
|
370
396
|
controlled operator process. Missing environment credentials or OAuth elicitation
|
|
371
397
|
must be resolved before a call can proceed. An action refused for lack of
|
|
372
|
-
confirmation needs an
|
|
373
|
-
|
|
398
|
+
confirmation needs an embedding with an independent operator approval provider;
|
|
399
|
+
the built-in CLI has no such channel. Do not retry a changed action with old
|
|
400
|
+
approval state.
|
|
374
401
|
|
|
375
402
|
From the repository, the package checks are:
|
|
376
403
|
|
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
|
}
|
|
@@ -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
|
@@ -323,7 +323,7 @@ export interface SafeApprovalPresentation {
|
|
|
323
323
|
preparedCallDigest: Sha256;
|
|
324
324
|
}
|
|
325
325
|
export interface ActionAuthorizer {
|
|
326
|
-
authorize(call: PreparedCall, credential: CredentialAuthorizationBinding, context: AuthorizationContext): Promise<AuthorizationDecision>;
|
|
326
|
+
authorize(call: PreparedCall, credential: CredentialAuthorizationBinding, context: AuthorizationContext, signal?: AbortSignal): Promise<AuthorizationDecision>;
|
|
327
327
|
consume(decision: AuthorizedActionDecision, call: PreparedCall, credential: CredentialAuthorizationBinding): Promise<void>;
|
|
328
328
|
}
|
|
329
329
|
export interface AuthorizedActionDecision {
|
|
@@ -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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ServerContext } from "@modelcontextprotocol/server";
|
|
2
|
-
import type { ActionAuthorizer, VerifiedActionRequestState } from "../runtime/types.ts";
|
|
2
|
+
import type { ActionAuthorizer, CredentialAuthorizationBinding, JsonObject, SafeApprovalPresentation, VerifiedActionRequestState } from "../runtime/types.ts";
|
|
3
3
|
import { type CompiledExactPolicy } from "./exact-policy.ts";
|
|
4
4
|
interface InternalLimits {
|
|
5
5
|
readonly pending: number;
|
|
@@ -16,10 +16,19 @@ export interface AuthorizerTestOptions {
|
|
|
16
16
|
}
|
|
17
17
|
export interface ClientElicitationAuthorizerOptions extends AuthorizerTestOptions {
|
|
18
18
|
readonly elicitationAvailable: () => boolean;
|
|
19
|
+
/** Authenticate operator approval outside the MCP requester; never trust inputResponses. */
|
|
20
|
+
readonly approve?: (presentation: SafeApprovalPresentation, context: TrustedActionApprovalContext) => Promise<boolean>;
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
/** Host-only values; never include these details in an MCP response. */
|
|
23
|
+
export interface TrustedActionApprovalContext {
|
|
24
|
+
readonly normalizedArguments: Readonly<JsonObject>;
|
|
25
|
+
readonly credentialBinding: CredentialAuthorizationBinding;
|
|
26
|
+
readonly policy?: CompiledExactPolicy;
|
|
27
|
+
readonly expiresAt: number;
|
|
28
|
+
readonly signal: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
export interface ExactPolicyAuthorizerOptions extends ClientElicitationAuthorizerOptions {
|
|
21
31
|
readonly templates: readonly CompiledExactPolicy[];
|
|
22
|
-
readonly elicitationAvailable: () => boolean;
|
|
23
32
|
}
|
|
24
33
|
export interface StatefulActionAuthorizer extends ActionAuthorizer {
|
|
25
34
|
readonly requestStateVerifier: (state: string, context?: ServerContext) => Promise<VerifiedActionRequestState>;
|
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 &&
|
|
@@ -147,17 +153,21 @@ function snapshotContext(context) {
|
|
|
147
153
|
class LedgerActionAuthorizer {
|
|
148
154
|
requestStateVerifier;
|
|
149
155
|
#elicitationAvailable;
|
|
156
|
+
#approve;
|
|
150
157
|
#now;
|
|
151
158
|
#randomBytes;
|
|
152
159
|
#limits;
|
|
153
160
|
#policies;
|
|
154
161
|
#pending = new Map();
|
|
162
|
+
#approving = new WeakSet();
|
|
163
|
+
#approvalsInFlight = 0;
|
|
155
164
|
#receipts = new Map();
|
|
156
165
|
#decisions = new WeakMap();
|
|
157
166
|
#activations = new Map();
|
|
158
167
|
#requestState;
|
|
159
168
|
constructor(options, policies = []) {
|
|
160
169
|
this.#elicitationAvailable = options.elicitationAvailable;
|
|
170
|
+
this.#approve = options.approve;
|
|
161
171
|
this.#now = options.now ?? Date.now;
|
|
162
172
|
this.#randomBytes = options.randomBytes ?? defaultRandom;
|
|
163
173
|
this.#limits = normalizeLimits(options.limits);
|
|
@@ -171,13 +181,15 @@ class LedgerActionAuthorizer {
|
|
|
171
181
|
now: this.#now,
|
|
172
182
|
isPending: (payload) => {
|
|
173
183
|
const pending = this.#pending.get(payload.pendingId);
|
|
174
|
-
return pending !== undefined &&
|
|
184
|
+
return (pending !== undefined &&
|
|
185
|
+
!this.#approving.has(pending) &&
|
|
186
|
+
exactPayloadMatch(payload, pending));
|
|
175
187
|
},
|
|
176
188
|
});
|
|
177
189
|
this.requestStateVerifier = this.#requestState.verify;
|
|
178
190
|
Object.freeze(this);
|
|
179
191
|
}
|
|
180
|
-
async authorize(call, credential, context) {
|
|
192
|
+
async authorize(call, credential, context, signal) {
|
|
181
193
|
let ownedContext;
|
|
182
194
|
try {
|
|
183
195
|
ownedContext = snapshotContext(context);
|
|
@@ -196,8 +208,10 @@ class LedgerActionAuthorizer {
|
|
|
196
208
|
// Resume is deliberately handled before active-policy matching. An
|
|
197
209
|
// echoed confirmation can never be bypassed by a newly active policy.
|
|
198
210
|
if (ownedContext.kind === "resume") {
|
|
199
|
-
return await this.#resume(prepared, ownedCredential, ownedContext);
|
|
211
|
+
return await this.#resume(prepared, ownedCredential, ownedContext, signal);
|
|
200
212
|
}
|
|
213
|
+
if (signal?.aborted)
|
|
214
|
+
return deny("Action request was cancelled");
|
|
201
215
|
const now = this.#now();
|
|
202
216
|
this.#prune(now);
|
|
203
217
|
const policy = this.#matchingPolicy(prepared, now);
|
|
@@ -258,16 +272,19 @@ class LedgerActionAuthorizer {
|
|
|
258
272
|
throw deniedError();
|
|
259
273
|
});
|
|
260
274
|
}
|
|
261
|
-
async #resume(call, credential, context) {
|
|
275
|
+
async #resume(call, credential, context, signal) {
|
|
262
276
|
const payload = this.#requestState.read(context.requestState);
|
|
263
277
|
if (payload === undefined)
|
|
264
278
|
return deny("Invalid confirmation state");
|
|
265
279
|
const pending = this.#pending.get(payload.pendingId);
|
|
266
|
-
if (pending === undefined ||
|
|
280
|
+
if (pending === undefined ||
|
|
281
|
+
this.#approving.has(pending) ||
|
|
282
|
+
!exactPayloadMatch(payload, pending)) {
|
|
267
283
|
return deny("Invalid confirmation state");
|
|
268
284
|
}
|
|
269
285
|
const now = this.#now();
|
|
270
|
-
if (
|
|
286
|
+
if (signal?.aborted ||
|
|
287
|
+
now >= pending.expiresAt ||
|
|
271
288
|
call.preparedCallDigest !== pending.callDigest ||
|
|
272
289
|
credential.bindingDigest !== pending.credentialBindingDigest) {
|
|
273
290
|
this.#pending.delete(pending.pendingId);
|
|
@@ -312,37 +329,112 @@ class LedgerActionAuthorizer {
|
|
|
312
329
|
? this.#activationKey(policy.policyDigest, credential.bindingDigest)
|
|
313
330
|
: undefined;
|
|
314
331
|
const newActivation = activationKey !== undefined && !this.#activations.has(activationKey);
|
|
315
|
-
if (this.#
|
|
332
|
+
if (this.#approvalsInFlight >= this.#limits.pending ||
|
|
333
|
+
this.#receipts.size >= this.#limits.receipts ||
|
|
316
334
|
(newActivation && this.#activations.size >= this.#limits.activations)) {
|
|
317
|
-
//
|
|
335
|
+
// Before independent approval, capacity denial leaves the pending request retriable.
|
|
318
336
|
return deny("Authorization capacity is exhausted");
|
|
319
337
|
}
|
|
320
|
-
|
|
338
|
+
// Reserve the pending slot while the independent channel is awaiting approval.
|
|
339
|
+
this.#approving.add(pending);
|
|
321
340
|
try {
|
|
322
|
-
|
|
341
|
+
if (!(await this.#awaitApproval(pending, call, credential, signal))) {
|
|
342
|
+
return deny("Trusted operator approval was not accepted");
|
|
343
|
+
}
|
|
344
|
+
const approvedAt = this.#now();
|
|
345
|
+
if (signal?.aborted ||
|
|
346
|
+
approvedAt >= pending.expiresAt ||
|
|
347
|
+
this.#pending.get(pending.pendingId) !== pending) {
|
|
348
|
+
return deny("Confirmation expired or no longer matches");
|
|
349
|
+
}
|
|
350
|
+
this.#prune(approvedAt);
|
|
351
|
+
if (activationKey !== undefined &&
|
|
352
|
+
!this.#activations.has(activationKey) &&
|
|
353
|
+
this.#activations.size >= this.#limits.activations) {
|
|
354
|
+
return deny("Authorization capacity is exhausted");
|
|
355
|
+
}
|
|
356
|
+
const decision = this.#mintReceipt(call, credential, policy ? "exact-policy" : "per-call", policy?.policyDigest, Math.min(approvedAt + RECEIPT_TTL_MS, activationExpiresAt ?? Number.MAX_SAFE_INTEGER));
|
|
357
|
+
if (policy !== undefined &&
|
|
358
|
+
activationKey !== undefined &&
|
|
359
|
+
activationExpiresAt !== undefined) {
|
|
360
|
+
this.#activations.set(activationKey, Object.freeze({
|
|
361
|
+
policyDigest: policy.policyDigest,
|
|
362
|
+
credentialBindingDigest: credential.bindingDigest,
|
|
363
|
+
expiresAt: activationExpiresAt,
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
return decision;
|
|
323
367
|
}
|
|
324
|
-
|
|
325
|
-
|
|
368
|
+
finally {
|
|
369
|
+
if (this.#pending.get(pending.pendingId) === pending) {
|
|
370
|
+
this.#pending.delete(pending.pendingId);
|
|
371
|
+
}
|
|
326
372
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
373
|
+
}
|
|
374
|
+
async #awaitApproval(pending, call, credential, signal) {
|
|
375
|
+
const approve = this.#approve;
|
|
376
|
+
const expiresAt = Math.min(pending.expiresAt, pending.activationExpiresAt ?? pending.expiresAt);
|
|
377
|
+
const remaining = expiresAt - this.#now();
|
|
378
|
+
if (approve === undefined ||
|
|
379
|
+
signal?.aborted ||
|
|
380
|
+
!Number.isFinite(remaining) ||
|
|
381
|
+
remaining <= 0)
|
|
382
|
+
return false;
|
|
383
|
+
const controller = new AbortController();
|
|
384
|
+
let stop = () => { };
|
|
385
|
+
const interrupted = new Promise((resolve) => {
|
|
386
|
+
stop = () => {
|
|
387
|
+
controller.abort();
|
|
388
|
+
resolve(false);
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
const timer = setTimeout(stop, remaining);
|
|
392
|
+
timer.unref();
|
|
393
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
394
|
+
try {
|
|
395
|
+
const details = Object.freeze({
|
|
396
|
+
normalizedArguments: call.normalizedArguments,
|
|
397
|
+
credentialBinding: credential,
|
|
398
|
+
...(pending.policy === undefined ? {} : { policy: pending.policy }),
|
|
399
|
+
expiresAt,
|
|
400
|
+
signal: controller.signal,
|
|
401
|
+
});
|
|
402
|
+
this.#approvalsInFlight++;
|
|
403
|
+
let result;
|
|
404
|
+
try {
|
|
405
|
+
result = approve(pending.presentation, details);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
this.#approvalsInFlight--;
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
// Providers that ignore cancellation still occupy a bounded slot until they settle.
|
|
412
|
+
const approval = Promise.resolve(result).then((accepted) => {
|
|
413
|
+
this.#approvalsInFlight--;
|
|
414
|
+
return accepted === true;
|
|
415
|
+
}, () => {
|
|
416
|
+
this.#approvalsInFlight--;
|
|
417
|
+
return false;
|
|
418
|
+
});
|
|
419
|
+
return await Promise.race([approval, interrupted]);
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
signal?.removeEventListener("abort", stop);
|
|
424
|
+
controller.abort();
|
|
335
425
|
}
|
|
336
|
-
this.#pending.delete(pending.pendingId);
|
|
337
|
-
return decision;
|
|
338
426
|
}
|
|
339
427
|
async #requestConfirmation(call, credential, policy, activationExpiresAt) {
|
|
428
|
+
if (this.#approve === undefined) {
|
|
429
|
+
return deny("Trusted operator approval is unavailable");
|
|
430
|
+
}
|
|
340
431
|
if (!this.#elicitationAvailable()) {
|
|
341
432
|
return deny("Client elicitation is unavailable");
|
|
342
433
|
}
|
|
343
434
|
const now = this.#now();
|
|
344
435
|
this.#prune(now);
|
|
345
|
-
if (this.#pending.size >= this.#limits.pending
|
|
436
|
+
if (this.#pending.size >= this.#limits.pending ||
|
|
437
|
+
this.#approvalsInFlight >= this.#limits.pending) {
|
|
346
438
|
return deny("Authorization capacity is exhausted");
|
|
347
439
|
}
|
|
348
440
|
let pendingId;
|
|
@@ -359,21 +451,22 @@ class LedgerActionAuthorizer {
|
|
|
359
451
|
if (policy !== undefined && activationExpiresAt === undefined) {
|
|
360
452
|
return deny("Policy activation is invalid");
|
|
361
453
|
}
|
|
454
|
+
const presentation = renderApproval(call, credential, policy === undefined
|
|
455
|
+
? undefined
|
|
456
|
+
: {
|
|
457
|
+
policyDigest: policy.policyDigest,
|
|
458
|
+
expiresAt: activationExpiresAt,
|
|
459
|
+
template: policy.template,
|
|
460
|
+
});
|
|
362
461
|
const pending = Object.freeze({
|
|
363
462
|
pendingId,
|
|
364
463
|
callDigest: call.preparedCallDigest,
|
|
365
464
|
credentialBindingDigest: credential.bindingDigest,
|
|
366
465
|
expiresAt,
|
|
466
|
+
presentation,
|
|
367
467
|
...(policy === undefined ? {} : { policy }),
|
|
368
468
|
...(activationExpiresAt === undefined ? {} : { activationExpiresAt }),
|
|
369
469
|
});
|
|
370
|
-
const presentation = renderApproval(call, credential, policy === undefined
|
|
371
|
-
? undefined
|
|
372
|
-
: {
|
|
373
|
-
policyDigest: policy.policyDigest,
|
|
374
|
-
expiresAt: activationExpiresAt,
|
|
375
|
-
template: policy.template,
|
|
376
|
-
});
|
|
377
470
|
this.#pending.set(pendingId, pending);
|
|
378
471
|
try {
|
|
379
472
|
const requestState = await this.#requestState.mint({
|
|
@@ -416,7 +509,7 @@ class LedgerActionAuthorizer {
|
|
|
416
509
|
const authorizationId = Object.freeze(Object.create(null));
|
|
417
510
|
let collision = false;
|
|
418
511
|
for (const existing of this.#receipts.values()) {
|
|
419
|
-
if (existing.randomId
|
|
512
|
+
if (randomIdsEqual(existing.randomId, token)) {
|
|
420
513
|
collision = true;
|
|
421
514
|
break;
|
|
422
515
|
}
|
package/dist/stdio/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type StdioServerHandle } from "@modelcontextprotocol/server/stdio";
|
|
2
|
+
import type { ClientElicitationAuthorizerOptions } from "./authorizer.ts";
|
|
2
3
|
import { type OpenApiStdioConfig } from "./config.ts";
|
|
4
|
+
export type { StatefulActionAuthorizer, TrustedActionApprovalContext, } from "./authorizer.ts";
|
|
3
5
|
export { type OpenApiStdioConfig, parseOpenApiStdioConfig } from "./config.ts";
|
|
4
|
-
export {
|
|
6
|
+
export { type ArgumentConstraint, type CompiledExactPolicy, compileExactPolicy, type ExactPolicyTemplate, } from "./exact-policy.ts";
|
|
7
|
+
export { createOpenApiMcpServer, createStdioActionAuthorizer, type OpenApiMcpServerOptions, } from "./server.ts";
|
|
5
8
|
/** Owns process-local credentials/catalogs independently of disposable SDK probes. */
|
|
6
|
-
export declare function serveOpenApiStdio(input: OpenApiStdioConfig): Promise<StdioServerHandle>;
|
|
9
|
+
export declare function serveOpenApiStdio(input: OpenApiStdioConfig, approve?: ClientElicitationAuthorizerOptions["approve"]): Promise<StdioServerHandle>;
|
package/dist/stdio/index.js
CHANGED
|
@@ -10,9 +10,10 @@ import { compileExactPolicy } from "./exact-policy.js";
|
|
|
10
10
|
import { createStdioSearchRuntime } from "./search.js";
|
|
11
11
|
import { createOpenApiMcpServer, createStdioActionAuthorizer, } from "./server.js";
|
|
12
12
|
export { parseOpenApiStdioConfig } from "./config.js";
|
|
13
|
-
export {
|
|
13
|
+
export { compileExactPolicy, } from "./exact-policy.js";
|
|
14
|
+
export { createOpenApiMcpServer, createStdioActionAuthorizer, } from "./server.js";
|
|
14
15
|
/** Owns process-local credentials/catalogs independently of disposable SDK probes. */
|
|
15
|
-
export async function serveOpenApiStdio(input) {
|
|
16
|
+
export async function serveOpenApiStdio(input, approve) {
|
|
16
17
|
const config = parseOpenApiStdioConfig(input);
|
|
17
18
|
const stores = [];
|
|
18
19
|
const providers = [];
|
|
@@ -57,7 +58,7 @@ export async function serveOpenApiStdio(input) {
|
|
|
57
58
|
// GenerationStore provides per-catalog CAS, not a cross-catalog transaction.
|
|
58
59
|
for (const entry of admitted)
|
|
59
60
|
await admitExecutableRelease({ store: entry.store, trust: config.trust, generations, limits }, entry.verified);
|
|
60
|
-
const authorizer = createStdioActionAuthorizer(templates);
|
|
61
|
+
const authorizer = createStdioActionAuthorizer(templates, approve);
|
|
61
62
|
const routes = [];
|
|
62
63
|
for (const profile of config.profiles) {
|
|
63
64
|
const catalogs = admitted.filter((entry) => entry.config.profileId === profile.profileId);
|
package/dist/stdio/server.d.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
2
2
|
import type { CredentialProvider, OpenApiRuntime } from "../runtime/types.ts";
|
|
3
3
|
import { type RuntimeLimits } from "../runtime/versions.ts";
|
|
4
4
|
import type { createLocalDispatchBoundary } from "../sqlite/guarded-fetch.ts";
|
|
5
|
-
import { type StatefulActionAuthorizer } from "./authorizer.ts";
|
|
5
|
+
import { type ClientElicitationAuthorizerOptions, type StatefulActionAuthorizer } from "./authorizer.ts";
|
|
6
6
|
import type { CompiledExactPolicy } from "./exact-policy.ts";
|
|
7
7
|
type Boundary = ReturnType<typeof createLocalDispatchBoundary>;
|
|
8
8
|
/** One authorizer across factory instances preserves opaque continuation ownership. */
|
|
9
|
-
export declare function createStdioActionAuthorizer(templates?: readonly CompiledExactPolicy[]): StatefulActionAuthorizer;
|
|
9
|
+
export declare function createStdioActionAuthorizer(templates?: readonly CompiledExactPolicy[], approve?: ClientElicitationAuthorizerOptions["approve"]): StatefulActionAuthorizer;
|
|
10
10
|
export interface OpenApiServerRoute {
|
|
11
11
|
readonly catalogId: string;
|
|
12
12
|
readonly releaseId: string;
|
package/dist/stdio/server.js
CHANGED
|
@@ -8,11 +8,18 @@ import { resolveRuntimeLimits, } from "../runtime/versions.js";
|
|
|
8
8
|
import { createClientElicitationActionAuthorizer, createExactPolicyActionAuthorizer, } from "./authorizer.js";
|
|
9
9
|
const requestCapabilities = new AsyncLocalStorage();
|
|
10
10
|
/** One authorizer across factory instances preserves opaque continuation ownership. */
|
|
11
|
-
export function createStdioActionAuthorizer(templates = []) {
|
|
11
|
+
export function createStdioActionAuthorizer(templates = [], approve) {
|
|
12
12
|
const elicitationAvailable = () => requestCapabilities.getStore()?.form === true;
|
|
13
13
|
return templates.length
|
|
14
|
-
? createExactPolicyActionAuthorizer({
|
|
15
|
-
|
|
14
|
+
? createExactPolicyActionAuthorizer({
|
|
15
|
+
templates,
|
|
16
|
+
elicitationAvailable,
|
|
17
|
+
approve,
|
|
18
|
+
})
|
|
19
|
+
: createClientElicitationActionAuthorizer({
|
|
20
|
+
elicitationAvailable,
|
|
21
|
+
approve,
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
const argumentsSchema = z.strictObject({
|
|
18
25
|
path: z.record(z.string(), z.json()).optional(),
|
|
@@ -138,12 +145,45 @@ function outcome(value, snapshot) {
|
|
|
138
145
|
]
|
|
139
146
|
.filter(Boolean)
|
|
140
147
|
.sort((left, right) => right.length - left.length);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const redact = (text) =>
|
|
148
|
+
// Literal redaction without a dynamic RegExp: find the earliest occurrence
|
|
149
|
+
// across variants (ties broken longest-first, matching the previous
|
|
150
|
+
// longest-first alternation), redact it, and resume after the match.
|
|
151
|
+
// indexOf skips non-matching regions in native code, so adversarial
|
|
152
|
+
// long-prefix inputs stay linear instead of quadratic per offset.
|
|
153
|
+
const redact = (text) => {
|
|
154
|
+
if (variants.length === 0 || text.length === 0)
|
|
155
|
+
return text;
|
|
156
|
+
const parts = [];
|
|
157
|
+
let cursor = 0;
|
|
158
|
+
let redacted = false;
|
|
159
|
+
for (;;) {
|
|
160
|
+
let at = -1;
|
|
161
|
+
let match = "";
|
|
162
|
+
for (const variant of variants) {
|
|
163
|
+
const found = text.indexOf(variant, cursor);
|
|
164
|
+
if (found === -1)
|
|
165
|
+
continue;
|
|
166
|
+
if (at === -1 ||
|
|
167
|
+
found < at ||
|
|
168
|
+
(found === at && variant.length > match.length)) {
|
|
169
|
+
at = found;
|
|
170
|
+
match = variant;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (at === -1)
|
|
174
|
+
break;
|
|
175
|
+
if (at > cursor)
|
|
176
|
+
parts.push(text.slice(cursor, at));
|
|
177
|
+
parts.push("[REDACTED]");
|
|
178
|
+
cursor = at + match.length;
|
|
179
|
+
redacted = true;
|
|
180
|
+
}
|
|
181
|
+
if (!redacted)
|
|
182
|
+
return text;
|
|
183
|
+
if (cursor < text.length)
|
|
184
|
+
parts.push(text.slice(cursor));
|
|
185
|
+
return parts.join("");
|
|
186
|
+
};
|
|
147
187
|
// Only upstream values are untrusted. Keep protocol fields and opaque tokens intact.
|
|
148
188
|
if (value.kind === "success") {
|
|
149
189
|
try {
|
|
@@ -231,7 +271,7 @@ async function executeAction(route, authorizer, input, ctx) {
|
|
|
231
271
|
kind: "resume",
|
|
232
272
|
requestState: state,
|
|
233
273
|
inputResponses: ctx.mcpReq.inputResponses,
|
|
234
|
-
});
|
|
274
|
+
}, ctx.mcpReq.signal);
|
|
235
275
|
if (decision.status === "denied")
|
|
236
276
|
throw new OpenApiMcpError("ACTION_DENIED");
|
|
237
277
|
if (decision.status === "input-required")
|
|
@@ -302,7 +342,7 @@ export function createOpenApiMcpServer(options) {
|
|
|
302
342
|
return outcome(await route.boundary.transport.dispatchRead(plan), snapshot);
|
|
303
343
|
}));
|
|
304
344
|
server.registerTool("action", {
|
|
305
|
-
description: "Request one verified action with
|
|
345
|
+
description: "Request one verified action with independent operator approval or an explicitly activated exact policy. Actions are denied when a trusted approval provider is unavailable.",
|
|
306
346
|
inputSchema: actionSchema,
|
|
307
347
|
}, (input, ctx) => handle(ctx, () => executeAction(routeFor(input.operation), options.authorizer, input, ctx)));
|
|
308
348
|
return server;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knitli/openapi-mcp",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.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",
|
|
@@ -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": {
|