NLQueries GitHub

Authenticating the MCP server

The MCP server exposes nine tools. One of them, query, generates SQL and runs it against a configured database; others return your schema, your connector hosts, and the questions people have asked. Until a caller is identified, there is nothing to authorise: the SQL policy constrains what may be run and never who may run it.

This page covers turning that on. If you use the server over stdio — Claude Desktop, or nlqueries mcp-server start with no --sse — none of it applies and nothing has changed.

stdio needs no configuration

The client launches the process, talks to it over its own pipes, and the caller already has whatever the process has: the knowledge base, the connector file, the stored credentials. Requiring a token there would be theatre. The calls are still authorised and audited, against a principal that is the process owner.

A networked transport will not start without a verifier

--transport sse and --transport streamable-http refuse to start unless one of the two below is configured. The refusal names what to set.

If you are upgrading and need the previous behaviour immediately, set NLQ_ALLOW_UNAUTHENTICATED_MCP=1. It logs a warning on every start and grants every caller everything, which is what the server did before. It is deliberately not the same switch as NLQ_ALLOW_INSECURE_BIND: that one says you know the port is reachable, this one says you know anyone who reaches it may run SQL against your database.

Option 1 — an identity provider

export NLQ_MCP_OIDC_DISCOVERY_URL=https://idp.example.com/.well-known/openid-configuration
export NLQ_MCP_OIDC_CLIENT_ID=nlqueries-mcp
export NLQ_MCP_RESOURCE_URL=https://mcp.internal.example.com

Tokens are verified against the provider's JWKS: signature, expiry, audience (aud must equal the client id) and issuer. A provider whose discovery document omits issuer is refused rather than accepted with the issuer check disabled, and a token carrying no sub is refused rather than becoming an identity of empty string.

Option 2 — a pre-shared token

For an install with no identity provider:

export NLQ_MCP_STATIC_TOKEN="$(openssl rand -hex 32)"
export NLQ_MCP_STATIC_SUBJECT=operator            # optional; defaults to "static-token"
export NLQ_MCP_RESOURCE_URL=https://mcp.internal.example.com

Or NLQ_MCP_STATIC_TOKEN_FILE=/run/secrets/mcp_token to read it from a mounted file rather than the environment.

The token is compared in constant time and must be at least 32 characters. It authenticates and authorises nothing on its own: the subject it maps to gets exactly what the grants file gives that subject.

Configure one option or the other. Setting both is refused — two ways in is two things to get wrong.

Grants

Once authentication is on, NLQ_MCP_GRANTS_FILE is required. Without it every authenticated call would be denied, so the server refuses to start rather than serving something that looks working and answers nothing.

# Which subjects may do what, on which agents.
grants:
  # An analyst who may ask questions of one agent and read its schema.
  - subject: "[email protected]"
    agents: ["sales"]
    actions: ["query:execute", "schema:read", "agents:list"]

  # A service account that reads cache statistics across everything.
  - subject: "monitoring"
    agents: ["*"]
    actions: ["cache:stats", "health:read"]

  # An operator with everything, on everything.
  - subject: "operator"
    agents: ["*"]
    actions: ["*"]

subject is what the identity provider put in sub, or NLQ_MCP_STATIC_SUBJECT for a pre-shared token. Several grants may name the same subject; they combine.

A subject with no grant is denied. There is no deny list: a policy with both grants and denials needs a precedence rule, and precedence rules are where authorisation bugs live.

agents and actions must be lists. A bare string is refused, because YAML scalars are iterable and agents: "prod-*" read as a sequence yields its characters — one of which is *, which would grant every agent. An action that is not one of the names below is refused too: a misspelt action grants nothing, and you would have no way to tell that from a grant that is working.

Actions

Action Tool Names an agent
query:execute query yes
schema:read get_agent_schema yes
history:read get_query_history yes
feedback:submit submit_feedback yes
cache:invalidate invalidate_cache yes
cache:stats get_cache_stats yes
agents:list list_agents no
connectors:list list_connectors no
health:read health no

The three that name no agent are authorised without one; the rest are authorised against the agent in the call, and a call whose agent cannot be determined is refused.

Limits

Authorisation says whether a caller may run query on an agent. It says nothing about how often, and each call is an LLM charge, a query against your database, and a slot in a process that will otherwise start as many as it is asked to.

Two limits apply per principal on the networked transports, with defaults:

Variable Default What it bounds
NLQ_MCP_RATE_LIMIT_PER_MINUTE 60 Calls a principal may make in a minute
NLQ_MCP_MAX_CONCURRENT 8 Calls a principal may have in flight at once

Set either to 0 to disable it. A value that is not a number, or is negative, falls back to the default with a warning rather than to no limit — a typo in a number should not switch a control off.

The concurrency limit is the one that matters for a slow tool: query is allowed forty-five seconds, so a caller who ignores the rate limit's refusals could otherwise hold open as many slow calls as they can start.

The rate limit is a fixed window, so a burst of up to twice the limit can cross a boundary. Both counters live in the process, so a deployment running several servers behind a load balancer gets the limit multiplied by that number.

These are per principal, so what they ration depends on how many principals there are.

With an identity provider each caller has their own sub, and the numbers above are per person.

With a pre-shared token they are not. Every accepted token resolves to the same subject, so one allowance covers the whole install: a small team sharing a token shares 60 calls a minute and 8 in flight between them, and one client in a loop will exhaust it and see the rest refused. Size both numbers for the deployment rather than for one person, or move to an identity provider if you need to tell callers apart.

stdio has no limits — the caller owns the process, and rationing them against themselves would achieve nothing — and neither does a transport running with NLQ_ALLOW_UNAUTHENTICATED_MCP, where every request is the same anonymous caller. There, a limit would not ration anybody: it would put one budget across a deployment that had none before, which is not what a switch asking for the previous behaviour should hand you.

What gets recorded

Every decision, allowed and denied, goes to the nlqueries.audit logger:

{"at": "2026-08-28T09:00:00+00:00", "principal": "[email protected]",
 "source": "oidc", "action": "query:execute", "agent_id": "sales",
 "decision": "deny", "reason": "no grant covers this action on this agent"}

Denials log at WARNING and allows at INFO, so the two can be routed differently without parsing messages. Neither carries the token nor the token's claims — these records travel into support bundles.

The refusal returned to the caller names the action and the agent and never the reason. A caller learning which agents exist from the wording of a denial is one of the disclosures this exists to prevent.

Enterprise

nlqueries-enterprise supplies its own authorizer over its tenant tables and existing RBAC, so a deployment there does not write a grants file. The interface is the same; only the source of the answer differs.