Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/e2e/demo-e2e-cloudflare/e2e/helpers/managed-bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,33 @@ const createInvoiceOperation: OperationDescriptor = {
authBindingRef: 'default',
};

const voidInvoiceOperation: OperationDescriptor = {
operationId: 'voidInvoice',
serviceId: 'billing',
httpMethod: 'DELETE',
pathTemplate: '/v1/invoices/{id}',
summary: 'Void an invoice',
public: true,
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
outputSchema: {
type: 'object',
properties: { id: { type: 'string' }, status: { type: 'string' } },
required: ['id', 'status'],
},
mapper: [{ inputKey: 'id', type: 'path', key: 'id', required: true }],
authBindingRef: 'default',
};

export type BillingSkillId = 'invoices' | 'reports' | 'refunds';

const billingSkills: Record<BillingSkillId, ResolvedBundle['skills'][number]> = {
invoices: {
id: 'invoices',
name: 'Invoices',
description: 'Create billing invoices for customers.',
instructions: '# Invoices\n\nCreate an invoice with createInvoice.',
description: 'Create and void billing invoices for customers.',
instructions: '# Invoices\n\nCreate an invoice with createInvoice, and void one with voidInvoice.',
tags: ['billing'],
operationIds: ['createInvoice'],
operationIds: ['createInvoice', 'voidInvoice'],
},
reports: {
id: 'reports',
Expand Down Expand Up @@ -139,6 +156,6 @@ export function billingBundle(version: string, skillIds: readonly BillingSkillId
services: [{ id: 'billing', baseUrl: BILLING_API_URL }],
authBindings: { default: { kind: 'bearer', vaultRef: 'billing-token' } },
skills: skillIds.map((skillId) => billingSkills[skillId]),
operations: { createInvoice: createInvoiceOperation },
operations: { createInvoice: createInvoiceOperation, voidInvoice: voidInvoiceOperation },
};
}
4 changes: 4 additions & 0 deletions apps/e2e/demo-e2e-cloudflare/e2e/helpers/managed-internet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class ManagedInternet {
const invoice = JSON.parse(body) as { customerId: string; amount: number };
return Response.json({ id: `inv_${invoice.customerId}`, status: 'open' }, { status: 201 });
}
if (method === 'DELETE' && url.startsWith(`${BILLING_API_URL}/v1/invoices/`)) {
if (authorization !== `Bearer ${this.options.billingToken}`) return new Response('unauthorized', { status: 401 });
return Response.json({ id: url.slice(`${BILLING_API_URL}/v1/invoices/`.length), status: 'void' });
}
return new Response(`no route for ${method} ${url}`, { status: 404 });
}
}
Expand Down
54 changes: 53 additions & 1 deletion apps/e2e/demo-e2e-cloudflare/e2e/helpers/managed-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,27 @@ const SECOND_REPLICA = 'replica-2';
const THIRD_REPLICA = 'replica-3';

interface JsonRpcResponse {
result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> };
result?: AskingToolResult;
error?: { code: number; message: string };
}

/** A protocol 2026-07-28 `tools/call` result: complete, or asking for input the client sends back on its retry. */
export interface AskingToolResult {
resultType?: 'complete' | 'input_required';
isError?: boolean;
content?: Array<{ type: string; text?: string }>;
inputRequests?: Record<string, { method: string; params: { message: string } }>;
requestState?: string;
}

/** What a retry of a 2026-07-28 call carries: the answers to the input requests, and the state to echo. */
export interface AskingToolRetry {
inputResponses: Record<string, unknown>;
requestState?: string;
}

const PROTOCOL_20260728 = '2026-07-28';

export interface ToolCallResult<T> {
isError: boolean;
text: string;
Expand Down Expand Up @@ -129,6 +146,41 @@ export class ManagedWorker {
return { isError: message.result.isError === true, text, json: () => JSON.parse(text) as T };
}

/** A protocol 2026-07-28 `tools/call` from a client that declares elicitation, so the server may ask it. */
async callToolAsking(
name: string,
args: Record<string, unknown>,
retry?: AskingToolRetry,
): Promise<AskingToolResult> {
const response = await this.fetch(MCP_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': PROTOCOL_20260728,
'mcp-method': 'tools/call',
'mcp-name': name,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: ++this.requestId,
method: 'tools/call',
params: {
name,
arguments: args,
...retry,
_meta: {
'io.modelcontextprotocol/protocolVersion': PROTOCOL_20260728,
'io.modelcontextprotocol/clientCapabilities': { elicitation: { form: {} } },
},
},
}),
});
const message = await readJsonRpc(response);
if (!message.result) throw new Error(`tools/call ${name} failed: ${JSON.stringify(message.error)}\n${this.log}`);
return message.result;
}

/** Run the Cron Trigger handler, as the platform does on schedule. */
async scheduled(): Promise<{ outcome: string }> {
const worker = await this.miniflare.getWorker(this.replicaName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface SearchSkillResponse {
}

interface LoadSkillResponse {
skill: { id: string; bundleVersion?: string; actions?: Array<{ actionId: string }> };
skill: { id: string; bundleVersion?: string; actions?: Array<{ actionId: string; asksForConfirmation?: true }> };
}

interface RunWorkflowResponse {
Expand Down Expand Up @@ -75,7 +75,7 @@ describe('a managed edge Worker on workerd', () => {
expect(skills.map((skill) => skill.skillId).sort()).toEqual(['invoices', 'reports']);
expect(skills.every((skill) => skill.bundleVersion === '1.0.0')).toBe(true);
expect(loaded.json().skill).toMatchObject({ id: 'invoices', bundleVersion: '1.0.0' });
expect(loaded.json().skill.actions?.map((action) => action.actionId)).toEqual(['createInvoice']);
expect(loaded.json().skill.actions?.map((action) => action.actionId)).toEqual(['createInvoice', 'voidInvoice']);
expect(internet.requestsTo(BUNDLE_URL)).toHaveLength(1);
expect(await worker.cachedBundleVersion()).toBe('1.0.0');
});
Expand All @@ -95,6 +95,53 @@ describe('a managed edge Worker on workerd', () => {
expect(result.text).not.toContain(billingToken);
});

describe("managed.confirm: { methods: ['DELETE'] }, on a Worker that can't enable elicitation", () => {
const VOID_INVOICE = { actionId: 'voidInvoice', input: { id: 'inv_edge' } };
const CANNOT_ASK =
"the user's confirmation is needed for voidInvoice, and this server can't ask for it: elicitation is disabled; nothing was sent";
const voidRequests = () => internet.requests.filter((request) => request.method === 'DELETE');

it('refuses the DELETE action in execute_action and run_workflow, sending nothing', async () => {
const action = await worker.callTool('execute_action', VOID_INVOICE);
const workflow = await worker.callTool<RunWorkflowResponse>('run_workflow', {
script: "return await callTool('voidInvoice', { id: 'inv_edge' });",
});

expect(action.isError).toBe(true);
expect(action.text).toBe(CANNOT_ASK);
expect(workflow.json()).toEqual({ success: false, error: CANNOT_ASK });
expect(voidRequests()).toEqual([]);
});

it('refuses it to a client that declares elicitation too, without asking', async () => {
const answer = await worker.callToolAsking('execute_action', VOID_INVOICE);

expect(answer.resultType).not.toBe('input_required');
expect(answer.isError).toBe(true);
expect(answer.content?.[0]?.text).toBe(CANNOT_ASK);
expect(voidRequests()).toEqual([]);
});

it('still runs an action the rule does not name', async () => {
const result = await worker.callTool<{ id: string }>('execute_action', {
actionId: 'createInvoice',
input: { customerId: 'cus_confirm', amount: 3 },
});

expect(result.isError).toBe(false);
expect(result.json()).toMatchObject({ id: 'inv_cus_confirm' });
});

it('marks the DELETE action in load_skill as one that asks for confirmation', async () => {
const loaded = await worker.callTool<LoadSkillResponse>('load_skill', { skillId: 'invoices' });

expect(loaded.json().skill.actions).toEqual([
expect.not.objectContaining({ asksForConfirmation: true }),
expect.objectContaining({ actionId: 'voidInvoice', asksForConfirmation: true }),
]);
});
});

it('picks up a new bundle version on a Cron run, applied and kept in KV by the time the run ends', async () => {
publish('2.0.0', ['invoices', 'refunds']);

Expand Down
1 change: 1 addition & 0 deletions apps/e2e/demo-e2e-cloudflare/fixture-managed/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const mcp = createEdgeMcp({
trustedKeys: [JSON.parse(bindings.BUNDLE_TRUSTED_KEY)],
credentials: { 'billing-token': bindings.BILLING_API_TOKEN },
enableWebhook: true,
confirm: { methods: ['DELETE'] },
cache: kvBundleCacheFromEnv('BUNDLE_CACHE'),
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { expect, test, type McpTestClient, type TestFixtures } from '@frontmcp/testing';

import { ProductionEnvironment } from './helpers/production-environment';
import { asAdmin, executeAction, runWorkflow } from './helpers/skilled-client';
import { asAdmin, executeAction, loadSkill, runWorkflow } from './helpers/skilled-client';

const environment = new ProductionEnvironment();

Expand Down Expand Up @@ -115,6 +115,19 @@ test.describe('a DELETE action on a server that confirms them', () => {
expect(requestsSent()).toEqual(['POST /v1/invoices']);
});

test('is marked in load_skill as an action that asks for confirmation; the others are not', async ({
server,
auth,
}) => {
const client = await asAdmin({ server, auth });

const skill = await loadSkill(client, 'billing-admin');

const asking = (skill?.actions ?? []).filter((action) => action.asksForConfirmation === true);
expect(asking.map((action) => action.actionId)).toEqual(['voidInvoice']);
expect(skill?.actions?.length).toBeGreaterThan(1);
});

test('does not ask for an action the option does not name', async ({ server, auth }) => {
const { client, asked } = await adminAnswering({ server, auth }, 'decline');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface LoadedSkill {
inputJsonSchema: Record<string, unknown>;
outputJsonSchema: Record<string, unknown>;
requiredAuthorities?: Record<string, unknown>;
asksForConfirmation?: true;
}>;
}

Expand Down
15 changes: 15 additions & 0 deletions libs/adapters/src/skills/audit/__tests__/audit-writer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,21 @@ describe('SkillAuditWriter', () => {
expect(result.ok).toBe(true);
});

it('records the confirmation a call passes, signed and chained with the rest of the record', async () => {
const { writer, store } = makeWriter();

await writer.writeAuthorityPass(ctx);
await writer.writeHttpCallSuccess({ ...ctx, confirmation: 'confirmed' }, { status: 200, output: { id: 'inv-1' } });
await writer.writeHttpCallFailure({ ...ctx, confirmation: 'declined' }, { status: 0, error: 'the user declined' });
const all = await store.read();

expect(all.map((record) => record.confirmation)).toEqual([undefined, 'confirmed', 'declined']);
expect('confirmation' in all[0]).toBe(false);
expect(verifyChain(all, TRUSTED, defaultAuditSignatureVerifier)).toEqual({ ok: true, verified: 3 });
const edited = all.map((record, index) => (index === 1 ? { ...record, confirmation: 'skipped' as const } : record));
expect(verifyChain(edited, TRUSTED, defaultAuditSignatureVerifier)).toMatchObject({ ok: false, breakAt: 2 });
});

it('does not produce a sequence gap when signing fails (M4 regression)', async () => {
// Backing adapter that exposes a real `decr` for sequence rollback.
const seqMap = new Map<string, number>();
Expand Down
17 changes: 17 additions & 0 deletions libs/adapters/src/skills/audit/audit-record.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export type SkillAuditPhase =
| 'http-call-success'
| 'http-call-failure';

/**
* Whether the user confirmed the call, as `@frontmcp/plugin-skilled-openapi`'s `confirm` option decided it.
*
* - `not_required`: no `confirm` rule names the action, so nobody was asked.
* - `confirmed`: the user said yes. A generated CLI's `--yes` says yes for them, so it is recorded the same.
* - `skipped`: the client can't ask, and `whenUnsupported: 'allow'` sent the request without asking.
* - `declined`: the user said no, or cancelled. Nothing was sent.
* - `unanswered`: no answer came, so nothing was sent: the client can't ask (`whenUnsupported: 'refuse'`), the
* question timed out, or a `run_workflow` script reached the action by an id it didn't name.
*/
export type SkillAuditConfirmation = 'not_required' | 'confirmed' | 'skipped' | 'declined' | 'unanswered';

/**
* Signature algorithm used to sign the audit record.
*
Expand Down Expand Up @@ -90,6 +102,11 @@ export interface SkillAuditRecord {
outputHash?: string;
/** Redacted, truncated error description for http-call-failure. */
errorMessage?: string;
/**
* Whether the user confirmed the call — see {@link SkillAuditConfirmation}. Set on the records written once that is
* decided, after the input check; left out of the records before it, and of records written up to 1.11.0.
*/
confirmation?: SkillAuditConfirmation;
/**
* sha256Hex of the canonicalized previous record (without its own signature).
* The genesis record uses `'0'.repeat(64)`. Tamper detection works because
Expand Down
10 changes: 9 additions & 1 deletion libs/adapters/src/skills/audit/audit-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import { bytesToHex, hmacSha256, randomUUID, sha256Hex } from '@frontmcp/utils';
import { canonicalize } from '../security/bundle-signature';
import { linkRecord } from './audit-chain';
import type { SkillAuditSubjectMode } from './audit-config';
import { SKILL_AUDIT_GENESIS_PREV_HASH, type SkillAuditPhase, type SkillAuditRecord } from './audit-record.types';
import {
SKILL_AUDIT_GENESIS_PREV_HASH,
type SkillAuditConfirmation,
type SkillAuditPhase,
type SkillAuditRecord,
} from './audit-record.types';
import type { SkillAuditSigner } from './audit-signer';
import type { SkillAuditStore } from './audit-store';

Expand Down Expand Up @@ -97,6 +102,8 @@ export interface SkillAuditWriteContext {
bundleVersion: string;
/** Raw input — hashed via JCS before storage. Never persisted verbatim. */
input: unknown;
/** Whether the user confirmed the call, once that is decided; recorded as it is. */
confirmation?: SkillAuditConfirmation;
}

/** Extra fields for the http-call-success path. */
Expand Down Expand Up @@ -365,6 +372,7 @@ export class SkillAuditWriter {
...(phase === 'http-call-failure' || phase === 'authority-check-fail'
? { errorMessage: this.formatError(error) }
: {}),
...(ctx.confirmation ? { confirmation: ctx.confirmation } : {}),
};

// Stamp prevHash from `prev` (or the genesis sentinel for the first write).
Expand Down
1 change: 1 addition & 0 deletions libs/adapters/src/skills/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export {
SKILL_AUDIT_GENESIS_PREV_HASH,
SKILL_AUDIT_KEYS,
type SkillAuditConfirmation,
type SkillAuditPhase,
type SkillAuditRecord,
type SkillAuditSignatureAlg,
Expand Down
1 change: 1 addition & 0 deletions libs/adapters/src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export {
type AuditTrustedKey,
type SkillAuditAuthorityFailExtras,
type SkillAuditConfig,
type SkillAuditConfirmation,
type SkillAuditFailureExtras,
type SkillAuditLogger,
type SkillAuditMetrics,
Expand Down
3 changes: 3 additions & 0 deletions libs/edge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ crons = ["*/5 * * * *"]
| `pushReplay` | Where pushed tokens' `jti`s are kept: `{ binding }` (default `FRONTMCP_PUSH_REPLAY`), as below |
| `allowRollback` | Apply a pulled or pushed bundle older than the one being served, as below. Default `false` |
| `requireSignature`, `trustedKeys`, `dev`, `credentials`, `outbound` | Passed to the Skilled OpenAPI plugin. `dev` skips signature checks: never use it in production |
| `confirm` | Actions that ask the user before their request, as the plugin's `confirm`; see below |
| `pollIntervalMs`, `bundleCacheDir` | Ignored on a Worker, which has no timers or file system |

A Cron run ends once the bundle it pulled is applied and kept in `cache`, also when it is the first event an isolate gets: it then starts the bundle sync, whose first pull is the run's pull. A bundle that fails its signature or its other checks is not applied and not kept, so a cold start that can't reach the bundle server serves the last bundle the Worker applied.
Expand All @@ -159,6 +160,8 @@ Up to 1.11.0, such a run reported success. A run whose pull applies a bundle, or

Each operation call checks the API's host first: on a Worker, `node:dns` resolves it over DNS over HTTPS, so the host must resolve publicly.

`confirm` names the actions that ask the user before their request, by HTTP method, tag or operationId, as the Skilled OpenAPI plugin's [`confirm` option](https://frontmcp.dev/reference/plugins/skilled-openapi) does: `confirm: { methods: ['DELETE'] }`. The plugin checks it, so an invalid value refuses to start (`500 CONFIG_INVALID`, and the log names the field). The question is an MCP elicitation, which the server asks only with `elicitation: { enabled: true }`. On a Cloudflare Worker that setting refuses to start (`Elicitation requires distributed storage when running on Edge runtime`), so there an action `confirm` names is refused, `the user's confirmation is needed for voidInvoice, and this server can't ask for it: elicitation is disabled; nothing was sent`, or with `whenUnsupported: 'allow'` runs without asking. `load_skill` marks it with `asksForConfirmation: true`, and the audit log records what the confirmation came to. Up to 1.11.0 managed mode had no `confirm`, so every action ran without asking.

### Pushed bundles

With `enableWebhook: true`, the bundle server can push a new bundle instead of waiting for the next Cron run:
Expand Down
22 changes: 22 additions & 0 deletions libs/edge/src/__tests__/managed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/
import 'reflect-metadata';

import { skilledOpenApiPluginOptionsSchema } from '@frontmcp/plugin-skilled-openapi';

import { buildManagedOpenApiPluginOptions, createEdgeMcp, type ManagedEdgeOptions } from '../index';

const MANAGED: ManagedEdgeOptions = {
Expand Down Expand Up @@ -51,9 +53,29 @@ describe('buildManagedOpenApiPluginOptions', () => {
expect(opts['credentials']).toEqual({ 'acme-token': 'xyz' });
});

it("passes confirm to the plugin as it is, for the plugin's schema to check", () => {
const confirm = { methods: ['DELETE' as const], tags: ['destructive'], whenUnsupported: 'allow' as const };

const opts = buildManagedOpenApiPluginOptions({ ...MANAGED, confirm });

expect(opts['confirm']).toEqual(confirm);
expect(skilledOpenApiPluginOptionsSchema.parse(opts).confirm).toEqual(confirm);
});

it("leaves whenUnsupported to the plugin's default, refuse", () => {
const opts = buildManagedOpenApiPluginOptions({ ...MANAGED, confirm: { operations: ['voidInvoice'] } });

expect(opts['confirm']).toEqual({ operations: ['voidInvoice'] });
expect(skilledOpenApiPluginOptionsSchema.parse(opts).confirm).toEqual({
operations: ['voidInvoice'],
whenUnsupported: 'refuse',
});
});

it('omits unset optional fields (no undefined leakage)', () => {
const opts = buildManagedOpenApiPluginOptions(MANAGED);
expect('requireSignature' in opts).toBe(false);
expect('confirm' in opts).toBe(false);
expect('dev' in opts).toBe(false);
expect('pollIntervalMs' in (opts['source'] as object)).toBe(false);
expect('allowRollback' in (opts['source'] as object)).toBe(false);
Expand Down
Loading
Loading