Files
LocalAI/core/services/studio/mcpconns/client.go
T
Ettore Di Giacinto 024b7196de fix(mcpconns): guard the dial when private addresses are allowed
With mcp_connectors_allow_private on, a connector used a plain transport
with the environment proxy and no address check, so its URL could reach
cloud metadata addresses. Build the private path on the safehttp guard
with private addresses allowed: it resolves once, checks every answer,
and keeps the metadata addresses blocked. An optional DenyAddrs hook
refuses further addresses, for example the server's own.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-03 08:39:12 +00:00

228 lines
6.9 KiB
Go

package mcpconns
import (
"context"
"fmt"
"maps"
"net/http"
"net/url"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/LocalAI/pkg/httpclient"
"github.com/mudler/LocalAI/pkg/mcptransport"
"github.com/mudler/LocalAI/pkg/web/safehttp"
)
// maxTools bounds what one server may offer.
const maxTools = 200
var mcpClient = mcp.NewClient(&mcp.Implementation{Name: "LocalAI", Version: "v1.0.0"}, nil)
// conn is an open session with the tools it listed.
type conn struct {
cs *mcp.ClientSession
cancel context.CancelFunc
tools []*mcp.Tool
}
func (c *conn) close() {
if c == nil {
return
}
_ = c.cs.Close()
c.cancel()
}
// headerRT adds the connector's headers to a request, but only for the host the
// connector names. A redirect to another host never gets the credentials.
type headerRT struct {
base http.RoundTripper
origin *url.URL
headers map[string]string
}
func (h *headerRT) RoundTrip(r *http.Request) (*http.Response, error) {
if !strings.EqualFold(r.URL.Scheme, h.origin.Scheme) || !strings.EqualFold(r.URL.Host, h.origin.Host) {
return h.base.RoundTrip(r)
}
r = r.Clone(r.Context())
for k, v := range h.headers {
r.Header.Set(k, v)
}
return h.base.RoundTrip(r)
}
// httpClientFor builds the client of a remote connector. Unless the
// administrator allows private addresses, the transport resolves the host
// itself and refuses to dial an address that is not public, so a name whose
// answer changes between the check and the connection cannot reach the
// internal network, and every redirect hop is checked the same way.
func (s *Service) httpClientFor(c Connector, sec Secrets) (*http.Client, error) {
origin, err := url.Parse(c.URL)
if err != nil {
return nil, bad("the URL is not valid")
}
headers := map[string]string{}
if sec.Token != "" {
headers["Authorization"] = "Bearer " + sec.Token
}
maps.Copy(headers, sec.Headers)
if s.o.AllowPrivate() {
// Private networks are allowed, but not the cloud metadata addresses
// or the addresses of DenyAddrs. The guard resolves once, checks every
// answer and dials the checked address. No proxy: it would be dialed
// instead of the target and the target would not be checked.
g := safehttp.NewGuard(safehttp.GuardPolicy{AllowPrivate: true, DenyAddrs: s.o.DenyAddrs}, nil)
t := httpclient.HardenedTransport()
t.Proxy = nil
t.DialContext = g.DialContext
rt := &headerRT{base: t, origin: origin, headers: headers}
return httpclient.New(httpclient.WithTransport(rt)), nil
}
rt := &headerRT{base: httpclient.New(httpclient.WithExternalOnly()).Transport, origin: origin, headers: headers}
return httpclient.New(httpclient.WithTransport(rt), httpclient.WithExternalOnly(), httpclient.WithFollowRedirects()), nil
}
// openConn connects to a connector and lists its tools. The connection lives
// until conn.close: it keeps the values of reqCtx but not its cancellation,
// because a session is shared by later turns. While it connects, the end of
// reqCtx still aborts the work, so a stopped turn does not wait for a server
// that is slow to answer.
func (s *Service) openConn(reqCtx context.Context, c Connector, sec Secrets) (*conn, error) {
ctx, cancel := context.WithCancel(context.WithoutCancel(reqCtx))
stop := context.AfterFunc(reqCtx, cancel)
defer stop()
var (
cs *mcp.ClientSession
err error
)
switch c.Transport {
case TransportHTTP:
if err := CheckURL(c.URL, !s.o.AllowPrivate()); err != nil {
cancel()
return nil, err
}
var hc *http.Client
if hc, err = s.httpClientFor(c, sec); err != nil {
cancel()
return nil, err
}
cs, err = mcptransport.Connect(ctx, mcpClient, c.URL, hc, s.o.ConnectTimeout)
case TransportStdio:
// No shell: the command and its arguments go to the kernel as they are.
cmd := exec.CommandContext(ctx, c.Command, c.Args...) // #nosec G204 -- an administrator's own connector
cmd.Env = StdioEnv(sec.Env, os.Environ()) //nolint:forbidigo // stdio servers inherit a filtered copy of the process environment
cs, err = connectCommand(ctx, cancel, cmd, s.o.ConnectTimeout)
default:
err = bad("unknown transport")
}
if err != nil {
cancel()
return nil, err
}
lctx, lcancel := context.WithTimeout(ctx, s.o.ConnectTimeout)
defer lcancel()
var tools []*mcp.Tool
cursor := ""
for {
res, lerr := cs.ListTools(lctx, &mcp.ListToolsParams{Cursor: cursor})
if lerr != nil {
_ = cs.Close()
cancel()
return nil, lerr
}
tools = append(tools, res.Tools...)
if res.NextCursor == "" || len(tools) >= maxTools {
break
}
cursor = res.NextCursor
}
if len(tools) > maxTools {
tools = tools[:maxTools]
}
if !stop() {
// reqCtx ended and cancelled the connection while the tools were
// listed: do not hand out a dead session.
_ = cs.Close()
return nil, reqCtx.Err()
}
return &conn{cs: cs, cancel: cancel, tools: tools}, nil
}
func connectCommand(ctx context.Context, cancel context.CancelFunc, cmd *exec.Cmd, timeout time.Duration) (*mcp.ClientSession, error) {
type result struct {
cs *mcp.ClientSession
err error
}
done := make(chan result, 1)
go func() {
cs, err := mcpClient.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
done <- result{cs, err}
}()
select {
case r := <-done:
return r.cs, r.err
case <-ctx.Done():
// The request ended; the cancelled context kills the process.
return nil, context.Cause(ctx)
case <-time.After(timeout):
// Cancelling the context kills the process.
cancel()
return nil, fmt.Errorf("the command did not answer within %s", timeout)
}
}
// stdioKept are the variables of the server's own environment that a local MCP
// command gets. Everything else (API keys, cloud credentials) stays out.
var stdioKept = map[string]bool{"PATH": true, "HOME": true, "LANG": true, "LC_ALL": true, "TMPDIR": true, "TZ": true}
// StdioEnv builds the environment of a local MCP command: a few neutral
// variables of the parent, then the connector's own.
func StdioEnv(own map[string]string, parent []string) []string {
var out []string
for _, kv := range parent {
if k, _, ok := strings.Cut(kv, "="); ok && stdioKept[k] {
out = append(out, kv)
}
}
keys := make([]string, 0, len(own))
for k := range own {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = append(out, k+"="+own[k])
}
return out
}
// redact removes secrets, and the query of the address (it may hold a key),
// from the text of an error before anyone reads it.
func redact(msg string, c Connector, sec Secrets) string {
if u, err := url.Parse(c.URL); err == nil && u.RawQuery != "" {
clean := *u
clean.RawQuery = ""
msg = strings.ReplaceAll(msg, c.URL, clean.String())
msg = strings.ReplaceAll(msg, u.RawQuery, "")
}
secrets := []string{sec.Token}
for _, v := range sec.Headers {
secrets = append(secrets, v)
}
for _, v := range sec.Env {
secrets = append(secrets, v)
}
for _, v := range secrets {
if len(v) >= 3 {
msg = strings.ReplaceAll(msg, v, "***")
}
}
return msg
}