{"slug": "i-didn-t-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway", "title": "I Didn't Want My AI Agent to Have a Database Password, So I Built a Gateway", "summary": "An engineer built n0, an open-source Go platform that acts as a gateway between AI agents and enterprise databases, to avoid giving agents direct database passwords. The platform separates authentication, tenant context, and SQL safety checks, ensuring agents can only execute read-only queries through a controlled set of MCP tools. The project emphasizes that tools are part of the security model, not just conveniences.", "body_md": "The first version of the idea was embarrassingly simple.\n\nGive the agent a database URL, let it write SQL, run the query, and send the rows back to the model.\n\nIt would have made a nice demo. It also would have made a terrible system.\n\nThe problem wasn't only that a model might produce a `DROP TABLE`\n\n. A read-only user can still run a query that scans half a warehouse, hold connections open, join against a table it was never supposed to see, or return far more data than the agent actually needs.\n\nAnd a database password is still a database password, even when it is hidden inside an agent configuration file.\n\nThat was the starting point for [n0](https://github.com/sickagent/n0): an open-source Go platform that sits between AI agents and enterprise data. The agent gets a small set of useful tools. The gateway owns authentication and tenant context. A separate query service decides whether the SQL is safe to execute.\n\nThis is a note about the architecture, but also about the decisions behind it. The project is working software, not a claim that every production concern has been solved.\n\nI keep coming back to one sentence:\n\nThe agent decides what it wants to ask. The platform decides whether it is allowed to ask it and how the request is executed.\n\nThe rough request path looks like this:\n\n```\nAI agent\n   │\n   │ MCP / Streamable HTTP\n   ▼\nAgent Gateway\n   │  JWT, tenant context, tool routing\n   ├──────────────► Meta Service ─────► PostgreSQL\n   │                 workspaces,        metadata\n   │                 connections, schema\n   │\n   └──────────────► Query Engine ─────► NATS JetStream\n                     SQL sandbox,       asynchronous jobs\n                     result lifecycle\n                           │\n                           ▼\n                    Connection Manager\n                           │\n                           ▼\n                 PostgreSQL / MySQL / ClickHouse / ...\n```\n\nThere are a few more boxes in the repository, but these are the boundaries that matter for an agent.\n\nThe MCP server lives in the Agent Gateway. It 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.\n\nThat last part is intentional. I didn't want security rules to slowly diverge between \"the API path\" and \"the AI path\".\n\nMCP is a useful way to describe capabilities to an agent. It gives the client a standard way to discover tools and call them.\n\nIt does not answer the important questions for a data platform:\n\nThose questions belong to the gateway and the services behind it.\n\nIn n0, the MCP endpoint is exposed at:\n\n```\nhttp://localhost:8083/mcp\n```\n\nIt goes through the same JWT middleware as the REST API. The tenant ID used for internal requests comes from the verified JWT context, not from a `tenant_id`\n\nargument supplied by the agent.\n\nThat distinction is easy to miss. If a tool accepts both `connection_id`\n\nand `tenant_id`\n\n, the model can accidentally—or deliberately—ask for a different tenant's data. The public tool doesn't accept that field at all.\n\nThe current MCP server exposes six tools:\n\n| Tool | What it does |\n|---|---|\n`get_schema` |\nReturns the schema visible for a connection |\n`submit_query` |\nCreates an asynchronous read-only query job |\n`get_query_status` |\nChecks the state of a query job |\n`get_query_result` |\nFetches a paginated result page |\n`list_connections` |\nLists connections without returning credentials |\n`list_workspaces` |\nLists workspaces in the current tenant |\n\nThere is no `execute_sql_now`\n\ntool. There is no tool that returns a connection string. There is no tool that lets the model choose a different tenant.\n\nThe boringness is a feature. Tools are part of the security model, not just conveniences for prompting.\n\nOnce the gateway is running, a client can call `submit_query`\n\nwith a normal MCP JSON-RPC request. `$TOKEN`\n\ncan be a user JWT or an agent token issued by n0.\n\n```\ncurl -sS -X POST http://localhost:8083/mcp \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 1,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"submit_query\",\n      \"arguments\": {\n        \"connection_id\": \"conn_123\",\n        \"sql\": \"SELECT customer_id, sum(amount) AS revenue FROM public.orders GROUP BY customer_id\"\n      }\n    }\n  }'\n```\n\nThe response is intentionally small:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"result\": {\n    \"structuredContent\": {\n      \"job_id\": \"job_456\",\n      \"status\": \"pending\"\n    }\n  }\n}\n```\n\nThe agent polls `get_query_status`\n\n, then asks for pages with `get_query_result`\n\n. This is a better fit for analytical work than holding one HTTP request open while a warehouse does its thing.\n\nThe MCP registration is intentionally small. The official Go SDK handles the protocol details and typed tool schemas; the handler calls the existing Query Engine client.\n\n```\nmcp.AddTool(server, &mcp.Tool{\n    Name:        \"submit_query\",\n    Description: \"Submit a tenant-scoped read-only SQL query\",\n}, s.mcpSubmitQuery)\n```\n\nThe interesting part is not the registration. It is the context propagation:\n\n```\nresp, err := s.queryCli.SubmitQuery(ctx, &pb.SubmitQueryRequest{\n    TenantId:     mcpTenantID(ctx),\n    ConnectionId: input.ConnectionID,\n    Sql:          input.SQL,\n})\n```\n\nThe MCP request context has already passed through JWT verification. The handler doesn't trust a tenant field from the tool arguments and doesn't try to reimplement authorization in the MCP package.\n\nThis also means the same internal client can be exercised from REST, MCP, and tests. Fewer paths are good. Fewer security models are better.\n\n`submit_query`\n\nonly creates a job. Query Engine is the component that validates and executes the statement.\n\nThe current sandbox is intentionally conservative. It:\n\n`SELECT`\n\nstatement;`CREATE`\n\n, `DROP`\n\n, `INSERT`\n\n, `UPDATE`\n\n, and `DELETE`\n\n;`LIMIT`\n\nwhen one is missing;The source database should still use a read-only role. Application checks are not a replacement for database permissions. They are another layer.\n\nThis is the part where I resist the temptation to say \"the query is safe\". The honest version is: the query passed the checks we currently enforce, and the database role and network boundaries are still important.\n\nFor a tiny demo, synchronous execution is simpler. For a platform, it creates a pile of awkward edge cases.\n\nWhat happens when the client disconnects while the database is still working? What happens when the result is larger than the response body? What happens when the worker restarts after the query has been accepted? How do you retry without running the same expensive query twice?\n\nTurning the request into a job gives the system somewhere to put those answers.\n\nThe flow is roughly:\n\nIt is more code than `db.QueryContext`\n\n. It is also much easier to reason about once there is more than one user and more than one query running.\n\nThe current version has working MCP support, but it is not a finished cloud product.\n\nThere are still open areas:\n\nThe MCP transport is stateless so gateway replicas do not depend on an in-memory session store. That keeps the first deployment model simple, but it also means the rest of the production story—rate limiting, revocation, observability, and operational policy—still needs to be designed at platform level.\n\nI prefer saying this out loud. A working endpoint is not the same thing as a completed security program.\n\n```\ngit clone https://github.com/sickagent/n0.git\ncd n0\ncp .env.example .env\nmake up\nmake migrate-up\n```\n\nThe local stack exposes:\n\n`http://localhost:3000`\n\n;`http://localhost:8083`\n\n;`http://localhost:8083/mcp`\n\n.The repository README walks through registration, workspace creation, adding a connection, discovering its schema, and submitting a query. The architectural trade-offs are documented in [ADR-001](https://github.com/sickagent/n0/blob/main/ADR-001-n0.md).\n\nMCP makes it easier for an agent to discover and call tools. It does not make direct database access safe.\n\nThe useful pattern, at least for this project, is:\n\n```\nstandard agent protocol\n        +\nstrong identity and tenant context\n        +\ndefault-deny query policy\n        +\nasynchronous execution\n        +\ndatabase-level least privilege\n```\n\nNone of those layers is perfect on its own. That is exactly why they should not be collapsed into one clever prompt, one regex, or one database role.\n\nIf you are building an AI data analyst, the interesting question is probably not \"which model writes the best SQL?\"\n\nIt is \"what is the smallest, most boring capability I can safely give the model?\"\n\nThat is the question n0 is trying to answer.\n\nThe code is on [GitHub](https://github.com/sickagent/n0). Feedback, issues, and arguments about the boundaries are welcome.", "url": "https://wpnews.pro/news/i-didn-t-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway", "canonical_source": "https://dev.to/ivenin/i-didnt-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway-3770", "published_at": "2026-08-17 11:10:00+00:00", "updated_at": "2026-08-17 11:43:42.364438+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "ai-safety", "developer-tools"], "entities": ["n0", "MCP", "Go", "NATS JetStream", "PostgreSQL", "MySQL", "ClickHouse"], "alternates": {"html": "https://wpnews.pro/news/i-didn-t-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway", "markdown": "https://wpnews.pro/news/i-didn-t-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway.md", "text": "https://wpnews.pro/news/i-didn-t-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway.txt", "jsonld": "https://wpnews.pro/news/i-didn-t-want-my-ai-agent-to-have-a-database-password-so-i-built-a-gateway.jsonld"}}