Ctrl K
ComunidadGuías…

internal/mcpgateway/store.go

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

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"errors"
	"fmt"
	"maps"
	"slices"
	"strings"
	"time"

	"github.com/goccy/go-json"

	"github.com/enterpilot/gomodel/config"
)

// ErrNotFound indicates a requested managed MCP server was not found.
var ErrNotFound = errors.New("mcp server not found")

// Store defines persistence operations for admin-managed MCP servers.
type Store interface {
	List(ctx context.Context) ([]ManagedServer, error)
	Get(ctx context.Context, name string) (*ManagedServer, error)
	Upsert(ctx context.Context, server ManagedServer) error
	// Update rewrites an existing row and returns ErrNotFound when the row is
	// gone, so an edit racing a delete cannot recreate the server.
	Update(ctx context.Context, server ManagedServer) error
	Delete(ctx context.Context, name string) error
	Close() error
}

// ManagedServer is one admin-managed upstream server row. Stdio fields are
// deliberately absent: runtime-registered subprocesses are a remote code
// execution vector (see the gateway spec), so stdio servers exist only as
// declarative config.
type ManagedServer struct {
	// Name is the immutable ASCII slug and remains the storage primary key.
	Name        string `json:"slug"`
	DisplayName string `json:"name"`

	URL                 string            `json:"url"`
	Transport           string            `json:"transport"`
	Headers             map[string]string `json:"headers,omitempty"`
	Description         string            `json:"description,omitempty"`
	Enabled             bool              `json:"enabled"`
	AllowedTools        []string          `json:"allowed_tools,omitempty"`
	DisallowedTools     []string          `json:"disallowed_tools,omitempty"`
	UserPaths           []string          `json:"user_paths,omitempty"`
	DisallowedUserPaths []string          `json:"disallowed_user_paths,omitempty"`
	ToolTimeoutSeconds  int               `json:"tool_timeout_seconds,omitempty"`
	CreatedAt           time.Time         `json:"created_at"`
	UpdatedAt           time.Time         `json:"updated_at"`
}

// Validate checks the row against the same rules as declarative config,
// additionally rejecting the stdio transport. The receiver is only mutated
// (normalized transport/URL) once every check has passed.
func (m *ManagedServer) Validate() error {
	m.Name = strings.ToLower(strings.TrimSpace(m.Name))
	m.DisplayName = strings.TrimSpace(m.DisplayName)
	if m.DisplayName == "" {
		m.DisplayName = m.Name
	}
	if err := config.ValidateMCPServerName(m.DisplayName); err != nil {
		return err
	}
	if err := config.ValidateMCPServerSlug(m.Name); err != nil {
		return err
	}
	if m.Transport == config.MCPTransportStdio {
		return fmt.Errorf("stdio servers can only be declared in config.yaml or MCP_SERVERS, not via the admin API")
	}
	if m.ToolTimeoutSeconds < 0 {
		return fmt.Errorf("tool_timeout_seconds must not be negative")
	}
	cfg := config.MCPServerConfig{
		URL:                 m.URL,
		Transport:           m.Transport,
		Headers:             m.Headers,
		UserPaths:           m.UserPaths,
		DisallowedUserPaths: m.DisallowedUserPaths,
		ToolTimeout:         time.Duration(m.ToolTimeoutSeconds) * time.Second,
	}
	if err := config.ValidateMCPServerConfig(&cfg); err != nil {
		return err
	}
	m.Transport = cfg.Transport
	m.URL = cfg.URL
	m.UserPaths = cfg.UserPaths
	m.DisallowedUserPaths = cfg.DisallowedUserPaths
	return nil
}

// Spec converts the row into a runtime spec.
func (m ManagedServer) Spec() ServerSpec {
	timeout := time.Duration(m.ToolTimeoutSeconds) * time.Second
	if timeout <= 0 {
		timeout = config.DefaultMCPToolTimeout
	}
	return ServerSpec{
		Name:                m.Name,
		DisplayName:         m.DisplayName,
		URL:                 m.URL,
		Transport:           m.Transport,
		Headers:             maps.Clone(m.Headers),
		Description:         m.Description,
		Enabled:             m.Enabled,
		AllowedTools:        slices.Clone(m.AllowedTools),
		DisallowedTools:     slices.Clone(m.DisallowedTools),
		UserPaths:           normalizeUserPaths(m.UserPaths),
		DisallowedUserPaths: normalizeUserPaths(m.DisallowedUserPaths),
		ToolTimeout:         timeout,
		Managed:             false,
	}
}

func encodeJSONMap(value map[string]string) (string, error) {
	if value == nil {
		value = map[string]string{}
	}
	data, err := json.Marshal(value)
	if err != nil {
		return "", fmt.Errorf("encode map: %w", err)
	}
	return string(data), nil
}

func decodeJSONMap(data []byte) (map[string]string, error) {
	if len(data) == 0 {
		return nil, nil
	}
	var value map[string]string
	if err := json.Unmarshal(data, &value); err != nil {
		return nil, fmt.Errorf("decode map: %w", err)
	}
	if len(value) == 0 {
		return nil, nil
	}
	return value, nil
}

// stampUpsert sets timestamps: CreatedAt on insert, UpdatedAt always.
func stampUpsert(server *ManagedServer) {
	if strings.TrimSpace(server.DisplayName) == "" {
		server.DisplayName = strings.TrimSpace(server.Name)
	}
	now := time.Now().UTC()
	if server.CreatedAt.IsZero() {
		server.CreatedAt = now
	}
	server.UpdatedAt = now
}

// collectManagedServers drains a row iterator into a slice.
func collectManagedServers(next func() (ManagedServer, bool, error), rowsErr func() error) ([]ManagedServer, error) {
	result := make([]ManagedServer, 0)
	for {
		server, ok, err := next()
		if err != nil {
			return nil, err
		}
		if !ok {
			break
		}
		result = append(result, server)
	}
	if err := rowsErr(); err != nil {
		return nil, fmt.Errorf("iterate mcp servers: %w", err)
	}
	return result, nil
}