Database MCP Server for AI Agents
BifrostQL can present your database to an LLM agent as a set of Model Context Protocol tools. Point Claude Code, or any MCP client, at a BifrostQL MCP server and the agent can map your schema, read rows with structured filters and cursor pagination, aggregate, search across tables, and — when a deployment opts in — insert, update, and delete rows.
Like every non-GraphQL front door, the MCP server is a
protocol adapter: it owns only the
wire (MCP/JSON-RPC) and the tool codec. Every read executes through
IQueryIntentExecutor and every write through IMutationIntentExecutor, so
tenant isolation, soft-delete hiding, policy column guards, and the full
mutation pipeline are enforced on the wire exactly as they are for GraphQL —
the adapter has no API that could bypass them.
There are two ways to host it:
- stdio — the host process speaks MCP on its stdin/stdout. This is the
local / single-caller mode: Claude Code launches the process, and the caller
is whoever launched it. Registered with
AddProtocolAdapter<BifrostMcpAdapter>(). - Streamable HTTP + bearer — the MCP server is mounted on an HTTP route and
each session’s identity comes from the
Authorization: Bearerheader of the request that initiates it. This is the hosted / multi-caller mode. Registered withAddBifrostMcpHttp+MapBifrostMcp. The reference host (BifrostQL.Host) mounts it with one configuration flag:BifrostQL:Mcp:Http:Enabled = true(route override:BifrostQL:Mcp:Http:Path), with writes off. The reference host carries its own auth posture onto the route: with auth on (the shipped default) the route requires an authenticated bearer caller and answers401otherwise, and the validated principal of the request is what the MCP session runs as. WithBifrostQL:DisableAuth = true— which the host now sets only inappsettings.Development.json— the route is anonymous, declared asAnonymousDevso it logs a startup warning.
Both hosts build the identical tool surface from
BifrostMcpServerFactory.CreateServerOptions; they differ only in transport and
in how a session establishes identity.
Try it with a coding agent (OpenCode)
Section titled “Try it with a coding agent (OpenCode)”The repo ships an opencode.json that points OpenCode
at a locally running BifrostQL MCP server (and, as committed, at DeepSeek v4
Flash through OpenRouter — swap model for any provider OpenCode supports):
# 1. a database + the host with MCP enabled (any BifrostQL host works)sqlite3 blog.db < src/BifrostQL.UI/Schemas/blog.sqlsqlite3 blog.db < src/BifrostQL.UI/Schemas/blog-seed-sample.sqldotnet run --project src/BifrostQL.Host --no-launch-profile -- \ --urls http://127.0.0.1:5302 \ "--ConnectionStrings:bifrost=Data Source=blog.db" \ --BifrostQL:Provider=sqlite --BifrostQL:Mcp:Http:Enabled=true
# 2. an agent session over the database (key for your chosen model provider)export OPENROUTER_API_KEY=...opencode run "Using the bifrost MCP tools, how many posts exist per status?"A typical session chains bifrost_schema_overview →
bifrost_describe_table → bifrost_aggregate / bifrost_query — the agent
never writes SQL, and every call runs through the same tenant/policy pipeline
as GraphQL.
Setup: stdio (Claude Code / local)
Section titled “Setup: stdio (Claude Code / local)”Register the adapter alongside your BifrostQL endpoint, and register a
McpAuthOptions naming the identity posture. A stdio MCP session has no
per-request principal — the caller is whoever launched the process — and the
default FailClosed mode refuses every request, so a stdio server declares
either Bearer (credential read from the environment) or the explicit
AnonymousDev opt-in (empty user context; logs a startup warning):
builder.Services.AddSingleton(new McpAuthOptions { Mode = McpAuthMode.AnonymousDev });builder.Services.AddBifrostQL(o => o .AddProtocolAdapter<BifrostMcpAdapter>());Because the host process now speaks MCP JSON-RPC on stdout, nothing else in the process may write to stdout. Configure logging to stderr or a file.
To register it as an MCP server for Claude Code, point the client at the command
that starts your host — for example in .mcp.json:
{ "mcpServers": { "bifrost": { "command": "dotnet", "args": ["run", "--project", "src/YourApp.Host"] } }}The auth posture and the write opt-in are carried by McpAuthOptions, an
optional dependency the adapter resolves from DI. Absent, it defaults to
fail-closed with writes off — and fail-closed refuses every request, so a stdio
server must declare either a credential (Bearer) or anonymous access
(AnonymousDev). Register one to choose:
builder.Services.AddSingleton(new McpAuthOptions{ Mode = McpAuthMode.Bearer, // validate a token before minting identity CredentialSource = McpCredentialSources // read the token from a process env var .FromEnvironment("BIFROST_MCP_TOKEN"), ValidateBearerToken = token => MyJwt.Validate(token), // your handler → ClaimsPrincipal or null EnableWrites = false, // keep the write surface off (default)});Setup: HTTP + bearer (hosted)
Section titled “Setup: HTTP + bearer (hosted)”For a shared deployment, mount the MCP server on an HTTP route. Each session’s identity is resolved from the bearer token on the request that initiates the session, projected through the same shared identity factory:
var mcpAuth = new McpAuthOptions{ Mode = McpAuthMode.Bearer, ValidateBearerToken = token => MyJwt.Validate(token), // EnableWrites = true, // opt-in; off by default};
builder.Services.AddBifrostMcpHttp(mcpAuth, endpoint: "/graphql");
// ... later, on the app:app.MapBifrostMcp("/mcp"); // default route is /mcpOn the initialize request the bearer is extracted from the Authorization
header, the (async) credential exchange / validation is awaited on the ASP.NET
request path, and the resolved principal is projected once through the shared
factory and snapshotted for the session. Identity is then re-derived on every
subsequent request, so a revoked or expired token loses access at the next call.
An absent or invalid token mints no identity, and a request with no identity
is refused — on the stdio path too.
Config flags (McpAuthOptions)
Section titled “Config flags (McpAuthOptions)”| Flag | Default | Meaning |
|---|---|---|
Mode |
FailClosed |
FailClosed: no identity source, so no request can establish an identity and every request is refused (sanitized “Authentication failed”); silent. AnonymousDev: the only mode that serves a caller presenting no identity — empty context, tenant reads still fail closed in the pipeline, logs a deliberate-opt-in startup warning. Bearer: validate the presented token before minting identity; a valid token’s principal is handed to the factory, and an absent or invalid one is refused like FailClosed. |
EnableWrites |
false |
Master gate for the write tools (bifrost_insert, bifrost_update, bifrost_delete). Off by default: the write tools are never listed and build zero intent. Enabling it logs a startup warning. |
BearerToken |
null |
A single static token for the session (used only in Bearer mode when CredentialSource is unset). Null/empty presents no credential → fail closed. |
CredentialSource |
null |
Per-transport delegate that reads where the raw credential lives — build one with McpCredentialSources.FromEnvironment(...) (stdio) or .FromAuthorizationHeader(...) (HTTP). Returns null → fail closed. |
ValidateBearerToken |
null |
Host-supplied JWT handler: a token → ClaimsPrincipal (valid) or null (invalid). The adapter reads no claims itself; it hands the whole principal to the factory. |
CredentialStore |
null |
Optional OIDC / token-exchange store (IMcpCredentialStore). When set, the extracted upstream token is exchanged for a candidate principal instead of using ValidateBearerToken. A failed/unknown exchange resolves to null — never an ambient identity. Off unless configured. |
Security posture (as implemented)
Section titled “Security posture (as implemented)”Every tool inherits the protocol-adapter security guarantee — nothing is re-implemented on the MCP side:
-
Reads go through
IQueryIntentExecutor.bifrost_query,bifrost_row_context,bifrost_aggregate, andbifrost_searchcompile to programmatic intents; the transformer pipeline (tenant isolation, soft-delete hiding, policy row scope, column read guards) applies unconditionally. No code path here renders SQL or GraphQL text from model input. -
Writes go through
IMutationIntentExecutoronly. The write tools supply only the table, the caller’s column values, and the positional primary key. The fullTableMutationPipeline(tenant pinning, soft-delete rewrite, validation, field-encryption-on-write, audit, CDC/history hooks) decides every security-relevant outcome. The adapter builds no WHERE predicate and never special-cases soft-delete — a delete routes a Delete intent and the pipeline decides hard-vs-soft. -
The mutation surface defaults OFF. The three write tools are exposed only when a deployment sets
EnableWrites = trueand anIMutationIntentExecutoris available. When disabled they are never listed, so a disabled surface builds zero intent and cannot even be probed for behavior. There is no per-tool toggle:EnableWritesis the single master gate.Per-row scope is the pipeline’s, not an allow-list. The MCP layer holds no per-table allow-list. Write authorization and row scoping are the pipeline’s job: tenant scope is ANDed onto every write, so a row outside your scope matches nothing and affects zero rows, and any client-supplied tenant value on an insert is overridden. “Caller A cannot write caller B’s row” holds structurally, not because the adapter remembered to filter.
-
Identity is projected through
IBifrostAuthContextFactory— the same fail-closed seam the GraphQL, binary, pgwire, and RESP gates use. The adapter parses no claims of its own. A token from an OIDC issuer this deployment has no claim mapper for fails closed on projection; the MCP layer catches that and returns a sanitized tool error (“Authentication failed: the presented token could not be resolved to an identity.”), logging the specific issuer server-side only — never a degraded or anonymous context.
The tool surface
Section titled “The tool surface”The server exposes a fixed set of tools. The four read tools plus the two schema tools are always present; the three write tools appear only when writes are enabled. Every argument name below matches the tool’s input schema verbatim.
Schema tools
Section titled “Schema tools”| Tool | Arguments | Returns |
|---|---|---|
bifrost_schema_overview |
detail ("summary" | "full", default "summary") |
Curated map of the whole database: every table with primary key, foreign-key edges (both directions), and behavior notes. detail=full inlines condensed per-table column lists. Row counts and sample values are never included. |
bifrost_describe_table |
table (required) |
Column-level detail for one table: columns with types and nullability, primary key, foreign keys in both directions, and behavior notes. An unknown table name returns a prompt-style error with a nearest-name suggestion and the table list. |
Read tools
Section titled “Read tools”| Tool | Arguments | Returns |
|---|---|---|
bifrost_query |
table, filter, fields, sort, page ({ limit, cursor }), detail ("summary" | "full") |
Rows from one table with a structured filter, sort, field selection, and opaque-cursor pagination (default 25 rows/page; follow nextCursor). table is required unless page.cursor is set. |
bifrost_row_context |
table, id |
One row by primary key, plus each FK parent resolved to its key and display name, and each child collection summarized as a total count and its first rows. |
bifrost_aggregate |
table, groupBy, measures ([{ fn, column }], fn ∈ count/sum/avg/min/max), filter |
GROUP BY aggregation over one table. measures is required; column is required for sum/avg/min/max and omitted for count. The filter is applied before grouping. Returns at most 100 groups — the cap is the query’s limit, so the database never groups more than that — with truncated: true and a steering message when more groups match. |
bifrost_search |
term (min 2 chars), tables |
Case-insensitive substring search across the string columns of every table (or the supplied tables). Returns up to 5 ranked rows per table — id (usable as a bifrost_row_context id), display name, matched columns — plus per-table match totals. |
The filter argument (shared by bifrost_query and bifrost_aggregate) is a
structured {column: {_op: value}} object; sibling keys AND together, and
{"and":[...]} / {"or":[...]} form explicit groups. Operators: _eq, _neq,
_lt, _lte, _gt, _gte, _contains, _in, _between, _null (plus the
negated/pattern variants _ncontains, _starts_with, _ends_with, _like,
_nin, _nbetween). Values always bind as SQL parameters — the argument is a
data structure, never a SQL fragment.
Write tools (opt-in)
Section titled “Write tools (opt-in)”Present only when EnableWrites = true:
| Tool | Arguments | Returns |
|---|---|---|
bifrost_insert |
table, values (object of column values) |
Inserts one row through the mutation pipeline (tenant id pinned to your identity; validation, encryption-on-write, and audit hooks apply). Returns the generated identity. |
bifrost_update |
table, id, set (object of columns to change) |
Updates one row by primary key. Your tenant scope is ANDed on, so an out-of-scope row affects zero rows. Returns the number of rows affected. |
bifrost_delete |
table, id |
Deletes one row by primary key. On a soft-delete table the pipeline marks it deleted rather than removing it. Returns the number of rows affected. |
The id argument (shared by update, delete, and bifrost_row_context) is a
primary-key value: a scalar, an array in key-column order, or a "v1|v2"
delimited string for composite keys — never just the first key column. Arity and
column coercion are enforced downstream by the pipeline (composite-key safe).
The key’s arity decides how | is read, on every tool alike. Only a composite
key treats it as a separator; on a single-column key it is an ordinary character
of the value, so a row keyed "a|b" addresses that one row rather than splitting
into two key values. Pass the array form whenever a composite key’s own values
may contain |.
Schema resources
Section titled “Schema resources”The same schema payloads are also served as MCP resources:
bifrost://schema/overview— the full-detail schema map.bifrost://schema/{table}— one table’s description (URL-escaped table name).
Tool-design rationale
Section titled “Tool-design rationale”The tool surface is shaped by three deliberate decisions, made for the whole surface rather than tool by tool.
1. A chunky, fixed surface — not a generic query hole
Section titled “1. A chunky, fixed surface — not a generic query hole”Rather than exposing one “run this GraphQL/SQL” tool, the server ships a small
set of purpose-built tools, each of which compiles caller arguments to a
programmatic intent. This is what lets the transformer pipeline be
unskippable: because no tool accepts query text, there is nothing for a caller
to concatenate and no path that reaches SQL without the tenant, soft-delete, and
policy transformers having run. The fixed surface is also the security boundary —
bifrost_row_context, for instance, is implemented as one intent per
relationship rather than a hand-rolled join, so each sub-query independently
passes the pipeline, a documented simplicity choice over re-deriving join SQL for
a fixed access pattern.
2. Densified payloads — earn the agent’s context back
Section titled “2. Densified payloads — earn the agent’s context back”An agent pays for every token it reads, so the tools return dense, curated views
instead of raw dumps. bifrost_schema_overview is a single call that maps the
whole database — keys, relationship edges, behavior notes — with a
detail=summary/full dial so the agent inlines per-table columns only when it
needs them. bifrost_query’s summary detail returns the primary key, a display
column, and short text columns rather than every column. bifrost_row_context
bundles a row plus its entire parent/child neighborhood into one response.
bifrost_aggregate and bifrost_search cap and rank their output (top groups,
top matches per table) with a steering message and truncated: true when
results were cut short — so a high-cardinality result steers the agent to
narrow, instead of flooding its context. The aggregate cap is the query’s own
limit rather than a post-read truncation, so the cap bounds the database work
too, and the groups an agent sees are the same on every identical call.
3. Errors-as-prompts — every failure is actionable, none is a protocol fault
Section titled “3. Errors-as-prompts — every failure is actionable, none is a protocol fault”Argument mistakes and execution-layer rejections (a missing tenant context, a
policy-denied column, an unsupported filter shape, an out-of-range cursor) surface
as prompt-style tool errors — the tool returns isError with a message the
agent can act on, not a JSON-RPC protocol fault that tears down the session. An
unknown table or column name comes back with a nearest-name “did you mean”
suggestion and the list of valid names — the names this caller may read, never
the whole schema. A tampered or corrupted pagination cursor
collapses to one clear invalid-cursor prompt rather than silently clamping (which
would mask tampering).
Errors the adapter itself authors are forwarded verbatim, because their text is the answer the agent needs and it is built from the agent’s own arguments or from the schema it is already permitted to see. Errors raised inside the server are not: they can name a schema-qualified table, a tenant context-key, a policy-denied column, or raw driver text, and an agent needs none of that to recover. Those are answered with a stable code and the detail is logged server-side only:
| Condition | Wire code | What the agent should do |
|---|---|---|
| A table the caller may not READ, named to any read tool | none — the Unknown table '<name>' prompt |
Pick a table from the list the prompt gives; a denied table and a name that does not exist are deliberately indistinguishable. |
| Authorization refusal reaching the pipeline — a tenant-less identity, a policy-denied column, a denied write | access_denied |
Try a table it is permitted to read, or ask the user to supply the missing context. |
| Any other server-side execution failure | execution_error |
Not retryable as-is; the identical call will fail the same way. |
| Unmapped OIDC issuer | generic authentication error | Re-authenticate; the issuer name is never on the wire. |
The first row is the one condition answered before the request reaches the
server. Every read tool — bifrost_query, bifrost_row_context,
bifrost_aggregate, bifrost_search, and the schema tools — resolves the table
name against the caller’s readable schema, the projection the same policy
evaluator produces for the data path. A table it may not read is therefore
answered exactly like one that does not exist, down to the list of available
tables: a distinguishable refusal would confirm the table exists, and confirming
existence is the enumeration oracle this surface is built to deny. The same rule
governs column names, so a read-denied column is “unknown” and never appears in a
“did you mean” list. The write tools resolve against the full schema instead —
read visibility is not a write gate — so a write to a table the caller cannot
read still reaches the pipeline and is answered by its decision.
The code is stable and identical on every op class — tool calls, tools/list,
and the schema resources alike — so an agent can branch on it. The result is a
surface an agent can drive by trial and correction, where every recoverable
failure teaches it what to send next, and no failure teaches it the shape of a
database it was denied.
See also
Section titled “See also”- Protocol Adapters: One Pipeline, Many Front Doors — why every front door inherits these guarantees.
- Authoring a Protocol Adapter — the seam these tools are built on.
- Authentication — how identity and claim mapping work across transports.