MCP Forge
View Markdown
Exposure, masks & mutations.mdDownload .md

Policy

A connector's policy is its complete, stored contract of what it will ever show to anyone: which tables and procedures are exposed, which columns are masked, what writes are allowed. It lives in the admin store (see auth-and-permissions.md for how that's structured), managed via runku policy, never parsed from config.yaml. This document is about what a connector can show at all — restricting that further per identity (who gets to see or touch which of the exposed tables) is a separate layer, covered in auth-and-permissions.md.

Nothing is exposed by default. A table absent from policy, or with expose: false, produces no tools at all — not hidden, not filtered: absent from tools/list and from schema_overview, exactly as if it didn't exist.

Viewing and applying policy

runku policy show postgres-main

Prints the connector's current stored policy as YAML.

runku policy apply --file policy.yaml postgres-main

Reads a policy file (same shape as below), applies defaults, validates it internally (row limits, duplicate mask columns, mutation column references) and against the connector's actual, live-introspected schema — a table or column that doesn't exist is a hard error, not a silent no-op. Only then is it written to the store. If runku serve is already running against the same store, send it SIGHUP to pick the change up without a restart.

Shape of a policy file

# Connector-wide read limits applied unless a table overrides them.
default_max_rows: 100
query_timeout: 5s
# Per-table visibility, masking and limits.
tables:
  customers:
    # Publish typed tools for this table but remove sensitive columns entirely.
    expose: true
    mask_columns: [email, phone]
    # Tighter row cap for this table.
    max_rows: 50
  orders:
    # Publish read tools using connector-wide limits.
    expose: true
  salaries:
    # Keep the decision explicit and do not generate tools for this table.
    expose: false   # explicit, to document the decision
# Stored procedures require an explicit allow-list and parameter contract.
procedures:
  recalc_totals:
    # Publish only this procedure with only the declared parameter.
    expose: true
    params: [order_id]

default_max_rows / query_timeout

Policy-wide defaults. default_max_rows (default 100 if unset) caps every search_/aggregate_ call unless a table overrides it. query_timeout (default 5s) wraps every query/mutation/procedure call in a context deadline.

Per table (tables.<name>)

Field Effect
expose: true Generates get_<table>_by_pk, search_<table>, aggregate_<table> for this table. Required before mutations can be enabled on it.
mask_columns: [...] These columns are excluded from SELECT, results, filters, order_by, and schema_overview — structurally absent, not merely omitted from the response. Filtering by a masked column is rejected before the query ever reaches the database.
max_rows: N Overrides default_max_rows for this table only. 0 (or omitted) means "use the policy default."
mutations Opt-in write support — see below. Absent entirely = the table stays read-only.

Masking is absolute, including for writes

A masked column can never appear in a mutation's values, even if mutations.create.allowed_columns lists it — masking is checked before the write-side allow-list, not instead of it. This has one real consequence worth knowing before you write your own policy: a masked, NOT NULL column makes create_<table> permanently fail at the database level, since the agent can never supply a value for a column it can't see. If a column needs to stay populated on every insert, either don't mask it or make it nullable with a sensible default.

Mutations — opt-in writes

Every table starts read-only. Enabling writes is per-table, per- operation, per-column — nothing is exposed by adding a mutations: section unless each operation is separately turned on.

# Write permissions are configured per table and operation.
tables:
  customers:
    # The table must be exposed before any read or write tool can exist.
    expose: true
    # Masked columns are absent from reads, filters and mutation inputs.
    mask_columns: [email, phone]
    mutations:
      create:
        # Generate a create tool with an explicit input/output contract.
        enabled: true
        allowed_columns: [name, country]
        required_columns: [name]
        returning: [id, name, country]
      update:
        # Permit only these fields and protect against broad writes.
        enabled: true
        allowed_columns: [name, country]
        max_affected_rows: 1
        returning: [id, name, country]
      delete:
        # Soft-delete one row by setting the configured marker column.
        enabled: true
        mode: soft
        soft_delete_column: deleted_at
        max_affected_rows: 1
        returning: [id, name]

Generates exactly the tools each enabled operation implies — create_customers, update_customers_by_pk, soft_delete_customers_by_pk — and no others. A table with only create.enabled: true gets exactly one new tool.

create

update (always by primary key — never a filter-based bulk update)

delete — soft-delete only; there is no hard delete and no filter-based bulk delete anywhere in the product.

A soft delete does not automatically hide the row from reads afterward — deleted_at (or whatever you named it) is a normal, unmasked column like any other; excluding deleted rows from a search_ call is the caller's job (filters: [{"column":"deleted_at","op":"is_null"}]), the same way any other filter works. No implicit magic.

Procedures

# Procedure tools are denied unless explicitly listed here.
procedures:
  recalc_totals:
    # Expose the procedure and accept only this named parameter.
    expose: true
    params: [order_id]

A stored procedure is a second, first-class schema object alongside tables. expose: true generates one MCP tool per procedure, with a JSON Schema built from params. Calls are still fully declarative — a ProcedureCallPlan with named, typed parameters, never a raw CALL/ EXEC string assembled from agent input.

A complete worked example

# Default safeguards for every exposed table in this connector policy.
default_max_rows: 100
query_timeout: 5s
tables:
  customers:
    # Exposed with sensitive fields removed and a tighter row limit.
    expose: true
    mask_columns: [email, phone]
    max_rows: 50
  orders:
    # Exposed using the connector defaults above.
    expose: true
  order_items:
    expose: true
  products:
    expose: true
  salaries:
    # Explicitly excluded; Runku will not generate tools for it.
    expose: false   # explicit, to document the decision — not just omitted

This example policy exposes four tables exposed read-only, customers.email/phone masked, and salaries explicitly marked not-exposed rather than left out of the file entirely — the point is to document the decision, not just implement it. See config.mutations-example.yaml / seed.mutations-example.yaml for a full worked example with every mutation operation enabled.