Ctrl K
ComunidadGuías…

internal/mcpgateway/store_sql.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/enterpilot/gomodel/internal/storage/sqlutil"
	"github.com/enterpilot/gomodel/internal/storage/sqlx"
)

// SQLStore stores managed MCP servers in a SQL database.
type SQLStore struct {
	db sqlx.DB
}

var sqlTable = `CREATE TABLE IF NOT EXISTS mcp_servers (
	name TEXT PRIMARY KEY,
	display_name TEXT NOT NULL DEFAULT '',
	url TEXT NOT NULL DEFAULT '',
	transport TEXT NOT NULL DEFAULT 'http',
	headers TEXT NOT NULL DEFAULT '{}',
	description TEXT NOT NULL DEFAULT '',
	enabled ` + sqlx.TypeBool + ` NOT NULL DEFAULT TRUE,
	allowed_tools TEXT NOT NULL DEFAULT '[]',
	disallowed_tools TEXT NOT NULL DEFAULT '[]',
	user_paths TEXT NOT NULL DEFAULT '[]',
	disallowed_user_paths TEXT NOT NULL DEFAULT '[]',
	tool_timeout_seconds INTEGER NOT NULL DEFAULT 0,
	created_at ` + sqlx.TypeInt64 + ` NOT NULL,
	updated_at ` + sqlx.TypeInt64 + ` NOT NULL
)`

var sqlIndexes = []string{
	`CREATE INDEX IF NOT EXISTS idx_mcp_servers_enabled ON mcp_servers(enabled)`,
	`CREATE INDEX IF NOT EXISTS idx_mcp_servers_updated_at ON mcp_servers(updated_at DESC)`,
}

// sqlMigrations backfill columns added after the table's first release.
var sqlMigrations = []string{
	`ALTER TABLE mcp_servers ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE mcp_servers ADD COLUMN disallowed_user_paths TEXT NOT NULL DEFAULT '[]'`,
}

const selectMCPServerColumns = `name, display_name, url, transport, headers, description, enabled, ` +
	`allowed_tools, disallowed_tools, user_paths, disallowed_user_paths, tool_timeout_seconds, created_at, updated_at`

// NewSQLStore creates the mcp_servers table and indexes if needed.
func NewSQLStore(ctx context.Context, db sqlx.DB) (*SQLStore, error) {
	if db == nil {
		return nil, fmt.Errorf("database connection is required")
	}
	if err := db.Schema(ctx, sqlTable); err != nil {
		return nil, fmt.Errorf("failed to create mcp_servers table: %w", err)
	}
	if err := sqlx.AddColumns(ctx, db, sqlMigrations...); err != nil {
		return nil, err
	}
	// Rows predating display_name show the server name in the dashboard rather
	// than an empty label.
	if _, err := db.Exec(ctx, `UPDATE mcp_servers SET display_name = name WHERE display_name = ''`); err != nil {
		return nil, fmt.Errorf("backfill mcp_servers display_name: %w", err)
	}
	if err := db.Schema(ctx, sqlIndexes...); err != nil {
		return nil, fmt.Errorf("failed to create mcp_servers index: %w", err)
	}
	if err := db.Schema(ctx, sqlVirtualTable); err != nil {
		return nil, fmt.Errorf("failed to create mcp_virtual_servers table: %w", err)
	}
	if err := sqlx.AddColumns(ctx, db, sqlVirtualMigrations...); err != nil {
		return nil, err
	}
	if err := db.Schema(ctx, sqlOAuthTable); err != nil {
		return nil, fmt.Errorf("failed to create mcp_server_oauth table: %w", err)
	}
	return &SQLStore{db: db}, nil
}

func (s *SQLStore) List(ctx context.Context) ([]ManagedServer, error) {
	rows, err := s.db.Query(ctx, `
		SELECT `+selectMCPServerColumns+`
		FROM mcp_servers
		ORDER BY name ASC
	`)
	if err != nil {
		return nil, fmt.Errorf("list mcp servers: %w", err)
	}
	defer rows.Close()
	return collectManagedServers(func() (ManagedServer, bool, error) {
		if !rows.Next() {
			return ManagedServer{}, false, nil
		}
		server, err := scanSQLMCPServer(rows)
		return server, true, err
	}, rows.Err)
}

func (s *SQLStore) Get(ctx context.Context, name string) (*ManagedServer, error) {
	row := s.db.QueryRow(ctx, `
		SELECT `+selectMCPServerColumns+`
		FROM mcp_servers
		WHERE name = ?
	`, strings.TrimSpace(name))
	server, err := scanSQLMCPServer(row)
	if err != nil {
		if errors.Is(err, sqlx.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	return &server, nil
}

// sqlMCPServerJSON holds one row's JSON-encoded collection columns.
type sqlMCPServerJSON struct {
	headers, allowed, disallowed, paths, disallowedPaths string
}

func encodeSQLMCPServerJSON(server ManagedServer) (sqlMCPServerJSON, error) {
	var encoded sqlMCPServerJSON
	var err error
	if encoded.headers, err = encodeJSONMap(server.Headers); err != nil {
		return encoded, err
	}
	encoded.allowed = sqlutil.JSONStrings(server.AllowedTools)
	encoded.disallowed = sqlutil.JSONStrings(server.DisallowedTools)
	encoded.paths = sqlutil.JSONStrings(server.UserPaths)
	encoded.disallowedPaths = sqlutil.JSONStrings(server.DisallowedUserPaths)
	return encoded, nil
}

func (s *SQLStore) Upsert(ctx context.Context, server ManagedServer) error {
	stampUpsert(&server)
	encoded, err := encodeSQLMCPServerJSON(server)
	if err != nil {
		return err
	}
	_, err = s.db.Exec(ctx, `
		INSERT INTO mcp_servers (
			name, display_name, url, transport, headers, description, enabled,
			allowed_tools, disallowed_tools, user_paths, disallowed_user_paths, tool_timeout_seconds, created_at, updated_at
		)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON CONFLICT(name) DO UPDATE SET
			display_name = excluded.display_name,
			url = excluded.url,
			transport = excluded.transport,
			headers = excluded.headers,
			description = excluded.description,
			enabled = excluded.enabled,
			allowed_tools = excluded.allowed_tools,
			disallowed_tools = excluded.disallowed_tools,
			user_paths = excluded.user_paths,
			disallowed_user_paths = excluded.disallowed_user_paths,
			tool_timeout_seconds = excluded.tool_timeout_seconds,
			updated_at = excluded.updated_at
	`,
		strings.TrimSpace(server.Name),
		server.DisplayName,
		server.URL,
		server.Transport,
		encoded.headers,
		server.Description,
		server.Enabled,
		encoded.allowed,
		encoded.disallowed,
		encoded.paths,
		encoded.disallowedPaths,
		server.ToolTimeoutSeconds,
		server.CreatedAt.Unix(),
		server.UpdatedAt.Unix(),
	)
	if err != nil {
		return fmt.Errorf("upsert mcp server: %w", err)
	}
	return nil
}

func (s *SQLStore) Update(ctx context.Context, server ManagedServer) error {
	stampUpsert(&server)
	encoded, err := encodeSQLMCPServerJSON(server)
	if err != nil {
		return err
	}
	affected, err := s.db.Exec(ctx, `
		UPDATE mcp_servers SET
			display_name = ?, url = ?, transport = ?, headers = ?, description = ?, enabled = ?,
			allowed_tools = ?, disallowed_tools = ?, user_paths = ?, disallowed_user_paths = ?,
			tool_timeout_seconds = ?, updated_at = ?
		WHERE name = ?
	`,
		server.DisplayName,
		server.URL,
		server.Transport,
		encoded.headers,
		server.Description,
		server.Enabled,
		encoded.allowed,
		encoded.disallowed,
		encoded.paths,
		encoded.disallowedPaths,
		server.ToolTimeoutSeconds,
		server.UpdatedAt.Unix(),
		strings.TrimSpace(server.Name),
	)
	if err != nil {
		return fmt.Errorf("update mcp server: %w", err)
	}
	if affected == 0 {
		return ErrNotFound
	}
	return nil
}

func (s *SQLStore) Delete(ctx context.Context, name string) error {
	affected, err := s.db.Exec(ctx, `DELETE FROM mcp_servers WHERE name = ?`, strings.TrimSpace(name))
	if err != nil {
		return fmt.Errorf("delete mcp server: %w", err)
	}
	if affected == 0 {
		return ErrNotFound
	}
	return nil
}

// swapHeaders replaces a server's headers only while they still hold the
// values in current, and reports whether it did. Other columns are left
// alone, so a concurrent edit to them is never lost either.
func (s *SQLStore) swapHeaders(ctx context.Context, current, next ManagedServer) (bool, error) {
	currentJSON, err := encodeJSONMap(current.Headers)
	if err != nil {
		return false, err
	}
	nextJSON, err := encodeJSONMap(next.Headers)
	if err != nil {
		return false, err
	}
	affected, err := s.db.Exec(ctx, `UPDATE mcp_servers SET headers = ? WHERE name = ? AND headers = ?`,
		nextJSON, strings.TrimSpace(current.Name), currentJSON)
	if err != nil {
		return false, fmt.Errorf("swap mcp server headers: %w", err)
	}
	return affected > 0, nil
}

func (s *SQLStore) Close() error {
	return nil
}

func scanSQLMCPServer(scanner sqlx.Row) (ManagedServer, error) {
	var server ManagedServer
	var headers, allowed, disallowed, userPaths, disallowedUserPaths []byte
	var createdAt, updatedAt int64
	if err := scanner.Scan(
		&server.Name,
		&server.DisplayName,
		&server.URL,
		&server.Transport,
		&headers,
		&server.Description,
		&server.Enabled,
		&allowed,
		&disallowed,
		&userPaths,
		&disallowedUserPaths,
		&server.ToolTimeoutSeconds,
		&createdAt,
		&updatedAt,
	); err != nil {
		return ManagedServer{}, err
	}
	var err error
	if server.Headers, err = decodeJSONMap(headers); err != nil {
		return ManagedServer{}, err
	}
	if server.AllowedTools, err = sqlutil.DecodeJSONStrings(allowed); err != nil {
		return ManagedServer{}, fmt.Errorf("decode allowed_tools: %w", err)
	}
	if server.DisallowedTools, err = sqlutil.DecodeJSONStrings(disallowed); err != nil {
		return ManagedServer{}, fmt.Errorf("decode disallowed_tools: %w", err)
	}
	if server.UserPaths, err = sqlutil.DecodeJSONStrings(userPaths); err != nil {
		return ManagedServer{}, fmt.Errorf("decode user_paths: %w", err)
	}
	if server.DisallowedUserPaths, err = sqlutil.DecodeJSONStrings(disallowedUserPaths); err != nil {
		return ManagedServer{}, fmt.Errorf("decode disallowed_user_paths: %w", err)
	}
	if server.DisplayName == "" {
		server.DisplayName = server.Name
	}
	server.CreatedAt = sqlutil.TimeFromUnix(createdAt)
	server.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
	return server, nil
}