· Go/Golang · DBMcp · Software Design

DBMcp: Refactoring to a Driver Interface

How I restructured DBMcp's multi-database support, replacing scattered if/else checks with a Go interface so adding a new driver only requires one new file.

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 ...
    }
}
AI Agentcall tool listTables()if dbType =="postgres" "mysql" rows checked one dbType at a time -- repeating across all tool files

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:

  1. Open listTables.go and add an else if dbType == "cockroachdb" branch
  2. Open describeTable.go and do the same
  3. 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.

AI Agentcall tool listTables()Driver.ListTablesrows direct interface call on the active session driver -- zero branching

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.