mcp

package
v0.24.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: AGPL-3.0 Imports: 30 Imported by: 0

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

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

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

View Source
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

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

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

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

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

View Source
var ErrSQLRequired = errors.New("sql is required")

ErrSQLRequired is returned for an empty statement.

View Source
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

func SupportedProtocol(protocol string) bool

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.

func WithCaller

func WithCaller(ctx context.Context, c *Caller) context.Context

WithCaller attaches the authenticated caller to a request context. The HTTP layer does this after the ordinary API-key middleware has run.

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

type Caller struct {
	User   *store.User
	APIKey string
}

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

func CallerFrom(ctx context.Context) *Caller

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.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is dbbat's MCP layer.

func New

func New(deps Deps) *Server

New builds the MCP layer.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler is the Streamable-HTTP MCP endpoint.

type TableInfo

type TableInfo struct {
	Schema string `json:"schema,omitempty"`
	Name   string `json:"name"`
}

TableInfo is one table in a describe listing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL