Query icon
If icon
Subflow icon
Sleep icon
OutputValues icon
McpToolTrigger icon

Governed MCP Action Request with Production Approval

Let an AI agent request destructive actions over MCP. Staging runs freely, production needs human approval, duplicate keys are claimed once in Postgres.

Categories
AIInfrastructure

Give an AI agent a destructive tool it can call over MCP, without giving it the power to approve itself. Staging runs immediately; production returns a request id at once, waits for a human in Kestra, and the same idempotency key can never trigger the purge twice.

This blueprint is three flows that work together: mcp-governed-action-request (this MCP tool), mcp-governed-action-executor (claims the request, asks a human when needed, runs the purge) and mcp-governed-action-status (MCP tool to follow up a request).

How it works

  1. claim_key inserts the request into public.governed_requests. The primary key makes this atomic: with INSERT ... ON CONFLICT (request_key) DO UPDATE ... WHERE status IN ('expired','rejected','failed'), concurrent calls with the same key claim it once.
  2. If the claim was won, the flow starts mcp-governed-action-executor: staging waits for it, production does not (the agent never waits on a human). For production, record_executor_id stores the executor execution id in the row.
  3. peek_request, settle_duplicate and read_request read the row. A duplicate that arrives a few milliseconds after the winner waits one second if the executor id is not stored yet.
  4. build_response returns the status. A key that exists with a different environment or days returns conflict and starts nothing. A key that was executed is never reused.

Security model

  • MCP annotations (destructive, readOnly, idempotent, openWorld) are hints for the agent. They enforce nothing.
  • The Pause only waits. The purge is blocked by the explicit decision == 'approve' check and by SQL guards: the row must be running for this executor execution.
  • Approval is only as strong as who can call the resume endpoint (POST /api/v1/{tenant}/executions/{id}/actions/resume). On Kestra OSS 2.0.4, a private MCP server uses Kestra basic auth, the same login that can call that endpoint. Keep the login in the MCP client configuration, out of the agent's reach: never give the agent a shell, an HTTP tool, or a file it can read that holds those credentials.
  • A public MCP server needs no credentials: anyone who can reach it can request purges. Do not expose this tool on one.
  • The executor webhook has no authentication beyond its key (the GOVERNED_EXECUTOR_WEBHOOK_KEY secret). It cannot bypass approval, but anyone with the key can take over a claimed request.
  • The approver name is self-asserted text, not an authenticated identity.

What you get

  • One MCP tool, governed_action_request, with a fixed set of status values and an approval_execution_id the agent can hand to a human.
  • An idempotency key claimed atomically in Postgres: concurrent calls with the same key start one request, and a key that was executed is never reused.
  • Staging that runs at once and production that returns at once and waits for a human, so the agent never blocks.
  • A purge and its executed status in one SQL statement, guarded so it only runs for an approved, claimed row.
  • The companion executor and status flows, and SQL for a least-privilege database role.

Who it's for

Teams that want to give AI agents write access to real systems, with a human in the loop for production. purge_and_record in the executor is the slot for your own destructive action: replace the demo DELETE with yours and keep its guards (the EXISTS ... status = 'running' check and the executed update in the same statement).

Why orchestrate this with Kestra

  • Durable approval state: the request lives in Postgres and the approval in a paused Kestra execution, so nothing depends on the agent's session staying open.
  • Audit trail: every request, approval and purge is an execution, with environment, decision and decided_by labels on the executor, and the decision stored in the row.
  • Retries: the Slack notification is retried, calling the status tool re-sends it if it still failed after 10 minutes, and a request that expired, was rejected or failed can be made again with the same key.
  • The agent never blocks: production returns at once with the id of the execution waiting for approval, and the agent follows up with governed_action_status.

Tested end to end

On Kestra 2.0.4 OSS with Postgres 16, a private MCP server with basic auth and a local Slack stub. The flows ran unmodified (only extend removed, because the flow API rejects it), as the governed_actions role created by the SQL below. That SQL was run twice on a fresh database: both runs succeed, and the second changes nothing.

  • Staging success; production approve and deny; repeat call with the same key; conflict (other days or other environment); reuse after expired, rejected and failed.
  • Slack down: the request goes to pending_unnotified, approval still works, and calling the status tool re-sends the link after 10 minutes (tested by back-dating the row 11 minutes): one message, none before.
  • Restart after a failed purge deletes nothing and fails with "Nothing was deleted". A stuck claimed row is recovered by a direct run or a Restart.
  • Resume with no fields, with a wrong field, and with deny never reach the DELETE; an invalid value is rejected with 422. A real Pause expiry ends in expired with nothing deleted (tested with a 20-second copy of the pause, not the 1-hour literal).
  • The private MCP server returns 401 with no or wrong credentials; the webhook returns 404 for a wrong key.
  • Concurrency, with a database audit of every status change and every DELETE statement:
    • 50 concurrent calls, same staging key: 1 winner, 49 duplicates, 1 executor, 1 DELETE.
    • 50 concurrent calls, same production key: 1 winner, 49 duplicates, 1 executor waiting, 1 Slack message, nothing deleted before approval. Five simultaneous approvals: one accepted, four answered 409, one DELETE.
    • 50 distinct keys (25 staging, 25 production): 25 staging DELETEs, 25 Slack messages, then 13 approved and 12 denied gave 13 production DELETEs.
    • 25 keys with 50 webhook calls racing the starting executor: every key executed exactly once. For 5 of the 10 production keys the webhook won the claim, so the id returned to the agent pointed to an executor that did nothing.
    • No key was ever executed twice, and no status ever left executed.
  • Not covered: the real 1-hour expiry, opening the Slack link in a browser, more than one MCP server or tenant.

Prerequisites

  • Postgres, with the database setup below.
  • Kestra with a private MCP server named default.
  • Secrets: PG_PASSWORD (password of the governed_actions role), SLACK_WEBHOOK_URL (Slack incoming webhook) and GOVERNED_EXECUTOR_WEBHOOK_KEY (a long random value). On Kestra OSS, secrets are SECRET_-prefixed, base64-encoded environment variables.
  • The placeholders in variables, replaced in all three flows.

Database setup

Run as a database owner. Replace the password. It is safe to run twice: the role is only created when missing and the demo rows are only inserted into empty tables. CREATE TABLE IF NOT EXISTS does not upgrade an older governed_requests table: drop or migrate it yourself.

DO $$
BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'governed_actions') THEN
    CREATE ROLE governed_actions LOGIN PASSWORD 'change-me';
  END IF;
END
$$;

-- Demo target tables: the purge deletes old rows from these.
CREATE SCHEMA IF NOT EXISTS staging;
CREATE SCHEMA IF NOT EXISTS production;
CREATE TABLE IF NOT EXISTS staging.demo_events (
  id bigserial PRIMARY KEY,
  payload text,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS production.demo_events (
  id bigserial PRIMARY KEY,
  payload text,
  created_at timestamptz NOT NULL DEFAULT now()
);

-- Request state. The primary key on request_key makes the claim atomic.
CREATE TABLE IF NOT EXISTS public.governed_requests (
  request_key           text PRIMARY KEY CHECK (request_key ~ '^[A-Za-z0-9_-]{1,64}$'),
  environment           text NOT NULL CHECK (environment IN ('staging', 'production')),
  days                  int  NOT NULL CHECK (days BETWEEN 1 AND 3650),
  status                text NOT NULL CHECK (status IN (
                          'claimed', 'pending', 'pending_unnotified', 'running',
                          'executed', 'rejected', 'expired', 'failed')),
  request_execution_id  text,
  executor_execution_id text,
  decided_by            text,
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now()
);

-- Least privilege: the flows can read and write request state and delete demo rows, nothing else.
GRANT USAGE ON SCHEMA staging, production TO governed_actions;
GRANT SELECT, DELETE ON staging.demo_events, production.demo_events TO governed_actions;
GRANT SELECT, INSERT, UPDATE ON public.governed_requests TO governed_actions;

-- Optional demo data: 5 old rows per environment, only into empty tables.
INSERT INTO staging.demo_events (payload, created_at) SELECT 'old', now() - interval '400 days' FROM generate_series(1, 5) WHERE NOT EXISTS (SELECT 1 FROM staging.demo_events);
INSERT INTO production.demo_events (payload, created_at) SELECT 'old', now() - interval '400 days' FROM generate_series(1, 5) WHERE NOT EXISTS (SELECT 1 FROM production.demo_events);

Quick start

  1. Run the SQL from Database setup in your Postgres database.
  2. Create the secrets listed in Prerequisites.
  3. Replace the placeholders in variables in all three flows: jdbc_url, jdbc_user, and kestra_url (executor and status). Set kestra_url to your own Kestra URL: kestra.url is unset in Kestra 2.0.4, so the Slack approval links would otherwise point to http://localhost:8080.
  4. Import mcp-governed-action-executor, then this flow, then mcp-governed-action-status, all into the SAME namespace: this flow starts the executor in its own namespace, and the status tool links to it.
  5. Connect your MCP client to the private default MCP server and call governed_action_request. Read the security model first.

How to extend

Ideas, not tested in this blueprint:

  • Replace the demo DELETE in purge_and_record (executor) with your own destructive action, and keep its guards.
  • Point the purge at your own tables by editing the fixed schemas map in the executor's variables and the table name in purge_and_record. The environment and days always come from the claimed row, never from the agent's input.
  • Replace the self-asserted approver with a checked identity, for example by approving through your own front end that checks who the user is before it calls the resume endpoint.
  • Add your own tools next to this one on the same private MCP server, with the same claim, approval and status pattern.

Inputs

  • environment (SELECT: staging, production, required).
  • days (INT, required, 1 to 3650): keep the rows from the last N days, older rows are deleted.
  • key (STRING, required, ^[A-Za-z0-9_-]{1,64}$): idempotency key.

Expected outputs

  • status: pending, pending_unnotified, running, executed, rejected, expired, failed, claimed or conflict. Production returns pending.
  • request_id: the key.
  • approval_execution_id: the executor execution. For production it is the execution waiting for approval, for staging the execution that ran. A duplicate returns the same id. It is empty on conflict, or when the executor has not started yet.
  • duplicate: true when the key was not claimed by this call.

Known limits

  • A database outage makes the call fail: the agent gets an MCP error with no body.
  • The Postgres tasks have no timeout, so if the database is unreachable, a call waits until the connection fails or returns.
  • If record_executor_id fails after the production executor started, the call returns an error although a request is pending. A retry with the same key is safe: it returns the existing request and its executor id.
  • Staging waits for the executor synchronously, so a slow purge can exceed the MCP client timeout while the purge continues. A retry with the same key is safe: it returns the current status.
  • If the executor cannot be started (for example the flow is missing), the call returns an error and the row stays claimed. See the executor flow for recovery, and for resetting a killed executor.

Links

This blueprint was created by daiwangk.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.