Skip to content

RPC actions: compiling functions

Kozou compiles the nouns of your schema — tables and views — into a faithful REST / OpenAPI / MCP / Admin-UI surface. Since v1.4 it can also compile verbs: a Postgres function, tagged in its COMMENT, becomes a callable action across the same four surfaces at once.

This matters because the security model Kozou recommends — JWT → SET LOCAL ROLE → row-level security plus column GRANTs — pushes the meaningful writes (approve an order, record a payment, publish) into functions, with table-level write access revoked. Those verbs would otherwise live outside every generated surface. RPC actions bring them back in, on the same authorization machinery: no new auth model, just the database enforcing who may run each function.

For where RPC sits among Kozou’s outputs, see The @kozou/api REST layer and COMMENT conventions.

Nothing is exposed by default. A function is compiled into an action only when its COMMENT carries the @expose: rpc tag:

COMMENT ON FUNCTION approve_order(order_id uuid) IS
'Approve an order and reserve stock.
@ai: not idempotent — confirm the order status before re-calling.
@policy: only managers may approve.
@expose: rpc';

That single tag compiles the function to all four surfaces:

  • RESTPOST /rpc/<schema>.<fn> with a JSON body of named arguments, e.g. POST /rpc/public.approve_order { "order_id": "…" }.
  • OpenAPI — a POST /rpc/<schema>.<fn> operation (operationId: rpc.<schema>.<fn>), with the @ai / @policy advisory carried as x-kozou-ai / x-kozou-policy.
  • MCP — the describe_functions tool lists each action’s signature and advisory, so an AI agent knows what actions exist and how to call them.
  • Admin UI — an “Actions” page renders an argument form (with the same widget inference as table forms — enum-select, relation-select, …) and shows the result.

Arguments are named (scalar / enum / FK-typed); an argument with a DEFAULT may be omitted. The return maps to the wire as:

Postgres returnWire
scalar (integer, text, uuid, …)the value
single composite / table rowan object
SETOF / RETURNS TABLE(...)an array — of objects for a row set, of bare values for a scalar set
void204 No Content (REST); { ok: true, note: … } over the MCP call tool

The @expose: rpc tag means “surface this verb” — it does not decide who may run it. That is enforced by the PostgreSQL EXECUTE privilege under the request’s role (the role from the JWT claim, via SET LOCAL ROLE). A caller without EXECUTE gets a 42501, which Kozou maps to 403 Forbidden with a generic body — the function still runs inside the role’s transaction, so its own checks and RLS apply.

Because of that, exposure has guardrails. A tagged function that fails one is not exposed, and the build reports it loudly (never a silent gap):

CREATE FUNCTION grants EXECUTE to PUBLIC by default. A tagged function that still has that grant would let anyone — including an anonymous role — call the endpoint, so it is skipped. Lock it down first:

REVOKE EXECUTE ON FUNCTION approve_order(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION approve_order(uuid) TO app_manager;

If a function is meant to be public-callable (e.g. an anonymous search), declare it deliberately — @expose: rpc public in the COMMENT, or list it under api.rpc.allowPublicExecute.

A SECURITY DEFINER function runs as its owner and can bypass the caller’s RLS — the most powerful (and most footgun-prone) case. It is exposed only when both hold:

  1. it is listed under api.rpc.allowDefiner (the operator’s deploy-time opt-in, separate from the migration author’s tag); and

  2. it declares an owner-safe search_path — every element a schema only its owner may create in (e.g. pg_catalog), plus a trailing pg_temp:

    CREATE FUNCTION settle_invoice(invoice_id uuid) RETURNS void
    LANGUAGE sql SECURITY DEFINER
    SET search_path = pg_catalog, pg_temp
    AS $$ … $$;

    A search_path that includes a writable schema (so another role could plant an object the owner resolves unqualified) is rejected.

Overloaded names (more than one schema.name), VARIADIC / polymorphic / unnamed arguments, and unmappable return shapes are skipped with a build issue, so a tagged function that does not appear always tells you why.

describe_functions lets an agent learn the actions; the call tool lets it run one — the “act” half of describe → act. It is opt-in and off by default: the MCP server is describe-only until an operator enables execution.

Enable it in kozou.config.yaml under server.mcp.execution:

server:
mcp:
execution:
enabled: true
role: kozou_mcp_agent # the role calls run as (required without server.mcp.http.auth)
# claims: { tenant_id: acme } # optional, published for RLS
# allow: [public.approve_order] # optional allowlist; omit = every exposed action

With execution enabled, the bundled kozou mcp server lists a call tool that takes { "function": "<schema>.<fn>", "args": { … } }. It runs the function through the same enforcement envelope as the REST surfaceSET LOCAL ROLE plus published claims, inside a transaction — so the function’s EXECUTE privilege and its own RLS apply. A call can only reach the functions describe_functions already advertises, intersected with the optional allow list; anything else is reported as an unknown function (a function that is not exposed is indistinguishable from one that does not exist).

A single service role by default — per-user with OAuth

Section titled “A single service role by default — per-user with OAuth”

The REST surface carries a per-request JWT, so each call runs as that user’s role. Without server.mcp.http.auth, MCP has no end-user token: call runs as one operator-chosen role for every request, and the agent cannot choose the role or claims (that would let it self-elevate). This is deliberately coarser.

The execution role’s EXECUTE grants and the functions’ RLS are the entire boundary, so give call a dedicated, least-privilege role — never the database owner or a superuser — and GRANT EXECUTE only on the functions an agent should run:

CREATE ROLE kozou_mcp_agent NOLOGIN;
GRANT kozou_mcp_agent TO app_login; -- the role your connection logs in as
GRANT EXECUTE ON FUNCTION approve_order(uuid) TO kozou_mcp_agent;

SECURITY DEFINER is the sharpest edge: a definer function runs as its owner, bypassing the execution role’s RLS, so enabling call alongside a definer action means an agent can trigger an owner-privileged verb. Definer functions still require the allowDefiner double opt-in to be exposed at all — review them before turning execution on.

A failed call returns an MCP error result with a generic message — permission denied, a constraint category, or “the function call failed” for a RAISE. The raw database text is never returned to the agent (it goes to the server log only).

By default the MCP HTTP transport is unauthenticated: with call enabled, anyone who can reach the port can run the exposed actions as the execution role, so the server prints a louder startup warning on a non-loopback bind. Keep it on 127.0.0.1 (the default), put an auth/proxy layer in front, or enable the OAuth resource-server mode (server.mcp.http.auth) — then every request needs a verified bearer token and call runs as each token’s role instead of a fixed one (Remote MCP with OAuth). The call wire shape is a stable contract as of Kozou v1.6, like the rest of the RPC surface. The env-only standalone @kozou/mcp CLI stays describe-only — execution is a feature of the bundled kozou mcp.

The two RPC allowlists live under api.rpc in kozou.config.yaml and default to empty (only invoker functions with PUBLIC EXECUTE revoked are exposed without them):

api:
rpc:
allowDefiner:
- public.approve_order # schema-qualified
allowPublicExecute:
- public.search

Functions are served only by the in-house @kozou/api backend (the default). Under the external PostgREST opt-out the Admin UI hides the Actions surface and uses PostgREST’s own /rpc/ instead.