Skip to content

feat(adapters,plugins): actions a server names by method, tag or id ask the user before they run - #1099

Merged
frontegg-david merged 6 commits into
test/skilled-saas-e2efrom
feat/skilled-confirm-destructive
Nov 4, 2026
Merged

frontegg-david merged 6 commits into
test/skilled-saas-e2efrom
feat/skilled-confirm-destructive

Conversation

@frontegg-david

Copy link
Copy Markdown
Contributor

What and why

Say a user asks "clean up the test invoices", and the model writes a script that calls deleteInvoice twelve times. Until now all twelve requests went out, and nobody asked the user. This PR adds a confirm option to the Skilled OpenAPI plugin. Actions it names ask the user, through an MCP elicitation, before their request is sent.

It is opt-in: a server that sets nothing behaves as before, so developers can keep running destructive actions without being asked.

This PR stacks on #1098 (saas e2e), which stacks on #1097 (execute_action). It merges #1098 in. #1098 changed run_workflow to resolve actions from a registry snapshot taken when the workflow starts. The confirmation uses that same snapshot, so the question and the script see the same bundle.

Which actions ask

SkilledOpenApiPlugin.init({
  source: { type: 'static', path: './bundle.json' },
  confirm: { methods: ['DELETE'], tags: ['destructive'], operations: ['refundInvoice'], whenUnsupported: 'refuse' },
});
  • An action asks when any rule names it:
    • its HTTP method;
    • a tag of its operation;
    • a tag of any skill that lists it;
    • its operationId.
  • The option is a strict Zod object: a typo such as method is an error, not a rule that silently matches nothing.
  • Operation tags are new in the bundle:
    • compileSkilledBundleFromOpenApi() keeps each operation's OpenAPI tags and its x-frontmcp.tags, each once.
    • The strict bundle schema accepts operations.*.tags: up to 64 tags of up to 256 characters each.
    • A bundle without tags compiles and verifies as before, and the field is covered by the signature like every other field.
    • A change to an operation's tags counts as a changed operation in a bundle diff.

When it asks, and what

  • One action (execute_action, or an operation tool that server code calls) asks inside the shared executor:
    • after the call is authorized and its input validated, and before the request;
    • so a refused or invalid call never asks, and every caller goes through the same check.
    • The question names the action, its summary, method, path and input, for example Run voidInvoice: Void an invoice (DELETE /invoices/{id}) with {"id":"inv_1"}?.
    • The input is cut at 1,000 characters.
    • The form is one { confirmed: boolean } field, the pattern the elicitation docs use.
  • A workflow asks once, before its script starts, so a script that voids twelve invoices asks once.
    • The question lists the matching actions the script names as string literals that the caller may run (judged as load_skill judges them).
    • A yes covers every call of those actions in that run.
    • A matching action the question didn't name, such as one whose id the script builds at runtime, is refused at the call. The error tells the model to write the id as a string, or to use execute_action.
    • Why before the script and not at its first matching call: the sandbox's 8-second limit is wall-clock and includes time spent inside callTool, so a user taking longer than that to answer would kill the workflow.
  • A decline (decline, cancel, or confirmed: false) sends nothing:
    • the call fails with isError: true and the user declined: voidInvoice;
    • a declined workflow runs none of its script, reads included, and the error names every action it asked about;
    • a user who doesn't answer before the elicitation TTL gets the user did not answer in time: voidInvoice.
  • A client that can't show the question (it declared no elicitation capability, or the server has elicitation disabled):
    • with whenUnsupported: 'refuse', the default, the action is refused: the user's confirmation is needed for voidInvoice, and this client can't ask for it: it doesn't support elicitation; nothing was sent;
    • when the server has elicitation disabled, the message says this server can't ask for it: elicitation is disabled instead;
    • with 'allow', the action runs without asking.
    • The plugin catches the SDK's ElicitationFallbackRequired, ElicitationNotSupportedError, MissingClientCapabilityError and ElicitationDisabledError itself. It does not use the SDK's LLM-mediated elicitation fallback: with that fallback the model answers the question, so it would be confirming its own call.
    • A 2026-07-28 client's input request (InputRequiredSignal) passes through untouched, so that round trip still works.
  • Audit log:
    • a declined or refused call writes authority-check-pass, then http-call-failure with status 0 and the refusal as its errorMessage;
    • a confirmed call writes the records of any call;
    • a workflow declined before it starts ran no action, so it writes no records.

How it's built

  • The confirmation uses the SDK's this.elicit, so the elicitation:request and elicitation:result flows and their hooks run for it. It runs inside the tool call, after the plugin's own authorization and validation. A tools:call-tool hook would run before those, so the confirmation is a step of the shared executor instead.
  • ToolContext.elicit is protected, and operation tools were FUNCTION-kind tools whose executor got the context from outside. They are now one generated ToolContext subclass per operation (CLASS_TOKEN records), so each asks through its own this.elicit. Their names, metadata, visibility, hooks and answers are unchanged.
  • The confirmer is a required dependency of the shared executor, and the executor decides whether an action needs confirmation. A caller that forgets to supply one fails to compile; a gap can't silently skip the question.

The generated CLI. It can't show the question:

  • in-process, it connects without an onElicitation handler;
  • its daemon client initializes with capabilities: {}.

So execute-action and run-workflow exit 1 for such an action, with the refusal above, and send nothing. A CLI e2e test checks both paths. A terminal prompt and a --yes flag are left for the PR that reworks the CLI commands. The daemon's JSON transport would need server-to-client requests for that, which is not a small change. Until then, a server that wants CLI users to run such actions sets whenUnsupported: 'allow'. That setting applies to every client without elicitation, not only the CLI.

Model-facing text. The execute_action description now says an action may ask the user first, and lists the user declined: <actionId> among its failures (933 characters, within the 1024 budget). The run_workflow description is unchanged: it was already exactly 1024 characters. A workflow's refusals tell the model what to do.

User-facing change

  • What changed:
    • New plugin option confirm: { methods?, tags?, operations?, whenUnsupported?: 'refuse' | 'allow' }. The actions it names ask the user, through an MCP elicitation, before their request. It needs elicitation: { enabled: true } on the server.
    • New error texts: the user declined: <actionId>, the user did not answer in time: <actionId>, the user's confirmation is needed for <actionId>, and this client can't ask for it: …; nothing was sent, and the run_workflow refusal for an action its question didn't name.
    • compileSkilledBundleFromOpenApi() emits operations.*.tags, and the bundle schema accepts them.
    • Operation tools are class-based tools; this.callTool('<bundleId>.<operationId>') behaves as before.
    • New exported type ConfirmOptions.
  • Before → after:
    • Before: every action ran without asking anyone.
    • After, with no confirm option: unchanged, nothing asks.
    • After, with confirm: matching actions ask first. A decline sends nothing and fails the call. A client without elicitation, the generated CLI included, is refused unless whenUnsupported: 'allow'.
  • frontmcp.dev pages affected (if known):
    • the Skilled OpenAPI plugin reference: options, the four tools, the bundle, and the audit log;
    • the elicitation configuration page;
    • the CLI build page;
    • the Cloudflare skills-only page (managed mode doesn't set confirm).
  • Skills catalog entries updated:
    • frontmcp-config/references/configure-elicitation.md: new "Confirming Skilled OpenAPI Actions" section;
    • frontmcp-extensibility/references/skill-audit-log.md: how a declined call is recorded;
    • frontmcp-deployment/references/build-for-cli.md: the CLI refuses such an action;
    • frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md: managed mode has no confirmation.

Tests

Added:

  • @frontmcp/adapters, 3 tests:
    • the compiler keeps OpenAPI and x-frontmcp tags, each once, and an operation without tags has none;
    • the strict schema accepts operation tags and rejects one over 256 characters;
    • a bundle diff reports an operation whose tags change.
  • Plugin unit, action-confirmation.spec.ts (23 tests):
    • matching by method, id, operation tag, a tag of any listing skill, and no option;
    • the question's text and the input cut;
    • accept, decline, cancel and confirmed: false, and a timeout;
    • the fallback, not-supported, missing-capability and disabled cases, with refuse and with allow;
    • an InputRequiredSignal is rethrown;
    • a workflow asks once, about named, matching and runnable actions only, then lets their calls run and refuses an unnamed one;
    • no question without the option or without a named match.
  • Plugin unit, execute-skill-action.spec.ts (+5 tests):
    • the confirmer is called with the authorized listing and the validated input, before the request;
    • a refusal sends nothing and is audited as http-call-failure;
    • no question for a denied caller, for invalid input, for an unmatched action, or without the option.
  • Plugin integration, confirm-actions.integration.spec.ts (13 tests), with a real server and connect(), with and without onElicitation:
    • execute_action asks and sends on accept, and sends nothing on decline;
    • an operation-tag rule and a skill-tag rule each ask, and a read doesn't;
    • a workflow that voids twelve invoices asks once and sends twelve DELETEs;
    • a declined workflow sends nothing, its read included;
    • a computed action id is refused;
    • server code calling the operation tool asks;
    • execute_action without operation tools asks;
    • a declined call is audited;
    • a client without elicitation is refused, and with allow the action runs;
    • a server with elicitation disabled says so;
    • a server without confirm never asks.
  • operation-tool.factory.spec.ts: the executor tests now build the class-based tool, as the call-tool flow does.
  • E2E demo-e2e-skilled-openapi, production settings over streamable HTTP, new server entrypoint with confirm: { methods: ['DELETE'] } and elicitation enabled:
    • confirm-destructive.e2e.spec.ts (6 tests): the DELETE asks and is sent on accept, nothing is sent on decline, a three-invoice workflow asks once before any request, a declined workflow sends nothing, a client without the elicitation capability is refused, and an unmatched action doesn't ask;
    • execution.e2e.spec.ts (+1 test): the server without confirm voids an invoice by execute_action and in a workflow without asking.
  • E2E demo-e2e-cli-exec, cli-skilled-openapi.e2e.spec.ts (+2 tests): the generated CLI's execute-action for a DELETE that needs confirmation exits 1 with the refusal and sends nothing, in-process and through the daemon. The shared fixture gains a voidInvoice action and the confirm rule.

Run:

  • npx nx run-many -t build -p sdk,adapters,plugin-skilled-openapi,testing,cli: passed.
  • npx nx run-many -t typecheck -p adapters,plugin-skilled-openapi,demo-e2e-skilled-openapi,demo-e2e-cli-exec: passed.
  • libs/adapters jest: 61 suites, 1070 tests passed.
  • plugins/plugin-skilled-openapi jest: 46 suites, 679 tests passed.
  • libs/skills jest: 3 suites, 249 tests passed.
  • demo-e2e-skilled-openapi e2e: 8 suites, 80 tests passed, the saas specs from test(e2e): the saas bundle source on Node, and a workflow finishes on the operations it started on #1098 included.
  • demo-e2e-cli-exec: cli-skilled-openapi.e2e.spec.ts 16 tests passed. cli-login-authorities.e2e.spec.ts (4 tests) uses the changed fixture, but it is skipped on macOS by design (it would write to the Keychain), so it runs only in CI.
  • prettier --check and eslint on the changed files, and scripts/fix-unused-imports.mjs: clean.
  • No change to libs/sdk, so the browser bundle-size e2e doesn't apply.

…k the user before their request

A model cleaning up test invoices can write a script that calls deleteInvoice
twelve times, and every request went out with nobody asked. The new confirm
option names the actions that need the user's yes: by HTTP method, by a tag of
the operation or of a skill that lists it, or by operationId. Such an action
asks through an MCP elicitation after it is authorized and its input checked,
and before its request; a decline sends nothing and fails the call with
"the user declined: <actionId>", audited as a failed call.

A workflow asks once, before its script starts, about the actions it names:
waiting for the user inside the script would count against its 8 second limit.
A client that can't show the question is refused, or with
confirm.whenUnsupported: 'allow' runs the action unasked. Without the option
nothing asks, as before.

Compiled bundles now keep each operation's OpenAPI and x-frontmcp tags, and the
strict bundle schema accepts them. Operation tools are class-based tools, so
each can ask the user through its own elicit.
…/skilled-confirm-destructive

# Conflicts:
#	plugins/plugin-skilled-openapi/src/tools/run-workflow.tool.ts
…ends nothing, and the CLI refuses it

Integration tests run the confirm option through a real server and an
in-process client with and without elicitation: execute_action, operation
tools and run_workflow ask, a workflow that voids twelve invoices asks once, a
decline sends nothing and is audited as a failed call, and a server with no
confirm option never asks.

The production-settings e2e app gets a server whose DELETE actions ask, tested
over streamable HTTP with a client that answers the question and one that
can't. The CLI e2e fixture gets a DELETE action that asks: the generated CLI,
which can't show the question, exits 1 for it in-process and through its
daemon.

The plugin README and the skills catalog describe the option.
The option and the confirmation helpers had multi-line doc comments; the README describes them in full.
@frontegg-david
frontegg-david marked this pull request as ready for review November 4, 2026 20:39
@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Next included review available in 9 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between ed5a07b and 9f24dff.

Files selected for processing: 35
  • apps/e2e/demo-e2e-cli-exec/e2e/cli-skilled-openapi.e2e.spec.ts
  • apps/e2e/demo-e2e-cli-exec/e2e/helpers/skilled-cli-project.ts
  • apps/e2e/demo-e2e-skilled-openapi/e2e/confirm-destructive.e2e.spec.ts
  • apps/e2e/demo-e2e-skilled-openapi/e2e/execution.e2e.spec.ts
  • apps/e2e/demo-e2e-skilled-openapi/src/config.ts
  • apps/e2e/demo-e2e-skilled-openapi/src/confirm-destructive.main.ts
  • libs/adapters/src/skills/__tests__/bundle-diff.spec.ts
  • libs/adapters/src/skills/__tests__/bundle.schema.spec.ts
  • libs/adapters/src/skills/__tests__/compile-skilled-bundle.spec.ts
  • libs/adapters/src/skills/bundle/bundle-diff.ts
  • libs/adapters/src/skills/bundle/bundle.schema.ts
  • libs/adapters/src/skills/bundle/bundle.types.ts
  • libs/adapters/src/skills/openapi/compile-skilled-bundle.ts
  • libs/skills/catalog/frontmcp-config/references/configure-elicitation.md
  • libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md
  • libs/skills/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md
  • libs/skills/catalog/frontmcp-extensibility/references/skill-audit-log.md
  • plugins/plugin-skilled-openapi/README.md
  • plugins/plugin-skilled-openapi/src/__tests__/audit-and-startup.spec.ts
  • plugins/plugin-skilled-openapi/src/__tests__/confirm-actions.integration.spec.ts
  • plugins/plugin-skilled-openapi/src/__tests__/operation-tool.factory.spec.ts
  • plugins/plugin-skilled-openapi/src/executor/__tests__/action-confirmation.spec.ts
  • plugins/plugin-skilled-openapi/src/executor/__tests__/execute-skill-action.spec.ts
  • plugins/plugin-skilled-openapi/src/executor/action-confirmation.ts
  • plugins/plugin-skilled-openapi/src/executor/execute-skill-action.ts
  • plugins/plugin-skilled-openapi/src/executor/run-skill-action.ts
  • plugins/plugin-skilled-openapi/src/index.ts
  • plugins/plugin-skilled-openapi/src/registry/hidden-op.registry.ts
  • plugins/plugin-skilled-openapi/src/skilled-openapi.symbols.ts
  • plugins/plugin-skilled-openapi/src/skilled-openapi.types.ts
  • plugins/plugin-skilled-openapi/src/sync/bundle-sync.service.ts
  • plugins/plugin-skilled-openapi/src/tools/execute-action.schema.ts
  • plugins/plugin-skilled-openapi/src/tools/execute-action.tool.ts
  • plugins/plugin-skilled-openapi/src/tools/operation-tool.factory.ts
  • plugins/plugin-skilled-openapi/src/tools/run-workflow.tool.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

Project Tests Passed Warnings Failed Leaks
✅ demo-e2e-agents 4 4 0 0 0
✅ demo-e2e-cache 11 11 0 0 0
✅ demo-e2e-codecall 4 4 0 0 0
✅ demo-e2e-config 4 4 0 0 0
✅ demo-e2e-direct 3 3 0 0 0
✅ demo-e2e-elicitation 1 1 0 0 0
✅ demo-e2e-errors 4 4 0 0 0
✅ demo-e2e-hooks 3 3 0 0 0
✅ demo-e2e-multiapp 4 4 0 0 0
✅ demo-e2e-notifications 3 3 0 0 0
✅ demo-e2e-openapi 2 2 0 0 0
✅ demo-e2e-providers 4 4 0 0 0
✅ demo-e2e-public 4 4 0 0 0
✅ demo-e2e-redis 15 15 0 0 0
✅ demo-e2e-remember 4 4 0 0 0
✅ demo-e2e-remote 5 5 0 0 0
✅ demo-e2e-serverless 2 2 0 0 0
✅ demo-e2e-skills 15 15 0 0 0
✅ demo-e2e-standalone 2 2 0 0 0
✅ demo-e2e-transport-recreation 3 3 0 0 0
✅ demo-e2e-ui 4 4 0 0 0

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-04T22:09:38.817Z
Commit: fd6d34f4

@frontegg-david
frontegg-david merged commit a520402 into release/1.11.x Nov 4, 2026
46 checks passed
@frontegg-david
frontegg-david deleted the feat/skilled-confirm-destructive branch November 4, 2026 23:23
frontegg-david added a commit that referenced this pull request Nov 4, 2026
…, tag or id ask the user before they run (#1099)

Cherry-picked from #1099 (merged to release/1.11.x)
Original commit: a520402
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants