@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 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 and form elicitation for confirmation.
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
- Actions require server-controlled authorization bound to the prepared request,
238
- credential profile, release, and argument digest. Review the elicited destination,
239
- operation, effect, and arguments, then explicitly confirm. Rejection, cancellation,
240
- expiry, a changed request, or a changed credential binding prevents dispatch.
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 elicitation-capable client or an eligible reviewed exact
373
- policy. Do not retry a changed action with old approval state.
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
 
@@ -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
  }
@@ -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 &&
@@ -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
- 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,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
- export interface ExactPolicyAuthorizerOptions extends AuthorizerTestOptions {
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>;
@@ -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 && exactPayloadMatch(payload, pending);
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 || !exactPayloadMatch(payload, pending)) {
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 (now >= pending.expiresAt ||
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.#receipts.size >= this.#limits.receipts ||
332
+ if (this.#approvalsInFlight >= this.#limits.pending ||
333
+ this.#receipts.size >= this.#limits.receipts ||
316
334
  (newActivation && this.#activations.size >= this.#limits.activations)) {
317
- // Capacity denial is retriable and must not spend a valid human answer.
335
+ // Before independent approval, capacity denial leaves the pending request retriable.
318
336
  return deny("Authorization capacity is exhausted");
319
337
  }
320
- let decision;
338
+ // Reserve the pending slot while the independent channel is awaiting approval.
339
+ this.#approving.add(pending);
321
340
  try {
322
- decision = this.#mintReceipt(call, credential, policy ? "exact-policy" : "per-call", policy?.policyDigest, Math.min(now + RECEIPT_TTL_MS, activationExpiresAt ?? Number.MAX_SAFE_INTEGER));
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
- catch {
325
- return deny("Authorization capacity is exhausted");
368
+ finally {
369
+ if (this.#pending.get(pending.pendingId) === pending) {
370
+ this.#pending.delete(pending.pendingId);
371
+ }
326
372
  }
327
- if (policy !== undefined &&
328
- activationKey !== undefined &&
329
- activationExpiresAt !== undefined) {
330
- this.#activations.set(activationKey, Object.freeze({
331
- policyDigest: policy.policyDigest,
332
- credentialBindingDigest: credential.bindingDigest,
333
- expiresAt: activationExpiresAt,
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 === token) {
512
+ if (randomIdsEqual(existing.randomId, token)) {
420
513
  collision = true;
421
514
  break;
422
515
  }
@@ -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 { createOpenApiMcpServer, type OpenApiMcpServerOptions, } from "./server.ts";
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>;
@@ -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 { createOpenApiMcpServer, } from "./server.js";
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);
@@ -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;
@@ -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({ templates, elicitationAvailable })
15
- : createClientElicitationActionAuthorizer({ elicitationAvailable });
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
- 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;
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 client approval or an explicitly activated exact policy.",
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": "1.4.0",
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": "^2.9.0",
76
+ "yaml": "2.9.0",
77
77
  "zod": "4.5.4"
78
78
  },
79
79
  "devDependencies": {