Ctrl K
ComunidadGuías…

internal/mcpgateway/upstream.go

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

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"regexp"
	"slices"
	"strings"
	"sync"
	"sync/atomic"
	"time"

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

	"github.com/enterpilot/gomodel/internal/version"
)

// connectTimeout bounds one upstream dial + initialize handshake.
const connectTimeout = 15 * time.Second

// listTimeout bounds one full catalog listing pass.
const listTimeout = 30 * time.Second

// ErrToolExcluded rejects a call to a tool the operator filters hide.
var ErrToolExcluded = errors.New("tool is excluded by the gateway tool filters")

// upstream owns the client session and catalog snapshot for one server. One
// shared session serves all downstream sessions: v1 forwards no per-user
// upstream credentials and bridges no server-to-client requests, so
// multiplexing is safe and avoids a session per client (or worse, per call).
type upstream struct {
	spec       ServerSpec
	httpClient *http.Client

	// connectMu serializes dial/refresh so concurrent callers cannot race a
	// reconnect. stateMu guards the fields below and is never held across IO.
	connectMu sync.Mutex
	stateMu   sync.Mutex

	session      *mcp.ClientSession
	catalog      *catalog
	status       ServerStatus
	lastErr      string
	connectedAt  time.Time
	authRequired bool
	// closed marks a permanently disposed upstream (removed or replaced by a
	// reconcile). A stale background refresh must not redial it and leak an
	// untracked session.
	closed bool
	// life bounds every HTTP request the upstream makes; close cancels it, so
	// a dial hung on an unresponsive server ends with the upstream.
	life       context.Context
	lifeCancel context.CancelFunc
}

func newUpstream(spec ServerSpec, httpClient *http.Client) *upstream {
	u := &upstream{spec: spec, httpClient: httpClient, status: StatusConnecting}
	u.life, u.lifeCancel = context.WithCancel(context.Background())
	if !spec.Enabled {
		u.status = StatusDisabled
	}
	return u
}

// view snapshots the upstream state for admin/dashboard consumption.
func (u *upstream) view() ServerView {
	u.stateMu.Lock()
	defer u.stateMu.Unlock()
	return ServerView{
		Spec:              u.spec,
		Status:            u.status,
		LastError:         u.lastErr,
		ToolCount:         u.catalog.toolCount(),
		ExcludedToolCount: u.catalog.excludedToolCount(),
		PromptCount:       u.catalog.promptCount(),
		ResourceCount:     u.catalog.resourceCount(),
		ConnectedAt:       u.connectedAt,
		AuthRequired:      u.authRequired,
	}
}

// currentSpec returns the spec under the state lock; the access policy can
// change in place (setAccessPolicy), so readers of it must not race it.
func (u *upstream) currentSpec() ServerSpec {
	u.stateMu.Lock()
	defer u.stateMu.Unlock()
	return u.spec
}

// setAccessPolicy swaps the tool filters and user-path scopes from spec and
// re-filters the cached catalog without touching the upstream session. They
// are gateway policy, so changing them never needs a redial.
func (u *upstream) setAccessPolicy(spec ServerSpec) {
	u.stateMu.Lock()
	defer u.stateMu.Unlock()
	u.spec.AllowedTools = spec.AllowedTools
	u.spec.DisallowedTools = spec.DisallowedTools
	u.spec.UserPaths = spec.UserPaths
	u.spec.DisallowedUserPaths = spec.DisallowedUserPaths
	u.catalog = u.catalog.withToolFilters(spec.AllowedTools, spec.DisallowedTools)
}

// toolExposed reports whether the current filters expose name. Downstream
// sessions snapshot their tool list at initialize, so calls re-check here to
// make an exclusion effective for sessions that are already open.
func (u *upstream) toolExposed(name string) bool {
	u.stateMu.Lock()
	defer u.stateMu.Unlock()
	return toolAllowed(name, u.spec.AllowedTools, u.spec.DisallowedTools)
}

// snapshot returns the current catalog (nil when never listed).
func (u *upstream) snapshot() (*catalog, ServerStatus) {
	u.stateMu.Lock()
	defer u.stateMu.Unlock()
	return u.catalog, u.status
}

// refresh (re)establishes the session if needed and rebuilds the catalog. On
// failure the server is marked degraded and any previous catalog is kept, so
// a transient upstream hiccup does not blank out tools that were working — a
// failed listing must never register an "empty but connected" server.
func (u *upstream) refresh(ctx context.Context) error {
	u.connectMu.Lock()
	defer u.connectMu.Unlock()
	if !u.spec.Enabled || u.isClosed() {
		return nil
	}

	session, err := u.ensureSessionLocked(ctx)
	if err != nil {
		u.markDegraded(err)
		return err
	}

	listCtx, cancel := context.WithTimeout(ctx, listTimeout)
	defer cancel()
	fresh, err := u.list(listCtx, session)
	if err != nil {
		// A session whose listing failed is suspect (dead transport, wedged
		// stream). Drop it so the next re-probe starts from a fresh dial
		// instead of reusing it forever; refreshes are infrequent, so the
		// redial cost is negligible next to guaranteed recovery.
		u.dropSession(session)
		u.markDegraded(err)
		return err
	}

	u.stateMu.Lock()
	u.catalog = fresh.withToolFilters(u.spec.AllowedTools, u.spec.DisallowedTools)
	u.status = StatusConnected
	u.lastErr = ""
	u.stateMu.Unlock()
	return nil
}

// ensureSessionLocked returns the live session, dialing when necessary.
// connectMu must be held.
func (u *upstream) ensureSessionLocked(ctx context.Context) (*mcp.ClientSession, error) {
	u.stateMu.Lock()
	session := u.session
	closed := u.closed
	u.stateMu.Unlock()
	if closed {
		return nil, fmt.Errorf("mcp server %q was removed", u.spec.Name)
	}
	if session != nil {
		return session, nil
	}

	dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
	defer cancel()

	// The SDK's Connect closes the session when the handshake fails and waits
	// for in-flight requests, so cancelling dialCtx alone cannot end a dial
	// whose server never answers. The dial's HTTP requests are therefore bound
	// to requests, which the timeout, the caller, and close all cancel. A
	// connected session keeps it for its own requests until close.
	requests, abort := context.WithCancel(u.life)
	stopAbort := context.AfterFunc(dialCtx, abort)

	transport, probe, err := u.transport(requests)
	if err != nil {
		abort()
		return nil, err
	}
	client := mcp.NewClient(&mcp.Implementation{
		Name:    "gomodel",
		Title:   "GoModel MCP Gateway",
		Version: version.Version,
	}, u.clientOptions())
	session, err = client.Connect(dialCtx, transport, nil)
	if !stopAbort() && err == nil {
		// The dial ended as Connect returned, so its requests may already be
		// aborted; the session is not trustworthy.
		_ = session.Close()
		err = dialCtx.Err()
	}
	u.stateMu.Lock()
	u.authRequired = err != nil && probe.unauthorized()
	u.stateMu.Unlock()
	if err != nil {
		abort()
		return nil, u.connectError(err, probe)
	}

	u.stateMu.Lock()
	// close() may have disposed the upstream while the dial was in flight
	// (close deliberately does not wait on connectMu). Storing the session
	// then would leak it forever, so discard it instead.
	if u.closed {
		u.stateMu.Unlock()
		_ = session.Close()
		return nil, fmt.Errorf("mcp server %q was removed", u.spec.Name)
	}
	u.session = session
	u.connectedAt = time.Now().UTC()
	u.stateMu.Unlock()
	return session, nil
}

// clientOptions wires upstream change notifications into catalog refreshes.
// No sampling/elicitation/roots handlers are set, so those capabilities are
// not advertised upstream and servers legally never send such requests.
func (u *upstream) clientOptions() *mcp.ClientOptions {
	relist := func() {
		go func() {
			if err := u.refresh(context.Background()); err != nil {
				slog.Warn("mcp catalog refresh after list_changed failed",
					"server", u.spec.Name, "error", err)
			}
		}()
	}
	return &mcp.ClientOptions{
		ToolListChangedHandler:     func(context.Context, *mcp.ToolListChangedRequest) { relist() },
		PromptListChangedHandler:   func(context.Context, *mcp.PromptListChangedRequest) { relist() },
		ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { relist() },
	}
}

// transport builds a fresh transport for one dial attempt. HTTP transports
// also return the probe watching that dial (nil for stdio); see connectError.
// transport builds the dial's transport; requests bounds its HTTP requests.
func (u *upstream) transport(requests context.Context) (mcp.Transport, *connectProbe, error) {
	switch u.spec.Transport {
	case "http", "":
		probe := &connectProbe{method: http.MethodPost}
		return &mcp.StreamableClientTransport{
			Endpoint:   u.spec.URL,
			HTTPClient: u.dialClient(probe, requests),
		}, probe, nil
	case "sse":
		probe := &connectProbe{method: http.MethodGet}
		return &mcp.SSEClientTransport{
			Endpoint:   u.spec.URL,
			HTTPClient: u.dialClient(probe, requests),
		}, probe, nil
	case "stdio":
		cmd := exec.Command(u.spec.Command, u.spec.Args...) //nolint:gosec // stdio servers come only from startup config.
		// Start from a minimal environment, not os.Environ(): the gateway
		// process holds every provider API key and the master key, and a
		// compromised MCP server binary must not inherit them. Operators pass
		// anything else explicitly via the server's env map (${VAR} expands
		// in config), on top of the basics process launchers need. The
		// non-nil initialization matters: a nil cmd.Env would fall back to
		// inheriting the full parent environment.
		cmd.Env = []string{}
		for _, key := range []string{"PATH", "HOME", "TMPDIR", "USER", "LANG"} {
			if value := os.Getenv(key); value != "" {
				cmd.Env = append(cmd.Env, key+"="+value)
			}
		}
		for key, value := range u.spec.Env {
			cmd.Env = append(cmd.Env, key+"="+value)
		}
		cmd.Stderr = os.Stderr
		return &mcp.CommandTransport{Command: cmd}, nil, nil
	default:
		return nil, nil, fmt.Errorf("mcp server %q: unsupported transport %q", u.spec.Name, u.spec.Transport)
	}
}

// dialClient overlays the connect probe and the configured static headers on
// the shared HTTP client. The headers carry the upstream credential; the
// client's own bearer token was terminated at the gateway and is never
// forwarded.
func (u *upstream) dialClient(probe *connectProbe, requests context.Context) *http.Client {
	base := u.httpClient
	if base == nil {
		base = http.DefaultClient
	}
	transport := base.Transport
	if transport == nil {
		transport = http.DefaultTransport
	}
	// The OAuth transport sits inside the static headers so it runs last: a
	// signed-in token replaces a configured Authorization header.
	if u.spec.oauth != nil {
		transport = &bearerRoundTripper{
			base:   transport,
			source: u.spec.oauth,
			origin: requestOrigin(u.spec.URL),
		}
	}
	if len(u.spec.Headers) > 0 {
		transport = &headerRoundTripper{
			base:    transport,
			headers: u.spec.Headers,
			origin:  requestOrigin(u.spec.URL),
		}
	}
	probe.base = transport
	clone := *base
	clone.Transport = &boundRoundTripper{base: probe, bound: requests}
	return &clone
}

// boundRoundTripper cancels each request when bound ends, on top of the
// request's own context. The binding lasts until the response body closes,
// so a streamed response is cut off too.
type boundRoundTripper struct {
	base  http.RoundTripper
	bound context.Context
}

func (t *boundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	ctx, cancel := context.WithCancel(req.Context())
	stop := context.AfterFunc(t.bound, cancel)
	release := func() {
		stop()
		cancel()
	}
	resp, err := t.base.RoundTrip(req.WithContext(ctx))
	if err != nil {
		release()
		return resp, err
	}
	resp.Body = &releasingBody{ReadCloser: resp.Body, release: sync.OnceFunc(release)}
	return resp, nil
}

type releasingBody struct {
	io.ReadCloser
	release func()
}

func (b *releasingBody) Close() error {
	err := b.ReadCloser.Close()
	b.release()
	return err
}

// connectProbe watches one dial and remembers how the URL answered the first
// request of its handshake: the POST of a streamable transport, the GET of an
// SSE one. A 404 or 405 there means something listens at the URL but no MCP
// endpoint does — almost always a wrong path, since servers mount their
// endpoint under different paths. The SDK reports that as a bare "Not Found",
// which reads like a session problem; connectError turns it into a hint about
// the URL. Only that first request counts: a later 404 on a request carrying
// a session ID is the server dropping that session, not a missing endpoint,
// and stateless servers legitimately answer the standalone SSE GET with 405.
// A 401 there means the server wants a credential, which the admin view
// reports as AuthRequired so the dashboard can offer an OAuth sign-in. A
// redirect is not an answer: the request it leads to is.
type connectProbe struct {
	base    http.RoundTripper
	method  string
	decided atomic.Bool
	status  atomic.Int32
}

func (p *connectProbe) RoundTrip(req *http.Request) (*http.Response, error) {
	resp, err := p.base.RoundTrip(req)
	if err != nil || req.Method != p.method || isRedirect(resp.StatusCode) || !p.decided.CompareAndSwap(false, true) {
		return resp, err
	}
	switch resp.StatusCode {
	case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusUnauthorized:
		p.status.Store(int32(resp.StatusCode))
	}
	return resp, err
}

func isRedirect(status int) bool {
	return status >= 300 && status < 400
}

// unauthorized reports whether the dial's first handshake request was
// answered 401 Unauthorized.
func (p *connectProbe) unauthorized() bool {
	return p != nil && p.status.Load() == http.StatusUnauthorized
}

// handshakeStatus returns the status of the dial's first handshake request
// when it showed no MCP endpoint at the URL or asked for credentials, and 0
// otherwise.
func (p *connectProbe) handshakeStatus() int {
	if p == nil {
		return 0
	}
	return int(p.status.Load())
}

// connectError wraps a failed dial, pointing at the URL path or transport
// when the probe saw that the handshake never reached an MCP endpoint. A 404
// means nothing is mounted at the path. A 405 means the path exists but
// rejects the handshake method, which is what a streamable HTTP endpoint
// answers to the SSE transport's GET (and an SSE endpoint to a POST), so the
// transport is the first thing to check. Only the origin is ever named: the
// path and query stay out of errors and logs because some servers carry
// credentials in them.
func (u *upstream) connectError(err error, probe *connectProbe) error {
	err = redactDialError(err)
	switch status := probe.handshakeStatus(); status {
	case http.StatusNotFound:
		return fmt.Errorf("connect to mcp server %q: %s answered HTTP 404 Not Found; no MCP endpoint at that path, check the url: %w",
			u.spec.Name, requestOrigin(u.spec.URL), err)
	case http.StatusMethodNotAllowed:
		return fmt.Errorf("connect to mcp server %q: %s answered HTTP 405 Method Not Allowed to the %s handshake; the endpoint likely speaks the other transport, check the transport and the url: %w",
			u.spec.Name, requestOrigin(u.spec.URL), u.transportName(), err)
	case http.StatusUnauthorized:
		return fmt.Errorf("connect to mcp server %q: %s answered HTTP 401 Unauthorized; sign in from the dashboard or configure an Authorization header: %w",
			u.spec.Name, requestOrigin(u.spec.URL), err)
	}
	return fmt.Errorf("connect to mcp server %q: %w", u.spec.Name, err)
}

// transportName is the configured transport with the default made explicit.
func (u *upstream) transportName() string {
	if u.spec.Transport == "" {
		return "http"
	}
	return u.spec.Transport
}

// redactDialError trims every URL quoted in a transport failure to its
// origin. The HTTP client's *url.Error carries the request URL, and Go masks
// only its password, leaving userinfo, path, and query — all places a
// credential can sit. The SDK formats that error into a string several
// layers up, so the text is rewritten rather than the chain unwrapped; dial
// errors are only ever logged and displayed.
func redactDialError(err error) error {
	text := err.Error()
	redacted := quotedURL.ReplaceAllStringFunc(text, func(match string) string {
		if origin := requestOrigin(match[1 : len(match)-1]); origin != "" {
			return `"` + origin + `"`
		}
		return `"redacted"`
	})
	if redacted == text {
		return err
	}
	return errors.New(redacted)
}

// quotedURL matches the %q-formatted URL inside a *url.Error message.
var quotedURL = regexp.MustCompile(`"https?://[^"\s]+"`)

type headerRoundTripper struct {
	base    http.RoundTripper
	headers map[string]string
	origin  string
}

func (t *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	clone := req.Clone(req.Context())
	if t.origin != "" && requestOrigin(req.URL.String()) == t.origin {
		for key, value := range t.headers {
			clone.Header.Set(key, value)
		}
	}
	return t.base.RoundTrip(clone)
}

func requestOrigin(raw string) string {
	parsed, err := url.Parse(raw)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return ""
	}
	return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host)
}

// list rebuilds the catalog from the upstream's declared capabilities. Valid
// tool schemas and metadata pass through untouched; malformed schemas are
// made safe for the stricter downstream SDK. Tool filters are applied by the
// caller, under the state lock.
func (u *upstream) list(ctx context.Context, session *mcp.ClientSession) (*catalog, error) {
	fresh := &catalog{}
	init := session.InitializeResult()
	var caps *mcp.ServerCapabilities
	if init != nil {
		fresh.instructions = init.Instructions
		caps = init.Capabilities
	}

	if caps == nil || caps.Tools != nil {
		var tools []*mcp.Tool
		for tool, err := range session.Tools(ctx, nil) {
			if err != nil {
				return nil, fmt.Errorf("list tools from %q: %w", u.spec.Name, err)
			}
			tools = append(tools, tool)
		}
		fresh.discovered = discoverTools(tools)
	}

	if caps != nil && caps.Prompts != nil {
		for prompt, err := range session.Prompts(ctx, nil) {
			if err != nil {
				return nil, fmt.Errorf("list prompts from %q: %w", u.spec.Name, err)
			}
			if prompt == nil || prompt.Name == "" {
				continue
			}
			fresh.prompts = append(fresh.prompts, prompt)
		}
	}

	if caps != nil && caps.Resources != nil {
		for resource, err := range session.Resources(ctx, nil) {
			if err != nil {
				return nil, fmt.Errorf("list resources from %q: %w", u.spec.Name, err)
			}
			if resource == nil || resource.URI == "" {
				continue
			}
			fresh.resources = append(fresh.resources, resource)
		}
		for template, err := range session.ResourceTemplates(ctx, nil) {
			if err != nil {
				return nil, fmt.Errorf("list resource templates from %q: %w", u.spec.Name, err)
			}
			if template == nil || template.URITemplate == "" {
				continue
			}
			fresh.templates = append(fresh.templates, template)
		}
	}

	// Deterministic ordering across the whole catalog, not just tools: clients
	// that embed capability lists in prompts get stable prompt-cache keys.
	slices.SortFunc(fresh.prompts, func(a, b *mcp.Prompt) int {
		return strings.Compare(a.Name, b.Name)
	})
	slices.SortFunc(fresh.resources, func(a, b *mcp.Resource) int {
		return strings.Compare(a.URI, b.URI)
	})
	slices.SortFunc(fresh.templates, func(a, b *mcp.ResourceTemplate) int {
		return strings.Compare(a.URITemplate, b.URITemplate)
	})

	return fresh, nil
}

// callTool forwards one tools/call with the original tool name. A session
// that died since the last call is redialed once, transparently.
func (u *upstream) callTool(ctx context.Context, name string, args json.RawMessage) (*mcp.CallToolResult, error) {
	if !u.toolExposed(name) {
		return nil, fmt.Errorf("%w: %q", ErrToolExcluded, name)
	}
	params := &mcp.CallToolParams{Name: name}
	if len(args) > 0 {
		params.Arguments = args
	}
	var result *mcp.CallToolResult
	err := u.forward(ctx, func(ctx context.Context, session *mcp.ClientSession) error {
		var callErr error
		result, callErr = session.CallTool(ctx, params)
		return callErr
	})
	return result, err
}

// getPrompt forwards one prompts/get with the original prompt name.
func (u *upstream) getPrompt(ctx context.Context, params *mcp.GetPromptParams) (*mcp.GetPromptResult, error) {
	var result *mcp.GetPromptResult
	err := u.forward(ctx, func(ctx context.Context, session *mcp.ClientSession) error {
		var callErr error
		result, callErr = session.GetPrompt(ctx, params)
		return callErr
	})
	return result, err
}

// readResource forwards one resources/read by URI.
func (u *upstream) readResource(ctx context.Context, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
	var result *mcp.ReadResourceResult
	err := u.forward(ctx, func(ctx context.Context, session *mcp.ClientSession) error {
		var callErr error
		result, callErr = session.ReadResource(ctx, params)
		return callErr
	})
	return result, err
}

// forward runs one upstream operation under the per-server timeout, redialing
// once when the shared session turned out to be dead.
func (u *upstream) forward(ctx context.Context, op func(context.Context, *mcp.ClientSession) error) error {
	if !u.spec.Enabled {
		return fmt.Errorf("mcp server %q is disabled", u.spec.Name)
	}
	timeout := u.spec.ToolTimeout
	if timeout <= 0 {
		timeout = 30 * time.Second
	}
	opCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	u.connectMu.Lock()
	session, err := u.ensureSessionLocked(opCtx)
	u.connectMu.Unlock()
	if err != nil {
		u.markDegraded(err)
		return err
	}

	err = op(opCtx, session)
	if err == nil || !isConnectionClosed(err) {
		return err
	}

	// The shared session died underneath us; drop it and retry once on a
	// fresh dial so one upstream restart does not fail a client call.
	u.dropSession(session)
	u.connectMu.Lock()
	session, dialErr := u.ensureSessionLocked(opCtx)
	u.connectMu.Unlock()
	if dialErr != nil {
		u.markDegraded(dialErr)
		return dialErr
	}
	return op(opCtx, session)
}

func isConnectionClosed(err error) bool {
	return errors.Is(err, mcp.ErrConnectionClosed)
}

// dropSession forgets the shared session if it is still the given one.
func (u *upstream) dropSession(session *mcp.ClientSession) {
	u.stateMu.Lock()
	if u.session == session {
		u.session = nil
	}
	u.stateMu.Unlock()
	_ = session.Close()
}

func (u *upstream) markDegraded(err error) {
	u.stateMu.Lock()
	u.status = StatusDegraded
	u.lastErr = err.Error()
	u.stateMu.Unlock()
}

func (u *upstream) isClosed() bool {
	u.stateMu.Lock()
	defer u.stateMu.Unlock()
	return u.closed
}

// reset drops the shared session so the next use redials. The upstream stays
// alive — this is the "force reconnect" path, not disposal.
func (u *upstream) reset() {
	u.stateMu.Lock()
	session := u.session
	u.session = nil
	u.stateMu.Unlock()
	if session != nil {
		_ = session.Close()
	}
}

// close permanently disposes the upstream: the session is terminated and any
// in-flight background refresh is refused a redial.
func (u *upstream) close() {
	u.stateMu.Lock()
	u.closed = true
	session := u.session
	u.session = nil
	u.stateMu.Unlock()
	// Cancel first: it ends requests a dial is stuck on, which the session
	// close below would otherwise wait for.
	u.lifeCancel()
	if session != nil {
		_ = session.Close()
	}
}