PhoenixKit.API.Executor (phoenix_kit v2.63.0)

Copy Markdown View Source

The request flow of the shop API: what happens between "this key is authentic" and "here is the response".

PhoenixKit.API.Auth has already done steps 1 to 3 of the flow (API switch, refusal of a credential in the URL, authentication). execute/4 and visible/2 do the rest and return their outcome; call/4 and list/2 are thin senders over them for the REST transport:

  1. Tool visible. The tool exists for this audience (404 not_found), its module is enabled (404 module_disabled), and the key's effective scopes include the tool's scope (403 forbidden). A tool listed in api_disabled_tools does not exist for PhoenixKit.API.Registry, so it is not_found too.
  2. Rate limit, cost weighted (PhoenixKit.API.RateLimit, cost/2): a call costs 1 unit; only a write tool of the admin audience (not read-only) costs 1 per 50 rows of its longest top-level list, counted up to the tool's max_items (or, for a tool without one, up to 1000 rows) at most, so a request that is refused for size cannot use up the key's budget. Reads and public tools cost 1 however long their lists are. A tool that needs another price (1 per query of a search batch) declares it with Tool.cost. The public audience pays 1 unit BEFORE the tool lookup (a miss in Registry.find/2 costs about 2 ms, so a flood of unknown names must end in 429 without it) and a tool with a dearer price pays the difference after the lookup; the admin audience is charged once, after the scope check, because its price depends on the tool. A call the lookup or the scope check refuses (unknown, hidden, forbidden or disabled-module tool) never reaches that charge, so it pays one unit instead: a key cannot loop on invented names for free. The answer is the same refusal, or 429 when the key's budget is spent; the refusal writes no audit entry.
  3. Validate the input against max_items and against the tool's input_schema closed by PhoenixKit.API.Validator.close_schema/2 (so a schema that forgot additionalProperties: false still refuses unknown fields, the control fields included). list/2 shows the closed schema (input_schema/1), the one execute/4 validates against. Before that, over REST the Idempotency-Key header of a write call is merged into the input (Flow.merge_header/1), so it is validated like the field; MCP takes the key from the argument only.
  4. Run the handler, apply(module, function, [input, ctx]).
  5. Log. A call to a write tool (annotations.read_only is not true) from a key writes one api.request Activity entry: tool, key prefix, row count, outcome. Calls refused before step 6 (unknown tool, missing scope, rate limited) write nothing, so a refused request costs no write; so does a replay of a stored answer. The entry's outcome is ok, error, preview (a dry run), confirmation_required (the call asked for a code) or pending_approval (queued for an approver). The module logs its own domain changes.
  6. Shape the response: 200 with the handler's map as JSON, or a problem document (PhoenixKit.API.Problem).

Steps 7 and 8 are PhoenixKit.API.Flow:

  1. Idempotency (Flow.idempotency/1): a write call with an Idempotency-Key claims it; a finished twin is answered from the store, a running twin is 409.
  2. Danger (Flow.gate/1): dry_run answers the preview; a dangerous operation answers a preview and a confirmation code, or is queued for approval (a repeat of an operation that already waits gets the same approval back, marked with the replay header but not stored, and still audited); with a valid code it runs.

Each step is one function taking and returning the request state, chained in execute/4. A step may also end the request with {:done, state}: the body is already set (a preview, a replay) and the handler does not run. The executor adds the control fields dry_run, confirm_token and idempotency_key to the schema of every write tool (PhoenixKit.API.ControlFields) and closes every schema; a read tool has none of them, so a client that sends one gets 422 validation_failed: a field that would change what a write does is refused, never silently ignored.

Scopes and work per request

PhoenixKit.API.Keys.verify/2 (called by PhoenixKit.API.Auth) has already computed the effective scopes of this request into actor.scopes (the key's scopes filtered by the owner's permissions right now, never cached). The executor trusts those scopes and does not compute them again: it would only repeat the owner lookup and the scope filtering. What a request still scans twice is the module registry (ModuleRegistry.all_api_tools/0: once inside verify/2 to know the scopes that exist, once in Registry.find/2); that is two walks over a handful of modules, and sharing the result would need an API change in Registry, so 1a accepts it.

The handler

A tool's handler returns {:ok, map}, {:ok, map, effects} (the effects list is added to the response under "effects") or {:error, code, message, context} (code is an atom from PhoenixKit.API.Problem.statuses/0). Anything else, and any exception, exit or throw, answers 500 internal_error with a generic message; the detail goes to the log, never to the client.

The context is a map:

  • :actor the PhoenixKit.API.Actor; :scopes its effective scopes;
  • :audience, :language, :currency, :hosts, :base_url (see PhoenixKit.API.RequestContext);
  • :request_id;
  • :dry_run?, true while the danger gate asks for a preview (the handler must then write nothing and answer {:preview, preview, fingerprint}, where fingerprint is a hash of the current values the operation would touch: 1 to 64 bytes of UTF-8, e.g. a SHA-256 in hex; any other answer is a 500);
  • :approved_by_uuid, the person who approved the operation (nil unless it runs from the approval queue), :confirmation_uuid, the confirmation code or approval row it runs under (nil for an ordinary call) and :progress, a one-argument function a long batch may call with its partial results. It also keeps an approved operation alive: one that runs longer than 15 minutes without calling it is marked failed (interrupted) by the stuck-operation sweep, so a long handler calls it at least every few minutes.

Summary

Types

Response headers to send with an outcome: the rate-limit headers.

What execute/4 returns: the handler's result (the decoded map and its JSON encoding) or the error in PhoenixKit.API.Problem terms, each with the headers of the rate limit.

Functions

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input (the decoded request body), and sends the response.

Rate-limit units one call of tool with input costs (the cost is clamped).

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input, and returns the outcome instead of sending it. This is what call/4 does, minus the HTTP response, and what the MCP transport uses.

The input schema the executor enforces for tool: the tool's own schema, closed at the root with additionalProperties: false, with string keys, and for a write tool with the control fields (dry_run, confirm_token, idempotency_key). MCP's inputSchema and OpenAPI's request body are built from it, so a client is told exactly what the validator will accept. Nested objects retain the module's policy; declare additionalProperties: false on each nested object that must reject unknown fields.

Sends the list of tools the actor may call: name, title, description, scope, schemas and annotations. For the admin audience the list is filtered by the key's effective scopes.

The tools the actor may call, as list/2 shows them, returned instead of sent. For the admin audience: the tools whose scope the key effectively holds, plus the scope-less ones (system.*); for the public audience: every public tool. Costs one rate-limit unit, like list/2.

Types

headers()

@type headers() :: [{String.t(), String.t()}]

Response headers to send with an outcome: the rate-limit headers.

outcome()

@type outcome() ::
  {:ok, %{result: map(), json: binary()}, headers()}
  | {:error, atom(), String.t(), map(), headers()}

What execute/4 returns: the handler's result (the decoded map and its JSON encoding) or the error in PhoenixKit.API.Problem terms, each with the headers of the rate limit.

Functions

call(conn, audience, tool_name, input)

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input (the decoded request body), and sends the response.

cost(tool, input)

@spec cost(PhoenixKit.API.Tool.t() | nil, term()) :: pos_integer()

Rate-limit units one call of tool with input costs (the cost is clamped).

1 for everything, except a tool of the admin audience that is not read-only: it costs max(1, ceil(rows / 50)), where rows is the length of the longest top-level list in the input, clamped:

  • a tool with max_items: rows counts up to max_items at most, so the price is at most ceil(max_items / 50). A list above max_items is refused with 422, and a refused request must not use up the key's budget, so it is charged as a full batch, not as its real size;
  • a tool without max_items: rows counts up to 1000 at most, so the price is at most 20 units, for the same reason (the 2 MB limit of PhoenixKit.API.BodyGuard bounds the work of such a request).

So an admin read with a long fields list costs 1, a public call with a 50-line cart costs 1 and an admin write of 120 rows costs 3. nil (the discovery list) and an input that is not a map cost 1.

A tool that declares cost: (PhoenixKit.API.Tool) is priced by it instead.

execute(conn, audience, tool_name, input, opts \\ [])

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input, and returns the outcome instead of sending it. This is what call/4 does, minus the HTTP response, and what the MCP transport uses.

Returns {:ok, %{result: map, json: binary}, headers} or {:error, code, detail, context, headers} (code is an atom of PhoenixKit.API.Problem.statuses/0; context is the map the problem document carries). headers are the rate-limit headers when the request got as far as the limiter, else []. The api.request audit entry of a write call is written here, before returning.

The option :resolve_name is a function from the name as the caller gave it to the name of the tool to look up. It runs after the public charge, in the lookup step, so a name that costs registry walks to resolve is not resolved for a caller the limiter has already refused.

The option :idempotency_header (default true) says whether the Idempotency-Key HTTP header is read. The REST transport reads it; MCP passes false, because an MCP client sends the key as the idempotency_key argument and a header on the transport request is not part of the call.

input_schema(tool)

@spec input_schema(PhoenixKit.API.Tool.t()) :: map()

The input schema the executor enforces for tool: the tool's own schema, closed at the root with additionalProperties: false, with string keys, and for a write tool with the control fields (dry_run, confirm_token, idempotency_key). MCP's inputSchema and OpenAPI's request body are built from it, so a client is told exactly what the validator will accept. Nested objects retain the module's policy; declare additionalProperties: false on each nested object that must reject unknown fields.

list(conn, audience)

Sends the list of tools the actor may call: name, title, description, scope, schemas and annotations. For the admin audience the list is filtered by the key's effective scopes.

visible(conn, audience)

@spec visible(Plug.Conn.t(), PhoenixKit.API.Tool.audience()) ::
  {:ok, [PhoenixKit.API.Tool.t()], headers()}
  | {:error, atom(), String.t(), map(), headers()}

The tools the actor may call, as list/2 shows them, returned instead of sent. For the admin audience: the tools whose scope the key effectively holds, plus the scope-less ones (system.*); for the public audience: every public tool. Costs one rate-limit unit, like list/2.