QueryAPIGate is a governed MCP server for your databases. Your saved SQL queries become tools any MCP agent can call: Claude, your own agent, an IDE. The server, not the prompt, decides what each agent may see, change and spend. Masking, human approval for writes, quotas and a tamper-evident audit trail are built in, not bolted on.
Self-hosted. Your data never leaves your infrastructure. Every database it reaches, it reaches with the credentials you gave it, never the agent's.
pip install "queryapigate[server,mcp]" && queryapigate mcpRead-only saved queries (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN and any Mongo find(), the same classification the REST write guard uses) become MCP tools. A query that writes becomes one only when its author marked it for agents, for a key whose grant allows the write, and a person approves each write first. Ad-hoc SQL never writes over MCP.
list_tables, describe_table and execute_sql exist only for a key granted a connection, limited to the tables you list.execute_sql is always read-only, whatever the same key may do over REST.tools/list shows an agent only what it may call; anything else, called by name, is refused.mcp_disabled).tools/list honours exactly the per-key scoping GET /catalog does: connections, queries and collections grants. An agent's key never sees or calls more than the identical REST key could.
A tool's inputSchema is generated from the saved query's own declared parameters, the same JSON Schema translation /openapi.json already uses.
tools/call runs the query through the exact path GET /q/<name> does: the same grant check, parameter validation, cache_ttl caching, run history and audit trail. A run over MCP shows in the Console's History and the Home tab's live feed like a REST call.
Every tool, a saved query, list_tables or execute_sql, declares an outputSchema, and every successful call returns a matching structuredContent alongside the usual text content. A row-returning tool always answers {"rows": [...], "truncated": <bool>}; list_tables answers {"tables": [...], "truncated": <bool>}.
A saved query's result columns are only known once it runs, not from its stored definition, so declaring per-column types would need a new admin-authored field. The envelope is the same for every tool.
The text content is a serialised copy of the same object, kept for clients that do not read structuredContent. The first text block is always pure JSON.
A row-returning tool's text used to be a bare JSON array with a "(truncated to N rows)" suffix. It is now always the wrapped object: read structuredContent directly, or parse the text as the new shape.
Not every question an agent needs to answer has a saved query behind it yet. Two fixed tools, tied to no saved query, are listed for a key whenever it has any connection-level access at all, the same connections grant /execute_sql checks over REST. A key scoped only to specific queries or collections never sees them.
execute_sql on named connections and tables.list_tables{"connection_name", "database"} (database is admin-key only, as over REST). The same shape GET /connections/<name>/schema returns: every table, view or Mongo collection with its columns, types, nullability and keys where the dialect has them.
describe_table{"connection_name", "table", "database"}. One table's columns, types and keys, masking-aware: a masked or dropped column is flagged, never shown as readable. A table the key cannot reach answers exactly as a missing one (table_not_found), so it cannot probe outside allowed_tables.
execute_sql{"connection_name", "sql", "params", "page_size", "database"}. A single read-only statement, always read-only over MCP whatever the key's allow_writes grant; allowed_tables still applies. Results are capped to QUERYAPIGATE_MCP_MAX_ROWS like a saved-query call.
Both reuse the permission checks and execution code the REST API already has. An execute_sql call is recorded in run history like a POST /execute_sql one, with "transport": "mcp".
Save a query in the Console's API Designer and publish it: it's a tool. Create a key for your agent with only the queries it needs. Point any MCP client that speaks Streamable HTTP at http://<host>:5001/mcp with the key as the X-API-Key header, or turn on OAuth and let it sign in. The Console's own MCP screen shows the workspace's live tool list, a reachability check and the connect snippet.
QUERYAPIGATE_MCP_PORT, default 5001, separate from queryapigate serve, so both run side by side against the same QUERYAPIGATE_HOME. The endpoint is http://<host>:<port>/mcp, speaking Streamable HTTP; /metrics and /health on the same port report on this process.
MCP's HTTP transport is ASGI-native, while the REST API is WSGI (Flask on gunicorn). So queryapigate mcp is its own server, started with the same variables.
What matters for correctness, reusing the project's own connection-grant checks, caching and audit logging, happens by direct Python import, not an HTTP round-trip back to the REST API, whichever process each server runs in.
A separate, read-only MCP server answers questions about QueryAPIGate itself, from its documentation, guides and blog. In Claude Code: claude mcp add --transport http queryapigate-docs https://queryapigate-docs.queryapigate-docs-mcp.workers.dev/mcp
The server is at http://<host>:5001/mcp, speaking Streamable HTTP. A script or IDE sends the agent's key as the X-API-Key header; a hosted assistant signs its user in through your identity provider instead.
One command registers the server for this project; add --scope user to have it in every project. The key travels in a header, never in the URL.
Put this in .vscode/mcp.json. The inputs entry makes VS Code ask for the key once and keep it out of the file, so the file can be committed.
A hosted assistant takes a URL and signs the person in with OAuth 2.1; it doesn't hold a key. Turn sign-in on with the two variables, then add the server as a custom connector. The agent then acts under that person's role and masking rules.
The official mcp package, the same client library hosted agents use. This is the script the ten-minute guide runs against a real server.
tools/list then shows exactly what it may call.An X-API-Key header checked against the same server key and scoped keys, or, with signed-in users on, an Authorization: Bearer token, so an agent acting for a user gets that user's role and from_claim row filters. A call carrying an API key is judged on that key alone; a wrong key never falls back to the token.
Send either as a request header, the way any HTTP-capable MCP client configuration allows. A credential in a URL parameter would leak into logs. An unset server key means open access, as over REST.
Every request to /mcp is checked before the MCP session sees it: a missing or wrong credential is an HTTP 401 with WWW-Authenticate, a refused one a 403, and the rate limit a 429, each with the {"error", "code", "request_id"} body REST uses.
A hosted AI client, a connector in an assistant, takes no API key: it follows the MCP specification's authorization, OAuth 2.1. The MCP server is the resource server, checking tokens; your identity provider (Keycloak, Okta, Auth0, Entra ID) issues them, signs the user in and registers the client. QueryAPIGate never sees a password. Stable since 0.30, under the compatibility promise.
Guide 46, with Keycloak →from_claim) returns only that person's rows.from_claim binding.Set the first two, and a role, to turn it on; anything half-set, or not https:// (except localhost), stops startup.
GET /.well-known/oauth-protected-resource (and …/mcp) is the server's Protected Resource Metadata (RFC 9728): its URL, authorization_servers, scopes_supported. A 401 points at it in WWW-Authenticate, which is how a client knows where to sign the user in.
Signed by the provider's published keys with the key's own algorithm; iss the provider; aud the MCP server's URL; not expired; carrying the scope as scope or scp. Anything else is 401 invalid_token. Valid without the scope: 403 insufficient_scope. A user with no role here: 403 forbidden.
A token issued for the REST API is not accepted on /mcp, and REST refuses a token naming the MCP server, so one leaked from either cannot be replayed against the other. API keys still work for SDKs and scripts. No token is ever passed on: databases are reached with the connection's own credentials.
Its role's: connections, saved queries, allowed_tables, rate limit and masking rules. from_claim parameters bind to the token's claims, so an agent acting for a person sees only that person's rows. Its runs are in history as jwt:<sub> with "transport": "mcp", or mcp:<sub> when REST trusts another issuer.
Between the client and your provider: Client ID Metadata Documents or Dynamic Client Registration where supported, or an administrator registering it by hand. QueryAPIGate plays no part and issues no tokens of its own.
QueryAPIGate checks tokens and issues none, so registering clients and revoking tokens are the provider's. A token must be a JWT signed with an asymmetric key; an opaque one is refused. A token is valid until it expires.
What an agent is told the data means. Stable since 0.30, with descriptions themselves. Most wrong answers are not the model's fault: nobody told it that revenue is SUM(payment.amount). QueryAPIGate imports your database's own table and column comments; you write the rest.
SUM(payment.amount), not rental.amount.list_tables, and as an MCP resource per connection.structuredContent with an outputSchema, so your code reads rows, not prose.describe_table lets the agent inspect one table before it queries — columns, types and keys — and a masked column is flagged, never shown as readable.embed turns the agent's text into a vector for a pgvector search, so it searches by meaning — still masked, scoped and audited (experimental, PostgreSQL).list_tables carries the meaningEach table's and column's description, the connection's notes, and masked on each column the caller gets masked.
A saved-query tool's output schema describes its result columns, from the query's columns.
An MCP resource, queryapigate://connections/<name>/dictionary, in Markdown: the notes, then every table the caller can reach, each column with its type, description and whether it is masked for this caller. resources/list lists them; resources/read returns one. Callers with saved queries only have none.
'Tickets like this one', 'comfortable running shoes'. A saved query declares an embed parameter; QueryAPIGate turns the agent's words into a vector through any OpenAI-compatible embeddings endpoint, and pgvector ranks the rows. No vector store to run: the vectors stay in your PostgreSQL.
PostgreSQL with pgvector only.
ADR 0025 →Agents read only, except through a saved query whose author marked it agent_writes, for a caller whose grant allows that write, with QUERYAPIGATE_ALLOW_WRITES on, on PostgreSQL, MySQL, SQLite or DuckDB. Such a query is one INSERT, UPDATE or DELETE, never DDL, listed with readOnlyHint: false and destructiveHint: true. execute_sql never writes. Stable since 0.30.
"agent_writes": "approval"The first call writes nothing: it answers approval_required with a request id and an approve_at link. An administrator with writes.approve approves or rejects it. The agent calls again with the same arguments and approval: "wr_…": it runs if approved, for this caller, with exactly these arguments, before it expires (15 minutes by default), and once.
"agent_writes": "insert"A single INSERT runs at once, with an optional idempotency_key so a retry within a day returns the first call's result instead of inserting again.
max_affected_rowsDefault 1. The write runs in a transaction, rolled back when it changed more rows (too_many_rows_affected, nothing changed). On MySQL keep these queries on InnoDB tables; MyISAM cannot roll back.
RETURNING is masked like any read. Every request is kept 30 days (GET /api/v1/write-requests); the run is in history with request and approved_by; approvals and rejections are in the audit log. QUERYAPIGATE_CONSOLE_URL makes approve_at a full link.
Observability → Approvals, with a count of waiting writes in the sidebar. The link an agent is given opens its request there: the agent, the SQL, the exact arguments, the row ceiling, when it expires, with Approve and Reject. The writes_awaiting_approval alert says when writes are waiting; the Agents screen counts each agent's requests by how they ended.
A write is never approved by the credential that asked for it (own_write), so give agents their own keys. An agent with 100 writes waiting is refused more (too_many_pending_writes).
Every tool call passes through the same checks a REST request does, in the same order, by calling the same code. That matters because an agent is an untrusted user that is very fast and very easy to fool: one poisoned document in its context and it will ask for anything, and "please don't read the salaries table" in a system prompt is a request, not a control. QueryAPIGate moves every rule into the server, where the gateway cannot be talked out of its grants.
The threat model →The same eight steps a REST call goes through, in the same code. The prompt is never one of them.
email, national_id, salary.SELECT email AS contact), joined, inside an expression, through a view."transport": "mcp": which agent, which query, which arguments' names, how many rows.1. QUERYAPIGATE_RATE_LIMIT per client address, before authentication, so guessing keys is throttled too. 2. Authentication. 3. The caller's own rate_limit grant. 4. Grants: connections, queries, collections, allowed_tables, IP allow-lists, then the tool itself.
Past authentication, a refused call is a tool error (isError: true) whose text says why and whose structuredContent carries the same {"error", "code"} a REST error body does: rate_limited (with retry_after), unauthorized, table_not_allowed, read_only, param_invalid and the rest.
Every call is counted in /metrics with method MCP and endpoint mcp.saved_query, mcp.execute_sql or mcp.list_tables. Saved-query and ad-hoc runs land in run history and live events with "transport": "mcp".
With QUERYAPIGATE_REDIS_URL set for both processes, a key's 100/minute is 100 calls over REST and MCP together. Without it each process counts on its own, as two instances would.
queryapigate mcp serves its own GET /metrics and GET /health on its port, open or behind QUERYAPIGATE_METRICS_TOKEN as the REST server's. Scrape both.
queryapigate export reads the store directly with no key, so no grant or rate limit applies: whoever can run it can already read QUERYAPIGATE_HOME. Its runs are still recorded, with key_name cli.
A tool call caps its result at QUERYAPIGATE_MCP_MAX_ROWS rows, 200 by default, independent of QUERYAPIGATE_MAX_PAGE_SIZE: a model's context window cannot hold a result the way a person paging through the Console can.
QUERYAPIGATE_MCP_MAX_ROWS, 200 by default, sized for a context window.Redis, and across REST and MCP together.truncated is true, and it carries an agent-facing note ("…more rows match; add a filter, or ask for a summary with an aggregate"), both as a field in structuredContent and as a second text block, so a model narrows the query or aggregates instead of assuming it saw every row.
The note is a separate block; the first text block is still the pure JSON envelope, so a client that parses it keeps working.
A caller-supplied page_size below the cap is still respected; the cap is a ceiling, not a page size.
The OAuth settings are under sign-in above. These five cover the port, the row cap and the optional embeddings endpoint for semantic search. They show up as a read-only "MCP server" section on the Console's Settings screen, with the effective value and whether it is the default, like every other setting there.
Guide 27, check the MCP server from the Console →In the sidebar, for the operational view: Check now tries a TCP connection to that port on 127.0.0.1 (only when asked, GET /api/v1/mcp/status), Tools lists what tools/list returns in the current workspace (GET /api/v1/mcp/tools), and a connect snippet shows the URL and header a client needs.
Both are worked out by the REST server: the check only says whether something listens on that port on the same host, and the settings shown are the REST server's. An MCP process started with different variables is not reflected.
A parameter marked embed turns the agent's text into a vector for a pgvector search, so it searches by meaning, still masked, scoped and audited. Experimental, PostgreSQL only; it needs an embeddings endpoint you run (OpenAI, Azure, or a local Ollama).
Use a toolkit if you need the widest database coverage and will build governance yourself. Use QueryAPIGate if the question is "may this agent see this, change that, and can we prove what it did?"
Scored against an independent checklist →Security review, compliance, a customer-facing MCP server, one dictionary for every agent, or one gateway for many teams: the same controls, configured once.
from_claim row binding keeps each customer inside their own data.OAuth for AI clients, write tools with approval and the data dictionary are under the compatibility promise since 0.30. Every guide runs against a real server; MCP is exercised with the official SDK, OAuth against a real identity provider, the SQL guard under continuous fuzzing.
The threat model →MongoDB, H2 or generic JDBC connections; a caller with mask rules is refused there.PostgreSQL, MySQL (transactional tables), SQLite and DuckDB; one INSERT, UPDATE or DELETE per tool.embed) is experimental and PostgreSQL/pgvector only; it needs an OpenAI-compatible embeddings endpoint you run (OpenAI, Azure, or a local Ollama).No. Agents authenticate to QueryAPIGate; QueryAPIGate reaches your databases with each connection's own credentials, stored encrypted, and never passes a token or password on.
Any MCP client that speaks Streamable HTTP and can send a header (for an API key) or follow MCP's OAuth (for sign-in): agent frameworks, IDE assistants and hosted assistants' connectors.
Its question doesn't matter; its grant does. It can call only the tools its key or role allows; execute_sql, if granted at all, is read-only and limited to the tables you named; masked columns stay masked however the SQL is written, or the query is refused.
No, it's the same query. The tool you publish for agents is also GET /q/<name>, with an OpenAPI description, and can be exported to Parquet on S3 on a schedule.
Its rate limit refuses it, its quota stops it for the day or month, and its concurrency cap keeps it from tying up your database, each a clear, machine-readable error it can act on.