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.
Exposing a function
Section titled “Exposing a function”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:
- REST —
POST /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/@policyadvisory carried asx-kozou-ai/x-kozou-policy. - MCP — the
describe_functionstool 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 return | Wire |
|---|---|
scalar (integer, text, uuid, …) | the value |
| single composite / table row | an object |
SETOF / RETURNS TABLE(...) | an array — of objects for a row set, of bare values for a scalar set |
void | 204 No Content (REST); { ok: true, note: … } over the MCP call tool |
Exposure is not permission
Section titled “Exposure is not permission”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):
PUBLIC EXECUTE is a hard skip
Section titled “PUBLIC EXECUTE is a hard skip”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.
SECURITY DEFINER needs a double opt-in
Section titled “SECURITY DEFINER needs a double opt-in”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:
-
it is listed under
api.rpc.allowDefiner(the operator’s deploy-time opt-in, separate from the migration author’s tag); and -
it declares an owner-safe
search_path— every element a schema only its owner may create in (e.g.pg_catalog), plus a trailingpg_temp:CREATE FUNCTION settle_invoice(invoice_id uuid) RETURNS voidLANGUAGE sql SECURITY DEFINERSET search_path = pg_catalog, pg_tempAS $$ … $$;A
search_paththat includes a writable schema (so another role could plant an object the owner resolves unqualified) is rejected.
Other loud skips
Section titled “Other loud skips”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.
Executing actions from an MCP agent
Section titled “Executing actions from an MCP agent”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 actionWith 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 surface — SET 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.
Use a least-privilege role
Section titled “Use a least-privilege role”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 asGRANT 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.
Errors and transport
Section titled “Errors and transport”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.
Configuration
Section titled “Configuration”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.searchFunctions 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.