Ctrl K
ComunidadGuías…

internal/mcpgateway/manager.go

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

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"sort"
	"sync"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// reprobeInterval is how often degraded or never-connected upstreams are
// retried, mirroring PROVIDER_RECHECK_INTERVAL for model providers.
const reprobeInterval = 60 * time.Second

// refreshInterval is how often healthy upstream catalogs are re-listed as a
// safety net for servers that never emit list_changed notifications.
const refreshInterval = 5 * time.Minute

// Manager owns the set of upstream connections and their catalogs.
type Manager struct {
	httpClient *http.Client

	mu        sync.RWMutex
	upstreams map[string]*upstream

	stopOnce sync.Once
	stop     chan struct{}
	// stopCtx bounds the background connects Apply and the re-probe loop
	// start; Close cancels it, so no dial outlives shutdown.
	stopCtx    context.Context
	stopCancel context.CancelFunc
}

// NewManager creates an empty manager. Apply installs the initial specs.
func NewManager(httpClient *http.Client) *Manager {
	stopCtx, stopCancel := context.WithCancel(context.Background())
	m := &Manager{
		httpClient: httpClient,
		upstreams:  make(map[string]*upstream),
		stop:       make(chan struct{}),
		stopCtx:    stopCtx,
		stopCancel: stopCancel,
	}
	go m.maintain()
	return m
}

// Apply reconciles the running upstreams with the desired specs: removed
// servers are closed, new servers are added, changed servers are redialed.
// Unchanged servers keep their live session and catalog; servers whose only
// change is the access policy (tool filters, user-path scopes) keep their
// session and apply the new policy in place.
// Initial connects run asynchronously so startup and admin edits never block
// on upstream IO.
func (m *Manager) Apply(specs []ServerSpec) {
	desired := make(map[string]ServerSpec, len(specs))
	for _, spec := range specs {
		desired[spec.Name] = spec
	}

	var toRefresh []*upstream
	var toClose []*upstream

	m.mu.Lock()
	for name, existing := range m.upstreams {
		if spec, keep := desired[name]; keep {
			current := existing.currentSpec()
			if current.equal(spec) {
				delete(desired, name)
				continue
			}
			if current.withoutAccessPolicy().equal(spec.withoutAccessPolicy()) {
				existing.setAccessPolicy(spec)
				delete(desired, name)
				continue
			}
		}
		toClose = append(toClose, existing)
		delete(m.upstreams, name)
	}
	for name, spec := range desired {
		fresh := newUpstream(spec, m.httpClient)
		m.upstreams[name] = fresh
		if spec.Enabled {
			toRefresh = append(toRefresh, fresh)
		}
	}
	m.mu.Unlock()

	for _, u := range toClose {
		u.close()
	}
	for _, u := range toRefresh {
		go func(u *upstream) {
			if err := u.refresh(m.stopCtx); err != nil {
				if m.stopCtx.Err() != nil {
					return
				}
				slog.Warn("mcp server initial connect failed; will re-probe",
					"server", u.spec.Name, "error", err)
			} else {
				view := u.view()
				slog.Info("mcp server connected",
					"server", u.spec.Name, "tools", view.ToolCount)
			}
		}(u)
	}
}

// maintain re-probes degraded upstreams and periodically re-lists healthy
// catalogs until Close.
func (m *Manager) maintain() {
	reprobe := time.NewTicker(reprobeInterval)
	relist := time.NewTicker(refreshInterval)
	defer reprobe.Stop()
	defer relist.Stop()
	for {
		select {
		case <-m.stop:
			return
		case <-reprobe.C:
			m.refreshWhere(func(status ServerStatus) bool {
				return status == StatusDegraded || status == StatusConnecting
			})
		case <-relist.C:
			m.refreshWhere(func(status ServerStatus) bool {
				return status == StatusConnected
			})
		}
	}
}

func (m *Manager) refreshWhere(match func(ServerStatus) bool) {
	for _, u := range m.list() {
		if !u.spec.Enabled {
			continue
		}
		if _, status := u.snapshot(); !match(status) {
			continue
		}
		go func(u *upstream) {
			if err := u.refresh(m.stopCtx); err != nil {
				slog.Debug("mcp server refresh failed", "server", u.spec.Name, "error", err)
			}
		}(u)
	}
}

func (m *Manager) list() []*upstream {
	m.mu.RLock()
	defer m.mu.RUnlock()
	result := make([]*upstream, 0, len(m.upstreams))
	for _, u := range m.upstreams {
		result = append(result, u)
	}
	return result
}

func (m *Manager) get(name string) (*upstream, bool) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	u, ok := m.upstreams[name]
	return u, ok
}

// spec returns the spec the named upstream currently runs with.
func (m *Manager) spec(name string) (ServerSpec, bool) {
	u, ok := m.get(name)
	if !ok {
		return ServerSpec{}, false
	}
	return u.currentSpec(), true
}

// Views returns admin snapshots of every upstream, sorted by name.
func (m *Manager) Views() []ServerView {
	upstreams := m.list()
	views := make([]ServerView, 0, len(upstreams))
	for _, u := range upstreams {
		views = append(views, u.view())
	}
	sort.Slice(views, func(i, j int) bool { return views[i].Spec.Name < views[j].Spec.Name })
	return views
}

// Reconnect force-redials one server and relists its catalog synchronously.
func (m *Manager) Reconnect(ctx context.Context, name string) (ServerView, error) {
	u, ok := m.get(name)
	if !ok {
		return ServerView{}, fmt.Errorf("mcp server %q is not configured", name)
	}
	u.reset()
	if u.spec.Enabled {
		if err := u.refresh(ctx); err != nil {
			return u.view(), err
		}
	}
	return u.view(), nil
}

// CallTool forwards one tool call to the named server using the original
// (un-prefixed) tool name.
func (m *Manager) CallTool(ctx context.Context, server, tool string, args json.RawMessage) (*mcp.CallToolResult, error) {
	u, ok := m.get(server)
	if !ok {
		return nil, fmt.Errorf("mcp server %q is not configured", server)
	}
	return u.callTool(ctx, tool, args)
}

// GetPrompt forwards one prompts/get to the named server.
func (m *Manager) GetPrompt(ctx context.Context, server string, params *mcp.GetPromptParams) (*mcp.GetPromptResult, error) {
	u, ok := m.get(server)
	if !ok {
		return nil, fmt.Errorf("mcp server %q is not configured", server)
	}
	return u.getPrompt(ctx, params)
}

// ReadResource forwards one resources/read to the named server.
func (m *Manager) ReadResource(ctx context.Context, server string, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
	u, ok := m.get(server)
	if !ok {
		return nil, fmt.Errorf("mcp server %q is not configured", server)
	}
	return u.readResource(ctx, params)
}

// Close terminates the maintenance loop and every upstream session.
func (m *Manager) Close() {
	m.stopOnce.Do(func() {
		m.stopCancel()
		close(m.stop)
	})
	for _, u := range m.list() {
		u.close()
	}
}