I’ve been building DBMcp, an MCP server that lets AI assistants introspect databases: list tables, describe schemas, analyze foreign keys, and so on. It started with PostgreSQL, then got MySQL, then SQLite.
By the third database, the codebase had a problem I couldn’t ignore.
The old way
Every tool function (and there were around 20 of them) had the same shape. list_tables looked like this:
func listTablesHandler(ctx context.Context, dbType string, conn *sql.DB, schema string) ([]TableInfo, error) {
if dbType == "postgres" {
rows, err := conn.QueryContext(ctx, `
SELECT table_name, table_schema, table_type
FROM information_schema.tables
WHERE table_schema = $1
ORDER BY table_name`, schema)
// ... scan rows ...
} else if dbType == "mysql" {
rows, err := conn.QueryContext(ctx, `
SELECT TABLE_NAME, TABLE_SCHEMA, TABLE_TYPE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME`, schema)
// ... scan rows ...
} else if dbType == "sqlite" {
rows, err := conn.QueryContext(ctx, `
SELECT name, 'main', type
FROM sqlite_master
WHERE type IN ('table', 'view')
ORDER BY name`)
// ... scan rows ...
}
} describe_table had the same shape, and so did list_views, list_foreign_keys, and every other tool: three branches, copied again and again.
The session state carried a plain string to track which database was active:
type DBSessionState struct {
Conn *sql.DB
DBType string // "postgres", "mysql", or "sqlite"
CurrentSchema string
} This worked fine, until I thought about adding a fourth database.
Why this hurts
Say you want to add CockroachDB support. You’d have to:
- Open
listTables.goand add anelse if dbType == "cockroachdb"branch - Open
describeTable.goand do the same - Open
analyzeTable.go,listViews.go,foreignKeys.go,triggers.go,functions.go,constraints.go,columnSearch.go,sequences.go,getEnumValues.go, and add the branch in each one
That’s 20 files to edit for one new database, and every edit is a chance to introduce a bug. Each file is also tied to a list of databases it has to know about. The tools should only care about what they do, but they’re full of how each database does it.
This violates the open/closed principle: you can’t extend the system without modifying it.
The interface approach
The fix is to define a Driver interface that owns all the database-specific queries:
type Driver interface {
GetDbInfo(ctx context.Context, conn *sql.DB) (DbInfo, error)
ListTables(ctx context.Context, conn *sql.DB, schema string) ([]TableRow, error)
DescribeTable(ctx context.Context, conn *sql.DB, table, schema string) (DescribeTableResult, error)
ListViews(ctx context.Context, conn *sql.DB, schema string) ([]ViewRow, error)
ListForeignKeys(ctx context.Context, conn *sql.DB, table, schema string) ([]ForeignKeyRow, error)
ListTriggers(ctx context.Context, conn *sql.DB, table, schema string) ([]TriggerRow, error)
ListFunctions(ctx context.Context, conn *sql.DB, schema string, filterBySchema bool) ([]FunctionRow, error)
ListConstraints(ctx context.Context, conn *sql.DB, table, schema string) ([]ConstraintRow, error)
FindColumns(ctx context.Context, conn *sql.DB, column, table, schema string, exactMatch bool) ([]ColumnMatch, error)
ListSchemas(ctx context.Context, conn *sql.DB) ([]SchemaRow, error)
// ... and more
} Each database gets one file that implements this interface. The session state now holds a Driver instead of a string:
type DBSessionState struct {
Conn *sql.DB
Driver driver.Driver
} And every tool becomes a straight delegation:
func listTablesHandler(...) {
session, _ := state.GetActiveSession("default")
rows, err := session.Driver.ListTables(ctx, session.Conn, input.Schema)
// map rows to output, done
} There are no branches or string comparisons left. The tool doesn’t know or care whether it’s talking to Postgres or SQLite.
Return types without json tags
The driver interface returns plain structs, with no json or jsonschema tags.
// driver package: raw data, no presentation concerns
type TableRow struct {
Name string
Schema string
Type string
} The tools package has its own output types with the MCP-facing annotations:
// tools package: presentation layer
type TableInfo struct {
Name string `json:"name" jsonschema_description:"Table name"`
Schema string `json:"sch" jsonschema_description:"Schema name"`
Type string `json:"type,omitempty" jsonschema_description:"Table type"`
} Each tool handler copies one into the other, field by field. That keeps presentation out of the driver layer, so the MCP output format can change without touching the driver queries.
Capability flags
Not every database supports every feature. PostgreSQL has enums, sequences, and materialized views. MySQL doesn’t. SQLite has none of the above.
The old approach handled this with if dbType == "postgres" in the tool registration code: same string-matching problem. The new approach adds capability flags to the interface:
type Driver interface {
// ...query methods...
SupportsEnums() bool
SupportsSequences() bool
SupportsMaterializedViews() bool
SupportsFunctions() bool
SupportsShowCommands() bool
} Each driver declares what it supports:
// postgres
func (d *PostgresDriver) SupportsEnums() bool { return true }
func (d *PostgresDriver) SupportsSequences() bool { return true }
func (d *PostgresDriver) SupportsMaterializedViews() bool { return true }
// sqlite
func (d *SqliteDriver) SupportsEnums() bool { return false }
func (d *SqliteDriver) SupportsSequences() bool { return false }
func (d *SqliteDriver) SupportsMaterializedViews() bool { return false } Tool registration becomes readable:
func RegisterTools(s *mcp.Server, drv driver.Driver) {
GetListTablesTool().Register(s)
GetListSchemasTool().Register(s)
if drv.SupportsEnums() {
GetListEnumsTool().Register(s)
GetEnumValuesTool().Register(s)
}
if drv.SupportsMaterializedViews() {
GetListMaterializedViewsTool().Register(s)
}
} The server registers only the tools the connected database can use.
Adding a new driver
Adding CockroachDB support now means creating one file:
// internal/driver/cockroachdb.go
package driver
type CockroachDBDriver struct{}
func (d *CockroachDBDriver) SupportsEnums() bool { return true }
func (d *CockroachDBDriver) SupportsSequences() bool { return true }
func (d *CockroachDBDriver) SupportsMaterializedViews() bool { return false }
func (d *CockroachDBDriver) SupportsFunctions() bool { return true }
func (d *CockroachDBDriver) SupportsShowCommands() bool { return false }
func (d *CockroachDBDriver) ListTables(ctx context.Context, conn *sql.DB, schema string) ([]TableRow, error) {
// CockroachDB is wire-compatible with PostgreSQL, so this is often identical
// ...
}
// implement the rest of the interface... Then wire it in the connection switch:
case "cockroachdb":
drv = &driver.CockroachDBDriver{} The tool files don’t change. The 20 tool handlers work without knowing CockroachDB exists, and the compiler tells you right away if you’ve missed an interface method.
Cleaning up hidden state
One more improvement came from rethinking CurrentSchema. The old code stored the current schema in session state and silently applied it as a fallback whenever a tool was called without an explicit schema argument:
schema := input.Schema
if schema == "" {
schema = session.CurrentSchema // hidden default
} So the same tool call could return different results depending on which schema was current when the connection was set up, and nothing in the call told you that.
The new approach removes CurrentSchema entirely. Instead, a list_schemas tool gives callers an explicit way to discover what schemas are available. When a schema is required and none is provided, the driver handles the default internally: for PostgreSQL that’s public, for SQLite the schema parameter is irrelevant and ignored.
Now you pass the schema on purpose instead of inheriting it from connection setup.
The result
The driver package now looks like this:
internal/driver/
driver.go <- interface + shared return types
postgres.go <- PostgresDriver (one file)
mysql.go <- MysqlDriver (one file)
sqlite.go <- SqliteDriver (one file)
helpers.go <- shared parsing utilities Each file stands on its own, and adding a driver means adding a file instead of editing the ones already there. The tool files lost thousands of lines of duplicated query logic and are now simple delegations that are easy to read and test.
None of this is new. It’s the standard Go interface pattern, applied consistently.