MCP Forge
View Markdown
MCP Forge architecture.mdDownload .md

Architecture

This is how a tool call actually reaches your database, and why the non-negotiable rule — no tool ever accepts raw SQL — holds structurally, not just by convention.

The one path every call takes

Agent → typed MCP tool call
      → declarative plan (QueryPlan | MutationPlan | ProcedureCallPlan)
      → PolicyEngine validation (exposure, masking, identity, verb)
      → driver translates plan → parameterized SQL, in its own dialect
      → your database
      → AuditLog entry (always, regardless of outcome)

There is no branch of this path where a string the agent supplied is concatenated into a query. The plan types have no field that could hold one — QueryPlan.Filters is a typed list of {column, op, value} triples with op restricted to a fixed enum (eq, neq, in, gt, gte, lt, lte, ilike, is_null, not_null), not a WHERE-clause fragment. This is what makes SQL injection structurally impossible rather than merely filtered against, and what makes it impossible for a guardrail to be routed around — masking and permission checks happen on the plan, before any driver sees it, so there's no later stage that could "forget" to apply them.

Introspection → SchemaModel

At startup (and on every SIGHUP), each connector's driver introspects its target database's real catalog — information_schema for Postgres/MySQL, sys.* views for SQL Server — and normalizes it into an engine-agnostic schema.SchemaModel: tables, columns (with a normalized logical type: integer/float/text/boolean/timestamp/uuid/unknown, not the engine's native type name), primary keys, foreign keys, and stored procedures with their parameters. Table and column comments (COMMENT ON in Postgres, similar mechanisms elsewhere) are captured too — they feed directly into generated tool descriptions, so an agent sees your actual schema documentation, not just column names.

Policy → PolicyEngine

A connector's stored policy (see policy.md) plus its introspected SchemaModel build a policy.Engine — the single source of truth every downstream component asks, never bypasses: is this table exposed, is this column masked, is this identity allowed to see or write to this table, what's the row cap, what's the query timeout. Nothing about visibility or permission is decided anywhere else.

Tool generation

The tool generator walks the exposed tables and procedures in the SchemaModel, filtered through the PolicyEngine, and emits one MCP tool definition per operation actually enabled: get_<table>_by_pk, search_<table>, aggregate_<table> always (for every exposed table); create_<table>, update_<table>_by_pk, soft_delete_<table>_by_pk only for tables with that specific mutation enabled; one tool per exposed procedure; and a global schema_overview. Each tool's JSON Schema is built from the table's visible columns only — a masked column has no field in the schema to put a value into, and doesn't appear in the tool's description either.

Known limitation, not accidental: tool definitions are generated once per connector, the same for every caller — an identity blocked from a table still sees that table's tools in tools/list with a full schema; only calling them is blocked, at the plan-validation step below. True per-identity tool visibility would mean dynamic, per-session tool registration — a materially bigger change, tracked as a known gap rather than silently left unaddressed.

Execution: the choke points

Every read, write or procedure call follows the same sequence regardless of which tool triggered it:

  1. Resolve the verified identity (if auth.enabled) from the request.
  2. Check PolicyEngine.IdentityAllowed/IdentityReadOnly/ IdentityCanMutate — blocked here never reaches step 3.
  3. Build the plan from the agent's typed arguments, coercing JSON values to each column's logical type.
  4. Validate the plan structurally — table/columns exist and are visible, ops are in the enum, mutation columns are writable and not masked, update/delete carry every primary-key column as an equality filter, limit is capped to the policy's row cap.
  5. Hand the validated plan to the connector's driver.
  6. Write exactly one audit entry — allowed, blocked, or errored — no matter which of the above steps stopped the call.

A blocked or errored call never reaches the database at all; a masked value is never in reach of any of these steps to begin with, since it was never in the plan.

Drivers

driver.Driver = Introspector + Executor + Mutator + ProcedureCaller. PostgreSQL, MySQL and SQL Server support ships with the product; each connector translates the same plan types into that engine's own parameterized SQL dialect ($1, $2... for Postgres, ? for MySQL, @p1.../sql.Named for SQL Server). Mutations run inside a transaction: the driver checks AffectedRows against the policy's max_affected_rows before committing, rolling back rather than committing an over-broad write.

The published distribution includes PostgreSQL, MySQL and SQL Server support. Select the connector type in config.yaml; no separate driver installation is required.

Multi-connector

One runku serve process can protect several databases at once. Runku exposes one independent MCP endpoint per connector, at that connector's own path (defaulting to /mcp/<id>) — tool names never need a connector prefix, since each connector lives at a different URL. Each connector gets its own driver connection, its own introspected SchemaModel, its own PolicyEngine, and its own generated tool set, entirely independently of the others.

The admin store

Roles, identities, API key hashes, role bindings, and every connector's policy live in the admin store — a small database of its own (SQLite or PostgreSQL), completely separate from both the databases Runku protects and from the audit trail's own storage. runku serve reads from it at startup and again on SIGHUP; API key lookups are live, per-request, with no caching layer to invalidate. See auth-and-permissions.md for the full roles/identities model this database holds.

Both SQLite and PostgreSQL admin stores are included in the published distribution. PostgreSQL is recommended for production and multi-instance operation; SQLite is appropriate for local or small single-instance setups.

Audit and the outbox pattern

Every call produces one structured entry — never the actual row data returned or a masked value, only what was asked and what was decided. It's always written to stdout. Two additional sinks are optional and independent: a local SQLite buffer (powers /reports/* and the portal) and a rotated on-disk file. From the SQLite buffer, an optional Shipper periodically POSTs unshipped batches to an external destination and deletes them only after a confirmed successful delivery — an outbox: a failing destination never loses data, it just keeps accumulating locally until shipping succeeds. See configuration.md for how to configure a durable destination.