Skip to content
Docs

Database MCP Server for AI Agents

A coding agent working the database through MCP — OpenCode (DeepSeek v4 Flash via OpenRouter) answers a data question using only the bifrost tools — schema overview, server-side aggregate, filtered query — and the recording closes by asking sqlite the same question: the answers match.
The wire itself: a raw MCP session — Initialize, tools/list, and real bifrost_query / bifrost_aggregate / bifrost_search calls over the Streamable HTTP transport — every response is the running server's answer.

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: Bearer header of the request that initiates it. This is the hosted / multi-caller mode. Registered with AddBifrostMcpHttp + 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 answers 401 otherwise, and the validated principal of the request is what the MCP session runs as. With BifrostQL:DisableAuth = true — which the host now sets only in appsettings.Development.json — the route is anonymous, declared as AnonymousDev so 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.

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):

Terminal window
# 1. a database + the host with MCP enabled (any BifrostQL host works)
sqlite3 blog.db < src/BifrostQL.UI/Schemas/blog.sql
sqlite3 blog.db < src/BifrostQL.UI/Schemas/blog-seed-sample.sql
dotnet 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_overviewbifrost_describe_tablebifrost_aggregate / bifrost_query — the agent never writes SQL, and every call runs through the same tenant/policy pipeline as GraphQL.

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)
});

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 /mcp

On 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.

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.

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, and bifrost_search compile 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 IMutationIntentExecutor only. The write tools supply only the table, the caller’s column values, and the positional primary key. The full TableMutationPipeline (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 = true and an IMutationIntentExecutor is 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: EnableWrites is 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 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.

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.
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.

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 |.

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).

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.