Documentation
¶
Overview ¶
Package mcp exposes dbbat's databases to AI agents over the Model Context Protocol, without adding a second way into a database.
The one design decision that matters ¶
Every statement an agent runs is executed by **dialing dbbat's own proxy listener over loopback** with a real protocol client (pgx, go-mysql, go-ora, go-mssqldb, mongo-driver), authenticating as the API key's owner with that same key as the password — which is exactly how any `dbb_` key holder connects with psql or the mysql CLI.
All five protocols dbbat proxies are covered. Each one is a file next to this doc plus a case in LoopbackExecutor.Execute; none of them adds enforcement, because there is nothing here to enforce with.
There is deliberately no internal execution path. The consequence is that authentication, grant resolution, `read_only`/`block_ddl`/`block_copy`, quotas, query logging, session capture and the mid-flight approval gate are not re-implemented here — they are the *same code*, reached over the same wire, and this package cannot drift from them because it never touches them.
A bypass path is precisely the bug class fixed in #306/#308 (statements skipping the approval gate). Do not add one: if you find yourself reaching for *store.Store or a driver connected straight to a customer database in order to run an agent's SQL, the design has been broken.
The store is read here for exactly one thing — listing the grants the caller holds, which is metadata the caller can already read from GET /api/v1/grants — and never to execute or authorize a statement.
Transport and identity ¶
The endpoint is Streamable HTTP, served on the existing gin router under /api/v1/mcp, and it runs **stateless**: each HTTP request carries the `Authorization: Bearer dbb_…` header, is authenticated by the ordinary API middleware, and gets a freshly built github.com/modelcontextprotocol/go-sdk/mcp.Server whose tool closures are bound to that caller. Nothing about a caller survives a request, so a revoked key stops working on the next call rather than at the end of some session.
Approval holds ¶
A held statement blocks the wire connection. That is fine for psql and awkward for an MCP request, so `query` waits a short grace window and then hands the agent a structured `approval_pending` (or `still_running`) result naming an execution id, while the loopback connection stays parked in the background. The `await_approval` tool long-polls that execution. The agent therefore never silently times out on a held query: every return is a status that names the next action.
Index ¶
- Constants
- Variables
- func SupportedProtocol(protocol string) bool
- func WithCaller(ctx context.Context, c *Caller) context.Context
- type AwaitApprovalInput
- type Caller
- type ColumnInfo
- type DatabaseInfo
- type Deps
- type DescribeInput
- type DescribeOutput
- type ExecRequest
- type Executor
- type GrantStore
- type ListDatabasesInput
- type ListDatabasesOutput
- type LoopbackExecutor
- type LoopbackListeners
- type QueryInput
- type QueryOutput
- type QueryResult
- type Server
- type TableInfo
Constants ¶
const ( // DefaultGraceWindow is how long `query` waits before handing the agent an // execution id instead of rows. The spec's "~10s": long enough that an // ordinary statement never pays the extra round-trip, short enough that a // held one does not look like a hang. DefaultGraceWindow = 10 * time.Second // DefaultAwaitWindow is how long `await_approval` blocks per call before // answering "still pending". It must stay comfortably under any // intermediary's idle timeout: an answered poll is cheap, a dropped // request is the silent timeout this whole mechanism exists to avoid. DefaultAwaitWindow = 55 * time.Second // MaxAwaitWindow bounds what an agent may ask for in one await call. MaxAwaitWindow = 120 * time.Second // ExecutionMaxLifetime is the hard bound on a backgrounded execution. A // hold has no timeout by design (docs/approvals.md), but an MCP execution // nobody ever comes back for must not park an upstream connection // forever; when it fires, the loopback socket closes and the hold ends as // `abandoned` — the ordinary "the client gave up" outcome. ExecutionMaxLifetime = 30 * time.Minute )
Execution lifetime bounds.
const ( // DefaultMaxRows is what `query` returns when the agent says nothing. DefaultMaxRows = 200 // HardMaxRows is the server-side ceiling. An agent asking for a million // rows gets HardMaxRows and a truncated flag — a model's idea of a // reasonable page size is not a server limit. HardMaxRows = 1000 )
Row cap. The agent may ask for fewer; it may never ask for more.
const ( // StatusOK means the statement ran and the rows (if any) are here. StatusOK = "ok" // StatusApprovalPending means the statement is parked on a human. The // result carries the execution id to poll and the query uid the approver // is looking at. StatusApprovalPending = "approval_pending" // StatusStillRunning means the statement has not returned yet and no hold // was observed — a slow query, not a gated one. Same next action. StatusStillRunning = "still_running" )
Tool result statuses. Every non-final status names the next action, which is the whole contract with the agent: a held query must never look like a hang or a failure.
Variables ¶
var ( // ErrProtocolUnsupported means the database speaks a protocol the MCP // server cannot drive a loopback client for. All five database protocols // dbbat proxies are covered; an SSH-only entry is not a database and never // will be. ErrProtocolUnsupported = errors.New("protocol not supported by the MCP server yet") // ErrListenerDisabled means the proxy listener for that protocol is not // running in this process, so there is nothing to dial. The MCP server // refuses rather than reaching around the proxy. ErrListenerDisabled = errors.New("the proxy listener for this protocol is disabled on this instance") )
Execution errors surfaced to the agent as tool errors.
var ( // ErrMongoCommandSyntax means the text is not `<command> <extended JSON>`. ErrMongoCommandSyntax = errors.New( `a MongoDB statement is "<command> <extended-JSON document>", e.g. find {"find":"users","limit":10}`) // ErrMongoCommandEmpty means the document carried no command. ErrMongoCommandEmpty = errors.New("the MongoDB command document is empty") // ErrMongoCommandMismatch means the leading word is not the document's // first key, which is what MongoDB uses to name the command. ErrMongoCommandMismatch = errors.New( "the command name must be the first key of the command document") // ErrMongoParamsUnsupported means the caller sent bind parameters. MongoDB // has none, and silently dropping them would run a command that is not the // one the agent thinks it wrote. ErrMongoParamsUnsupported = errors.New( "MongoDB has no bind parameters: put the values in the command document") )
Errors an agent gets back for a MongoDB "statement" it wrote wrong. They are deliberately explicit: a model that guessed the shape has to be able to fix it from the message alone.
var ErrExecutionPanicked = errors.New("the statement's execution panicked")
ErrExecutionPanicked is the outcome an execution reports when the goroutine running its statement panicked. The agent sees a failed statement, which is what any other failure would have looked like; before this, the panic took the whole process down instead.
var ErrNoGrant = errors.New("no active grant on that database")
ErrNoGrant means the caller holds no active grant on the named database. It deliberately does not distinguish "no such database" from "no grant on it": the MCP surface must not be a way to enumerate databases a caller cannot reach.
var ErrSQLRequired = errors.New("sql is required")
ErrSQLRequired is returned for an empty statement.
var ErrUnknownExecution = errors.New("unknown execution id")
ErrUnknownExecution means the execution id is not (or no longer) on this replica. Returned rather than guessed at: an agent must be told to look the query up rather than be handed a wrong answer.
Functions ¶
func SupportedProtocol ¶
SupportedProtocol reports whether the MCP server can execute statements against a database speaking this protocol.
The dispatch is one switch on purpose: adding a protocol means adding a case here and a loopback client next to the ones that exist, and nothing else — no new enforcement, no new auth path.
Types ¶
type AwaitApprovalInput ¶
type AwaitApprovalInput struct {
ExecutionID string `json:"execution_id" jsonschema:"the execution_id returned by query or describe when the status was not ok"`
TimeoutSeconds int `` /* 173-byte string literal not displayed */
}
AwaitApprovalInput identifies the execution to wait on.
type Caller ¶
Caller is the authenticated identity behind one MCP request: the API key's owner, and the key itself, which doubles as the database password.
func CallerFrom ¶
CallerFrom reads the caller back. Absent means the request did not come through the authenticated route, which is a programming error, not a recoverable state.
type ColumnInfo ¶
type ColumnInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Nullable bool `json:"nullable"`
Default string `json:"default,omitempty"`
}
ColumnInfo is one column of a described table.
type DatabaseInfo ¶
type DatabaseInfo struct {
Name string `json:"name" jsonschema:"the name to pass to query and describe"`
Description string `json:"description,omitempty"`
Protocol string `json:"protocol"`
Supported bool `json:"supported" jsonschema:"false when this dbbat version cannot run statements against this protocol yet"`
GrantExpiresAt string `json:"grant_expires_at" jsonschema:"RFC3339 instant after which the grant stops working mid-session"`
ReadOnly bool `json:"read_only"`
BlockDDL bool `json:"block_ddl"`
BlockCopy bool `json:"block_copy"`
ApprovalPatterns int `` /* 134-byte string literal not displayed */
MaxQueries *int64 `json:"max_queries,omitempty"`
QueriesUsed int64 `json:"queries_used"`
MaxBytes *int64 `json:"max_bytes_transferred,omitempty"`
BytesUsed int64 `json:"bytes_transferred"`
}
DatabaseInfo is one database the caller can reach, with everything an agent needs to plan: when access ends, what it may not do, and how much of its quota is left.
type Deps ¶
type Deps struct {
Store GrantStore
Logger *slog.Logger
// Broker resolves the shared event broker at call time. It is a function
// because the process wiring installs the shared broker on the API server
// *after* construction (Server.SetEventPlumbing), and the MCP layer must
// see that instance rather than the placeholder it was built with.
Broker func() *events.Broker
// Executor runs statements through the loopback proxy listeners.
Executor Executor
// GraceWindow / AwaitWindow override the defaults; zero means default.
// Test seams, and the knobs an operator would want first if the shape
// ever needs tuning.
GraceWindow time.Duration
AwaitWindow time.Duration
}
Deps are the collaborators the MCP layer needs.
type DescribeInput ¶
type DescribeInput struct {
Database string `json:"database" jsonschema:"database name, as returned by list_databases"`
Table string `json:"table,omitempty" jsonschema:"table (MongoDB: collection) to describe. Omit to list the database's tables instead"`
Schema string `json:"schema,omitempty" jsonschema:"PostgreSQL schema to disambiguate a table name present in several schemas"`
}
DescribeInput selects what to introspect.
type DescribeOutput ¶
type DescribeOutput struct {
Status string `json:"status"`
Database string `json:"database"`
Protocol string `json:"protocol"`
Tables []TableInfo `json:"tables,omitempty"`
Table string `json:"table,omitempty"`
Columns []ColumnInfo `json:"columns,omitempty"`
Truncated bool `json:"truncated" jsonschema:"true when the database has more tables or columns than one call returns"`
ExecutionID string `json:"execution_id,omitempty"`
QueryUID string `json:"query_uid,omitempty"`
Message string `json:"message,omitempty"`
}
DescribeOutput is the describe result. It carries the same status/execution fields as QueryOutput because introspection runs through the governed path too, and a grant whose approval patterns match `SELECT` will hold it.
type ExecRequest ¶
type ExecRequest struct {
// Protocol is the target's wire protocol; it selects the loopback client.
Protocol string
// Database is the **dbbat server name**, which is what every proxy resolves
// the target from (PostgreSQL's startup `database` parameter, MySQL's
// schema name, Oracle's SERVICE_NAME, SQL Server's LOGIN7 database and
// MongoDB's authSource are all looked up with GetServerByName).
Database string
// UpstreamDatabase is the database name *on the target server* — the
// `database_name` column of the dbbat row.
//
// Only the MongoDB client needs it, and only because a MongoDB command
// carries its own `$db` inside the message, which the proxy forwards
// verbatim: a command addressed to the dbbat entry's name would reach the
// upstream naming a database that does not exist there. Every other
// protocol carries the database once, at login, where the dbbat name is the
// right one.
UpstreamDatabase string
// Username is the API key owner's username. The proxies refuse a key
// whose owner does not match the username on the wire.
Username string
// APIKey is the caller's `dbb_` key, used verbatim as the connection
// password — the documented way a key holder authenticates to a proxy.
APIKey string
// SQL is the statement, passed through untouched. Rewriting it would
// falsify what /queries records and what approval patterns match.
SQL string
// Params are bind parameters. Non-empty forces the prepared-statement
// path on both protocols, which is also what an operator wants for
// untrusted values.
Params []any
// MaxRows caps the rows returned to the agent. Already clamped by the
// caller; the executor treats it as authoritative.
MaxRows int
}
ExecRequest is one statement to run through the loopback proxy.
type Executor ¶
type Executor interface {
Execute(ctx context.Context, req ExecRequest) (*QueryResult, error)
}
Executor runs one statement through a dbbat proxy listener.
It is an interface for exactly one reason: tests need to drive the approval-pending / await_approval machinery without standing up a proxy and an upstream database. It is **not** an extension point for a second execution strategy — see the package doc.
type GrantStore ¶
type GrantStore interface {
ListGrants(ctx context.Context, filter store.GrantFilter) ([]store.Grant, error)
GetServerByUID(ctx context.Context, uid uuid.UUID) (*store.Server, error)
// ListServerGroupMemberUIDs expands a group-bound grant into the servers
// it currently covers — a grant no longer names a single database.
ListServerGroupMemberUIDs(ctx context.Context, groupUID uuid.UUID) ([]uuid.UUID, error)
}
GrantStore is the slice of the store this package reads. Narrow on purpose: the MCP server reads grant metadata (which the caller can already fetch from GET /api/v1/grants) and nothing else. It never reads or writes a query row, and it never resolves credentials — the proxy does all of that, over the wire, exactly as it does for psql.
type ListDatabasesInput ¶
type ListDatabasesInput struct{}
ListDatabasesInput takes no arguments: the caller's grants are the scope.
type ListDatabasesOutput ¶
type ListDatabasesOutput struct {
Databases []DatabaseInfo `json:"databases"`
}
ListDatabasesOutput is the tool result.
type LoopbackExecutor ¶
type LoopbackExecutor struct {
// contains filtered or unexported fields
}
LoopbackExecutor dials this process's own proxy listeners.
func NewLoopbackExecutor ¶
func NewLoopbackExecutor(listeners LoopbackListeners) *LoopbackExecutor
NewLoopbackExecutor builds the real executor from the proxy listen addresses.
func (*LoopbackExecutor) Execute ¶
func (e *LoopbackExecutor) Execute(ctx context.Context, req ExecRequest) (*QueryResult, error)
Execute dispatches to the loopback client for the request's protocol.
type LoopbackListeners ¶
type LoopbackListeners struct {
PostgreSQL string
MySQL string
Oracle string
MongoDB string
MSSQL string
}
LoopbackListeners are this process's proxy listen addresses, one per protocol (config.Config.ListenPG, ListenMySQL, ListenOracle, ListenMongo, ListenMSSQL). An empty address means the listener is not running here, and the executor refuses rather than finding another way to the database.
type QueryInput ¶
type QueryInput struct {
Database string `json:"database" jsonschema:"database name, as returned by list_databases"`
SQL string `` /* 155-byte string literal not displayed */
Params []any `` /* 187-byte string literal not displayed */
MaxRows int `` /* 164-byte string literal not displayed */
}
QueryInput is the `query` tool's arguments.
type QueryOutput ¶
type QueryOutput struct {
Status string `` /* 151-byte string literal not displayed */
Database string `json:"database"`
Columns []string `json:"columns,omitempty"`
Rows []map[string]any `json:"rows,omitempty" jsonschema:"result rows keyed by column name, in result order"`
RowCount int `json:"row_count"`
Truncated bool `json:"truncated" jsonschema:"true when the statement produced more rows than max_rows"`
MaxRows int `json:"max_rows" jsonschema:"the cap actually applied, after server-side clamping"`
RowsAffected *int64 `json:"rows_affected,omitempty"`
DurationMs int64 `json:"duration_ms"`
ExecutionID string `json:"execution_id,omitempty" jsonschema:"pass this to await_approval while the status is not ok"`
QueryUID string `json:"query_uid,omitempty" jsonschema:"the dbbat query a human is being asked to approve, as shown in the dbbat UI and Slack"`
ApprovalPattern string `json:"approval_pattern,omitempty" jsonschema:"the grant pattern that matched and caused the hold"`
Message string `json:"message,omitempty"`
}
QueryOutput is the `query` (and `await_approval`) result.
type QueryResult ¶
type QueryResult struct {
// Columns are the result columns in wire order, de-duplicated so Rows can
// key by name.
Columns []string
// Rows are the (capped) result rows as column-name → value maps.
Rows []map[string]any
// Truncated reports that the statement produced more rows than MaxRows.
Truncated bool
// RowsAffected is set for statements that report it (INSERT/UPDATE/…).
RowsAffected *int64
}
QueryResult is one statement's outcome, protocol-independent.