Articles

I Didn't Want My AI Agent to Have a Database Password, So I Built a Gateway

Handing an AI agent a database URL might make a nice demo but a terrible system. This post explains the architecture behind n0, an open-source Go gateway that gives agents boring tools, owns tenant context, sandboxes SQL, and executes queries asynchronously.

Written by:
APin

Senior Technology Analyst • Verified Expert

More from this author
I Didn't Want My AI Agent to Have a Database Password, So I Built a Gateway

Handing an AI agent a database URL might make a nice demo but a terrible system. This post explains the architecture behind n0, an open-source Go gateway that gives agents boring tools, owns tenant context, sandboxes SQL, and executes queries asynchronously.

The Problem: A Database Password Is Still a Database Password

The simplest integration is also the most dangerous: give the agent a database URL, let it write SQL, run the query, and return rows to the model. It makes a convincing demo and a terrible system. The threat model is larger than DROP TABLE. A read-only credential does not stop a query from scanning half a warehouse, holding connections open, joining against tables outside the agent's intended scope, or returning more data than the model needs. The database role limits what the connection can do, not what the agent will ask.

A password hidden inside an agent configuration file is still a password. It can be read from disk, leaked through a prompt, logged, or committed to source control. The problem is not the format of the credential; it is its distribution. Once a long-lived secret reaches the agent's runtime, the agent holds the same access as the account it represents, regardless of wrapping.

n0 addresses this pattern: an open-source Go platform that sits between AI agents and enterprise data. The agent receives a small, fixed set of tools—not a connection string. The gateway owns authentication, JWT verification, and tenant context. A separate query service, the Query Engine, is the only component that validates and executes SQL.

In n0, the agent cannot select a tenant; the tenant ID comes from the verified JWT context, never from a tool argument. The MCP server exposes six deliberately narrow tools—get_schema, submit_query, get_query_status, get_query_result, list_connections, list_workspaces—with no execute_sql_now and no credential-returning tool. submit_query creates an asynchronous job that the agent polls; the job is validated before execution.

The Query Engine's checks are conservative:

  • accepts one read-only SELECT statement;
  • rejects DDL/DML (CREATE, DROP, INSERT, UPDATE, DELETE) and locking reads;
  • injects a finite LIMIT when absent and rejects excessive limits;
  • checks referenced tables against the connection policy and can inject a tenant predicate;
  • propagates an execution timeout to the database driver.

The source database should still use a read-only role; application checks are another layer, not a replacement for database permissions. Asynchronous execution also handles client disconnects, response size, worker restarts, and retries that would otherwise double an expensive query.

The Mental Model: The Agent Decides What to Ask, the Platform Decides Whether It Is Allowed

The governing mental model is intentionally simple: the agent decides what it wants to ask, and the platform decides whether it is allowed to ask it and how the request is executed. The agent never negotiates directly with a database. It presents a request through a standard agent protocol, and every subsequent decision belongs to platform components.

The request path follows a fixed sequence. The AI agent sends a request over MCP or Streamable HTTP to the Agent Gateway. The gateway verifies the JWT, extracts tenant context from the verified token, and performs tool routing. It does not trust tenant or connection identifiers supplied by the model in tool arguments; those values come from the token's context. The Meta Service, backed by PostgreSQL, holds workspaces, metadata, connections, and schema. The Query Engine, backed by NATS JetStream, owns SQL sandboxing, asynchronous job state, and result lifecycle. The Connection Manager is the only component that opens database sessions to PostgreSQL, MySQL, ClickHouse, and other engines.

The MCP server is a thin adapter inside the Agent Gateway. It does not open a database connection, inspect credentials, or implement a second query executor. Instead, it translates MCP calls into the same internal clients used by the REST API. That design prevents security rules from diverging between the API path and the AI path.

Practical example: a submit_query tool accepts connection_id and sql, then returns a job_id. The agent polls get_query_status and reads pages with get_query_result. There is no execute_sql_now tool, no tool that returns a connection string, and no tool that accepts a tenant ID.

Enforcement layers include:

  • read-only database roles as the baseline permission model;
  • single-statement SELECT validation with DDL and DML rejected;
  • automatic LIMIT insertion and rejection of excessive or malformed limits;
  • referenced tables checked against the connection policy;
  • optional tenant predicate injection when a policy defines a tenant column;
  • execution timeouts propagated to the database driver.

Asynchronous execution through NATS JetStream gives failures and retries a defined home: a worker can validate and run SQL without holding an HTTP request open, and job state and results persist independently of the gateway process. Application-level checks are an additional layer, not a replacement for database permissions and network boundaries.

Why MCP Is Not the Security Boundary

MCP gives an agent a standard way to discover capabilities and call tools. It does not answer the questions that determine whether a data access request is authorized:

  • Who is calling?
  • Which tenant do they belong to?
  • Which connection may they use?
  • Which tables are visible to that connection?
  • Is the SQL safe and bounded?
  • Where does the result live while the query runs?

Those decisions belong to the gateway and the services behind it, not to the MCP protocol layer. In n0, the MCP endpoint at http://localhost:8083/mcp is served by the Agent Gateway and passes through the same JWT middleware as the REST API. The tenant ID used for internal requests comes from the verified JWT context, never from a tenant_id argument supplied by the agent. That distinction is easy to miss. If a tool accepted both connection_id and tenant_id, a model could accidentally—or deliberately—ask for a different tenant's data. The public tool therefore does not accept tenant_id at all.

The MCP server does not open a database connection, inspect credentials, or implement a second query executor. It translates MCP calls into the same internal clients used by the REST API, so security rules do not diverge between API and AI paths. The exposed toolset is deliberately narrow: submit_query creates an asynchronous read-only query job; get_query_status and get_query_result let the agent poll status and fetch paginated results. There is no execute_sql_now tool, no tool that returns a connection string, and no way for the model to choose a different tenant.

The Query Engine validates each statement before execution: one SELECT; no DDL or DML; no multiple statements or locking reads; a finite LIMIT when one is missing; table checks against the connection policy; an optional tenant predicate; and a driver-level timeout. Execution is asynchronous. The gateway submits a durable job through NATS JetStream, a worker runs the SQL, and the agent polls for status and pages of results. That avoids orphaned queries if the client disconnects and keeps large result sets out of response bodies. The source database should still use a read-only role; application checks are an additional layer, not a replacement for database permissions.

The Tools Are Deliberately Boring: Six Tools and No Shortcuts

The MCP server exposes a deliberately narrow interface: six tools, each scoped to a single responsibility.

  • get_schema returns the schema visible for a connection.
  • submit_query creates an asynchronous read-only query job.
  • get_query_status checks the state of a query job.
  • get_query_result fetches a paginated result page.
  • list_connections lists connections without returning credentials.
  • list_workspaces lists workspaces in the current tenant.

Equally important is what is absent. There is no execute_sql_now tool, no tool that returns a connection string, and no tool that lets the model choose a different tenant. The tenant ID comes from the verified JWT context, not from a tool argument. Tools are part of the security model, not just conveniences for prompting.

A real call to submit_query uses a standard MCP JSON-RPC request. $TOKEN can be a user JWT or an agent token issued by the gateway:

curl -sS -X POST http://localhost:8083/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "submit_query",
      "arguments": {
        "connection_id": "conn_123",
        "sql": "SELECT customer_id, sum(amount) AS revenue FROM public.orders GROUP BY customer_id"
      }
    }
  }'

The response is intentionally small, containing only a job ID and status:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "structuredContent": {
      "job_id": "job_456",
      "status": "pending"
    }
  }
}

The agent polls get_query_status and requests pages with get_query_result. This asynchronous model fits analytical work better than holding one HTTP request open while the database executes. Job state and results persist separately from the gateway process, avoiding edge cases around client disconnects and oversized responses.

The implementation is a thin adapter. The official Go SDK handles protocol details and typed tool schemas; the handler calls the existing Query Engine client. The critical part is context propagation: the MCP request context has already passed through JWT verification, so the handler does not trust a tenant field from tool arguments and does not reimplement authorization in the MCP package. The same internal client is exercised from REST, MCP, and tests.

Inside the Query Engine: Sandboxing SQL and Running It Async

When an agent calls submit_query, the gateway does not open a database connection or execute SQL. It authenticates the caller, resolves tenant context from the verified JWT, and submits a job. The Query Engine service is the enforcement point: it validates the statement, applies policy, and only then invokes the Connection Manager to run it against the source database.

The sandbox is deliberately conservative. It accepts exactly one SELECT statement and rejects DDL and DML such as CREATE, DROP, INSERT, UPDATE, and DELETE. It rejects multiple statements separated by semicolons and locking reads such as SELECT ... FOR UPDATE. When a LIMIT clause is missing, the engine injects a finite one; excessive or malformed limits are rejected. Referenced tables are checked against the connection policy, and if that policy defines a tenant column, the engine injects a tenant predicate—for example, rewriting SELECT ... FROM orders to include WHERE tenant_id = ?—before dispatch. An execution timeout is propagated to the database driver so a runaway query cannot hold a connection open indefinitely.

These checks are an additional layer, not a substitute for database permissions. The source database should still be configured with a read-only role, because application-level validation can fail or be bypassed; least privilege at the database remains the final boundary.

Execution is asynchronous by design. The flow is:

  • Agent Gateway authenticates the caller and submits the request.
  • Query Engine publishes a durable job through NATS JetStream.
  • A worker validates and executes the SQL through Connection Manager.
  • Job state and results are persisted separately from the gateway process.
  • The agent polls get_query_status and reads paginated results via get_query_result.
  • Terminal events are sent through the audit pipeline.

This job-based model resolves the edge cases that synchronous execution creates: a client disconnect no longer abandons work the database is still performing; results larger than a single response body are delivered as pages; a worker restart after query acceptance does not lose the request because the job is durable; and a retry can check job state instead of executing the same expensive query twice.

Honest Gaps, Local Setup, and the Takeaway

n0 is working software with functional MCP support, but it is not a finished cloud product. The gateway authenticates callers via JWT and derives tenant context from the verified token, not from tool arguments. The MCP endpoint at http://localhost:8083/mcp passes through the same JWT middleware as the REST API, and the public tool set omits fields like tenant_id so a model cannot address another tenant's data.

Execution is asynchronous. submit_query accepts a connection_id and SQL, then returns a job_id; the agent polls get_query_status and reads paginated results via get_query_result. The Query Engine sandbox allows one SELECT, rejects DDL/DML and multiple statements, injects a finite LIMIT, checks referenced tables against connection policy, and can inject a tenant predicate. These checks do not replace database permissions; the source database should still use a read-only role.

Open areas remain:

  • Vault-backed runtime credential leases
  • PostgreSQL row-level security for metadata tables
  • Distributed rate limits and quotas
  • Production Kubernetes and high-availability manifests
  • Agent token ownership verification and revocation lifecycle
  • Dynamic capability discovery for plugins

The MCP transport is stateless, so gateway replicas do not depend on an in-memory session store. That keeps the first deployment simple, but rate limiting, revocation, observability, and operational policy still need platform-level design.

Local setup: git clone https://github.com/sickagent/n0.git, cd n0, cp .env.example .env, make up, make migrate-up. The stack exposes:

  • Web Admin at http://localhost:3000
  • REST API at http://localhost:8083
  • MCP at http://localhost:8083/mcp

The pattern that matters: standard agent protocol plus strong identity and tenant context plus default-deny query policy plus asynchronous execution plus database-level least privilege. None of these layers is perfect alone, which is why they should not be collapsed into one prompt, one regex, or one database role.

Editorial Policy & Research Methodology

Our findings are based on rigorous internal research, verified industry benchmarks, and direct technical implementation experience from our enterprise client projects. All statistics and technical claims are reviewed by senior engineers before publication to ensure accuracy, transparency, and helpfulness for our readers.

Have an Idea?

Let's Build Something Amazing Together.