diff --git a/core/config/mcp_remote_server_test.go b/core/config/mcp_remote_server_test.go new file mode 100644 index 000000000..4b15749fa --- /dev/null +++ b/core/config/mcp_remote_server_test.go @@ -0,0 +1,76 @@ +package config_test + +import ( + "bytes" + "fmt" + "log/slog" + + . "github.com/mudler/LocalAI/core/config" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MCP remote server OAuth2 configuration", func() { + parse := func(remote string) (MCPGenericConfig[MCPRemoteServers], error) { + c := MCPConfig{Servers: remote} + r, _, err := c.MCPConfigFromYAML() + return r, err + } + + It("parses an oauth2 block from the remote JSON", func() { + r, err := parse(`{"mcpServers": {"gw": {"url": "https://mcp.example.com/mcp", + "oauth2": {"token_url": "https://idp.example.com/token", "client_id": "localai", + "client_secret_env": "MCP_SECRET", "scopes": ["mcp"], "endpoint_params": {"audience": "mcp"}}}}}`) + Expect(err).NotTo(HaveOccurred()) + o := r.Servers["gw"].OAuth2 + Expect(o).NotTo(BeNil()) + Expect(o.TokenURL).To(Equal("https://idp.example.com/token")) + Expect(o.ClientID).To(Equal("localai")) + Expect(o.ClientSecretEnv).To(Equal("MCP_SECRET")) + Expect(o.Scopes).To(Equal([]string{"mcp"})) + Expect(o.EndpointParams).To(HaveKeyWithValue("audience", "mcp")) + }) + + It("keeps the static token form valid (counter-check)", func() { + r, err := parse(`{"mcpServers": {"s": {"url": "https://x", "token": "abc"}}}`) + Expect(err).NotTo(HaveOccurred()) + Expect(r.Servers["s"].Token).To(Equal("abc")) + Expect(r.Servers["s"].OAuth2).To(BeNil()) + }) + + DescribeTable("rejects inconsistent oauth2 blocks", + func(remote, msg string) { + _, err := parse(remote) + Expect(err).To(MatchError(ContainSubstring(msg))) + }, + Entry("token plus oauth2", + `{"mcpServers": {"s": {"url": "https://x", "token": "t", "oauth2": {"token_url": "https://i", "client_id": "a", "client_secret": "b"}}}}`, + "mutually exclusive"), + Entry("missing token_url", + `{"mcpServers": {"s": {"url": "https://x", "oauth2": {"client_id": "a", "client_secret": "b"}}}}`, + "token_url"), + Entry("missing secret", + `{"mcpServers": {"s": {"url": "https://x", "oauth2": {"token_url": "https://i", "client_id": "a"}}}}`, + "client_secret"), + Entry("secret given twice", + `{"mcpServers": {"s": {"url": "https://x", "oauth2": {"token_url": "https://i", "client_id": "a", "client_secret": "b", "client_secret_env": "B"}}}}`, + "mutually exclusive"), + ) + + It("does not write credentials to logs", func() { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + withToken := MCPRemoteServer{URL: "https://x", Token: "static-secret"} + withOAuth2 := MCPRemoteServer{ + URL: "https://y", + OAuth2: &MCPOAuth2Config{TokenURL: "https://idp", ClientID: "id", ClientSecret: "oauth-secret"}, + } + logger.Info("cfg", "a", withToken, "b", withOAuth2) + out := buf.String() + fmt.Sprintf("%v %+v", withToken, MCPRemoteServers{"s": withOAuth2}) + Expect(out).To(ContainSubstring("https://x")) + Expect(out).To(ContainSubstring("https://idp")) + Expect(out).NotTo(ContainSubstring("static-secret")) + Expect(out).NotTo(ContainSubstring("oauth-secret")) + }) +}) diff --git a/core/config/model_config.go b/core/config/model_config.go index bc084aa87..8f7008194 100644 --- a/core/config/model_config.go +++ b/core/config/model_config.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "os" @@ -775,6 +776,11 @@ func (c *MCPConfig) MCPConfigFromYAML() (MCPGenericConfig[MCPRemoteServers], MCP if err := yaml.Unmarshal([]byte(c.Stdio), &stdio); err != nil { return remote, stdio, err } + for name, server := range remote.Servers { + if err := server.Validate(); err != nil { + return remote, stdio, fmt.Errorf("remote MCP server %q: %w", name, err) + } + } return remote, stdio, nil } @@ -791,6 +797,70 @@ type ( type MCPRemoteServer struct { URL string `json:"url,omitempty"` Token string `json:"token,omitempty"` + // OAuth2 makes LocalAI obtain short-lived access tokens with the + // client_credentials grant instead of sending a static Token. The two are + // mutually exclusive. + OAuth2 *MCPOAuth2Config `yaml:"oauth2,omitempty" json:"oauth2,omitempty"` +} + +// @Description OAuth2 client_credentials settings for a remote MCP server +type MCPOAuth2Config struct { + TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"` + // ClientID / ClientSecret may be given literally, or by naming an + // environment variable (*_env) so the secret stays out of the model YAML. + // Exactly one of the two forms must be set for each. + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"` + ClientIDEnv string `yaml:"client_id_env,omitempty" json:"client_id_env,omitempty"` + ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` + ClientSecretEnv string `yaml:"client_secret_env,omitempty" json:"client_secret_env,omitempty"` + Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` + // EndpointParams are extra form parameters for the token request, e.g. + // "audience" (Auth0/Keycloak) or "resource" (RFC 8707). + EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"` +} + +// Validate checks the static shape of a remote server entry. Environment +// variables are deliberately not resolved here: in distributed mode the +// connection is made on an agent worker, whose environment may differ from +// the frontend that loads the YAML. +func (s MCPRemoteServer) Validate() error { + if s.OAuth2 == nil { + return nil + } + if s.Token != "" { + return errors.New("token and oauth2 are mutually exclusive") + } + o := s.OAuth2 + if o.TokenURL == "" { + return errors.New("oauth2.token_url is required") + } + if err := exactlyOne("client_id", o.ClientID, o.ClientIDEnv); err != nil { + return err + } + return exactlyOne("client_secret", o.ClientSecret, o.ClientSecretEnv) +} + +func exactlyOne(field, literal, env string) error { + switch { + case literal != "" && env != "": + return fmt.Errorf("oauth2.%s and oauth2.%s_env are mutually exclusive", field, field) + case literal == "" && env == "": + return fmt.Errorf("oauth2 requires %s or %s_env", field, field) + } + return nil +} + +// String keeps credentials out of logs: the MCP code logs whole server +// entries at debug level, and both xlog and slog format structs via fmt. +func (s MCPRemoteServer) String() string { + auth := "none" + switch { + case s.OAuth2 != nil: + auth = "oauth2(" + s.OAuth2.TokenURL + ")" + case s.Token != "": + auth = "token([redacted])" + } + return fmt.Sprintf("{url: %s, auth: %s}", s.URL, auth) } // @Description MCP STDIO server configuration diff --git a/core/http/endpoints/mcp/oauth2_transport.go b/core/http/endpoints/mcp/oauth2_transport.go new file mode 100644 index 000000000..8acc7eac4 --- /dev/null +++ b/core/http/endpoints/mcp/oauth2_transport.go @@ -0,0 +1,189 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "sync" + "time" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/httpclient" + "github.com/mudler/xlog" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +const ( + // oauth2ExpirySafetyMargin is how long before expiry a token that could + // not be refreshed stops being used. Sending a token that expires in + // flight only turns a clear local error into a confusing 401 upstream. + oauth2ExpirySafetyMargin = 30 * time.Second + // oauth2RetryInterval throttles refresh attempts while the token endpoint + // is failing, so every MCP request does not hammer the IdP. + oauth2RetryInterval = 5 * time.Second + // oauth2DefaultLifetime applies when the IdP omits expires_in, which + // RFC 6749 permits. Assuming "forever" would pin a revoked token. + oauth2DefaultLifetime = 5 * time.Minute + oauth2TokenRequestTimeout = 30 * time.Second +) + +// newRemoteServerTransport returns the RoundTripper that authenticates +// requests to one remote MCP server. Because it sits below the http.Client, +// every MCP transport built on that client (streamable HTTP, SSE) inherits it. +func newRemoteServerTransport(server config.MCPRemoteServer, base http.RoundTripper) (http.RoundTripper, error) { + if err := server.Validate(); err != nil { + return nil, err + } + if server.OAuth2 == nil { + return newBearerTokenRoundTripper(server.Token, base), nil + } + return newOAuth2ClientCredentialsRoundTripper(*server.OAuth2, base, time.Now) +} + +// oauth2ClientCredentialsRoundTripper injects an access token obtained with +// the client_credentials grant. It refreshes at half the token lifetime, so a +// temporarily unavailable IdP is absorbed by the remaining half; only when the +// old token is about to expire does it fail, and then with an error rather +// than an unauthenticated request. +type oauth2ClientCredentialsRoundTripper struct { + conf *clientcredentials.Config + base http.RoundTripper + tokenClient *http.Client + now func() time.Time + + mu sync.Mutex + token *oauth2.Token + expiresAt time.Time + refreshAt time.Time + nextAttempt time.Time +} + +func newOAuth2ClientCredentialsRoundTripper(cfg config.MCPOAuth2Config, base http.RoundTripper, now func() time.Time) (*oauth2ClientCredentialsRoundTripper, error) { + clientID, err := resolveCredential("client_id", cfg.ClientID, cfg.ClientIDEnv) + if err != nil { + return nil, err + } + clientSecret, err := resolveCredential("client_secret", cfg.ClientSecret, cfg.ClientSecretEnv) + if err != nil { + return nil, err + } + if base == nil { + base = httpclient.HardenedTransport() + } + var params url.Values + if len(cfg.EndpointParams) > 0 { + params = url.Values{} + for k, v := range cfg.EndpointParams { + params.Set(k, v) + } + } + return &oauth2ClientCredentialsRoundTripper{ + conf: &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: cfg.TokenURL, + Scopes: cfg.Scopes, + EndpointParams: params, + }, + base: base, + tokenClient: httpclient.NewWithTimeout(oauth2TokenRequestTimeout, httpclient.WithTransport(base)), + now: now, + }, nil +} + +func resolveCredential(field, literal, envName string) (string, error) { + if envName == "" { + return literal, nil + } + // The variable name comes from the model YAML, so this is operator-chosen + // secret indirection (like stdio "env"), not LocalAI process config. + v, ok := os.LookupEnv(envName) //nolint:forbidigo // secret indirection named in the model YAML + if !ok || v == "" { + return "", fmt.Errorf("oauth2.%s_env: environment variable %q is not set", field, envName) + } + return v, nil +} + +func (rt *oauth2ClientCredentialsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + tok, err := rt.currentToken(req.Context()) + if err != nil { + if req.Body != nil { + _ = req.Body.Close() + } + return nil, err + } + // RoundTrippers must not modify the caller's request. + out := req.Clone(req.Context()) + tok.SetAuthHeader(out) + return rt.base.RoundTrip(out) +} + +func (rt *oauth2ClientCredentialsRoundTripper) currentToken(ctx context.Context) (*oauth2.Token, error) { + rt.mu.Lock() + defer rt.mu.Unlock() + + now := rt.now() + if rt.token != nil && now.Before(rt.refreshAt) { + return rt.token, nil + } + if rt.token != nil && now.Before(rt.nextAttempt) { + if rt.stillUsable(now) { + return rt.token, nil + } + return nil, fmt.Errorf("oauth2: token for %s expired and refresh is failing", rt.conf.TokenURL) + } + + fetchCtx, cancel := context.WithTimeout(context.WithValue(ctx, oauth2.HTTPClient, rt.tokenClient), oauth2TokenRequestTimeout) + defer cancel() + tok, err := rt.conf.Token(fetchCtx) + if err != nil { + rt.nextAttempt = now.Add(oauth2RetryInterval) + if rt.stillUsable(now) { + xlog.Warn("MCP OAuth2 token refresh failed, keeping current token until near expiry", + "token_url", rt.conf.TokenURL, "expires_at", rt.expiresAt, "error", err) + return rt.token, nil + } + return nil, fmt.Errorf("oauth2: fetching token from %s: %w", rt.conf.TokenURL, err) + } + if tok.AccessToken == "" { + return nil, errors.New("oauth2: token endpoint returned an empty access_token") + } + + lifetime := tokenLifetime(tok) + rt.token = tok + rt.expiresAt = now.Add(lifetime) + rt.refreshAt = now.Add(lifetime / 2) + rt.nextAttempt = time.Time{} + return tok, nil +} + +func (rt *oauth2ClientCredentialsRoundTripper) stillUsable(now time.Time) bool { + return rt.token != nil && now.Before(rt.expiresAt.Add(-oauth2ExpirySafetyMargin)) +} + +// tokenLifetime reads the wire value of expires_in. clientcredentials only +// exposes an absolute Expiry computed from the wall clock, which would make the +// refresh schedule depend on time.Now instead of the injected clock. +func tokenLifetime(tok *oauth2.Token) time.Duration { + var secs float64 + switch v := tok.Extra("expires_in").(type) { + case float64: + secs = v + case string: + secs, _ = strconv.ParseFloat(v, 64) + } + if secs > 0 { + return time.Duration(secs * float64(time.Second)) + } + if !tok.Expiry.IsZero() { + if d := time.Until(tok.Expiry); d > 0 { + return d + } + } + return oauth2DefaultLifetime +} diff --git a/core/http/endpoints/mcp/oauth2_transport_test.go b/core/http/endpoints/mcp/oauth2_transport_test.go new file mode 100644 index 000000000..61c831a9d --- /dev/null +++ b/core/http/endpoints/mcp/oauth2_transport_test.go @@ -0,0 +1,374 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/httpclient" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeIdP is a minimal OAuth2 token endpoint for the client_credentials grant. +type fakeIdP struct { + server *httptest.Server + requests atomic.Int32 + failing atomic.Bool + expiresIn int + clientID string + secret string + + mu sync.Mutex + lastScope string + lastParams map[string]string +} + +func newFakeIdP(clientID, secret string, expiresIn int) *fakeIdP { + idp := &fakeIdP{clientID: clientID, secret: secret, expiresIn: expiresIn} + idp.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := idp.requests.Add(1) + if idp.failing.Load() { + http.Error(w, "idp down", http.StatusServiceUnavailable) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + id, sec, ok := r.BasicAuth() + if !ok { + id, sec = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret") + } + if r.PostForm.Get("grant_type") != "client_credentials" || id != idp.clientID || sec != idp.secret { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client"}`)) + return + } + idp.mu.Lock() + idp.lastScope = r.PostForm.Get("scope") + idp.lastParams = map[string]string{"audience": r.PostForm.Get("audience")} + idp.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": fmt.Sprintf("tok-%d", n), + "token_type": "Bearer", + "expires_in": idp.expiresIn, + }) + })) + DeferCleanup(idp.server.Close) + return idp +} + +// authRecorder stands in for an MCP server behind an OIDC-protected proxy: it +// records every Authorization header it receives. +type authRecorder struct { + server *httptest.Server + mu sync.Mutex + seen []string +} + +func newAuthRecorder() *authRecorder { + rec := &authRecorder{} + rec.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.seen = append(rec.seen, r.Header.Get("Authorization")) + rec.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + DeferCleanup(rec.server.Close) + return rec +} + +func (r *authRecorder) headers() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.seen...) +} + +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +func doGet(client *http.Client, url string) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + return resp.Body.Close() +} + +// protectedMCPServer serves a real MCP server (streamable HTTP or SSE) that +// rejects every request not carrying "Bearer ". +func protectedMCPServer(sse bool, want func() string) *httptest.Server { + srv := mcp.NewServer(&mcp.Implementation{Name: "protected", Version: "v0.0.1"}, nil) + mcp.AddTool(srv, &mcp.Tool{Name: "whoami", Description: "test tool"}, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil, nil + }) + var handler http.Handler + if sse { + handler = mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return srv }, nil) + } else { + handler = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+want() { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + handler.ServeHTTP(w, r) + })) + DeferCleanup(ts.Close) + return ts +} + +var _ = Describe("OAuth2 client_credentials for remote MCP servers", func() { + var ( + idp *fakeIdP + rec *authRecorder + clock *fakeClock + ) + + BeforeEach(func() { + idp = newFakeIdP("localai", "s3cret", 100) + rec = newAuthRecorder() + clock = &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + }) + + clientFor := func(cfg config.MCPOAuth2Config) *http.Client { + rt, err := newOAuth2ClientCredentialsRoundTripper(cfg, httpclient.HardenedTransport(), clock.Now) + Expect(err).NotTo(HaveOccurred()) + return httpclient.New(httpclient.WithTransport(rt)) + } + + baseCfg := func() config.MCPOAuth2Config { + return config.MCPOAuth2Config{ + TokenURL: idp.server.URL, + ClientID: "localai", + ClientSecret: "s3cret", + Scopes: []string{"mcp:tools"}, + EndpointParams: map[string]string{"audience": "mcp-gateway"}, + } + } + + It("fetches a token and sends it as a bearer token, with scopes and endpoint params", func() { + client := clientFor(baseCfg()) + Expect(doGet(client, rec.server.URL)).To(Succeed()) + + Expect(rec.headers()).To(Equal([]string{"Bearer tok-1"})) + idp.mu.Lock() + defer idp.mu.Unlock() + Expect(idp.lastScope).To(Equal("mcp:tools")) + Expect(idp.lastParams["audience"]).To(Equal("mcp-gateway")) + }) + + It("caches the token across requests", func() { + client := clientFor(baseCfg()) + for range 5 { + Expect(doGet(client, rec.server.URL)).To(Succeed()) + } + Expect(idp.requests.Load()).To(BeEquivalentTo(1)) + Expect(rec.headers()).To(HaveEach("Bearer tok-1")) + }) + + It("refreshes proactively at half of the token lifetime", func() { + client := clientFor(baseCfg()) + Expect(doGet(client, rec.server.URL)).To(Succeed()) + + clock.Advance(49 * time.Second) + Expect(doGet(client, rec.server.URL)).To(Succeed()) + Expect(idp.requests.Load()).To(BeEquivalentTo(1), "before half-life the cached token is reused") + + clock.Advance(2 * time.Second) // 51s of a 100s token + Expect(doGet(client, rec.server.URL)).To(Succeed()) + Expect(idp.requests.Load()).To(BeEquivalentTo(2)) + Expect(rec.headers()).To(Equal([]string{"Bearer tok-1", "Bearer tok-1", "Bearer tok-2"})) + }) + + It("keeps the old token while refresh fails, then fails loudly near expiry without an unauthenticated request", func() { + client := clientFor(baseCfg()) + Expect(doGet(client, rec.server.URL)).To(Succeed()) + + idp.failing.Store(true) + clock.Advance(51 * time.Second) + Expect(doGet(client, rec.server.URL)).To(Succeed()) + Expect(idp.requests.Load()).To(BeEquivalentTo(2), "a refresh was attempted") + + // Retries are throttled while the IdP is down. + Expect(doGet(client, rec.server.URL)).To(Succeed()) + Expect(idp.requests.Load()).To(BeEquivalentTo(2)) + + clock.Advance(18 * time.Second) // t=69s, expiry 100s, margin 30s + Expect(doGet(client, rec.server.URL)).To(Succeed()) + + clock.Advance(2 * time.Second) // t=71s: inside the safety margin + err := doGet(client, rec.server.URL) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("oauth2")) + + Expect(rec.headers()).To(HaveEach("Bearer tok-1"), "no request may go out without a token") + Expect(rec.headers()).To(HaveLen(4)) + + // Counter-check: once the IdP recovers, requests succeed again. + idp.failing.Store(false) + clock.Advance(oauth2RetryInterval) + Expect(doGet(client, rec.server.URL)).To(Succeed()) + Expect(rec.headers()[len(rec.headers())-1]).To(MatchRegexp(`^Bearer tok-\d+$`)) + Expect(rec.headers()[len(rec.headers())-1]).NotTo(Equal("Bearer tok-1")) + }) + + It("errors instead of sending an unauthenticated request when the first fetch fails", func() { + cfg := baseCfg() + cfg.ClientSecret = "wrong" + client := clientFor(cfg) + Expect(doGet(client, rec.server.URL)).NotTo(Succeed()) + Expect(rec.headers()).To(BeEmpty()) + }) + + It("reads client id and secret from environment variables", func() { + Expect(os.Setenv("TEST_MCP_OAUTH_ID", "localai")).To(Succeed()) + Expect(os.Setenv("TEST_MCP_OAUTH_SECRET", "s3cret")).To(Succeed()) + DeferCleanup(func() { + _ = os.Unsetenv("TEST_MCP_OAUTH_ID") + _ = os.Unsetenv("TEST_MCP_OAUTH_SECRET") + }) + cfg := config.MCPOAuth2Config{ + TokenURL: idp.server.URL, + ClientIDEnv: "TEST_MCP_OAUTH_ID", + ClientSecretEnv: "TEST_MCP_OAUTH_SECRET", + } + Expect(doGet(clientFor(cfg), rec.server.URL)).To(Succeed()) + Expect(rec.headers()).To(Equal([]string{"Bearer tok-1"})) + }) + + It("refuses to build the transport when a referenced environment variable is unset", func() { + _ = os.Unsetenv("TEST_MCP_OAUTH_MISSING") + _, err := newRemoteServerTransport(config.MCPRemoteServer{ + URL: rec.server.URL, + OAuth2: &config.MCPOAuth2Config{ + TokenURL: idp.server.URL, + ClientID: "localai", + ClientSecretEnv: "TEST_MCP_OAUTH_MISSING", + }, + }, nil) + Expect(err).To(MatchError(ContainSubstring("TEST_MCP_OAUTH_MISSING"))) + }) + + It("keeps the static token path unchanged and never contacts a token endpoint", func() { + rt, err := newRemoteServerTransport(config.MCPRemoteServer{URL: rec.server.URL, Token: "static"}, httpclient.HardenedTransport()) + Expect(err).NotTo(HaveOccurred()) + Expect(doGet(httpclient.New(httpclient.WithTransport(rt)), rec.server.URL)).To(Succeed()) + + rt, err = newRemoteServerTransport(config.MCPRemoteServer{URL: rec.server.URL}, httpclient.HardenedTransport()) + Expect(err).NotTo(HaveOccurred()) + Expect(doGet(httpclient.New(httpclient.WithTransport(rt)), rec.server.URL)).To(Succeed()) + + Expect(rec.headers()).To(Equal([]string{"Bearer static", ""})) + Expect(idp.requests.Load()).To(BeZero()) + }) + + It("rejects a static token combined with oauth2", func() { + _, err := newRemoteServerTransport(config.MCPRemoteServer{ + URL: rec.server.URL, + Token: "static", + OAuth2: &config.MCPOAuth2Config{TokenURL: idp.server.URL, ClientID: "a", ClientSecret: "b"}, + }, nil) + Expect(err).To(MatchError(ContainSubstring("mutually exclusive"))) + }) + + Context("against a real MCP server behind a token check", func() { + It("establishes a streamable-HTTP session through NamedSessionsFromMCPConfig", func() { + ts := protectedMCPServer(false, func() string { return "tok-1" }) + modelName := "oauth2-mcp-streamable-test" + DeferCleanup(func() { CloseMCPSessions(modelName) }) + + remote := config.MCPGenericConfig[config.MCPRemoteServers]{ + Servers: config.MCPRemoteServers{ + "protected": {URL: ts.URL, OAuth2: &config.MCPOAuth2Config{ + TokenURL: idp.server.URL, ClientID: "localai", ClientSecret: "s3cret", + }}, + }, + } + sessions, err := NamedSessionsFromMCPConfig(modelName, remote, config.MCPGenericConfig[config.MCPSTDIOServers]{}, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(sessions).To(HaveLen(1)) + Expect(sessions[0].Error).To(BeEmpty()) + Expect(sessions[0].Session).NotTo(BeNil()) + + tools, err := DiscoverMCPTools(context.Background(), sessions) + Expect(err).NotTo(HaveOccurred()) + Expect(tools).To(HaveLen(1)) + Expect(tools[0].ToolName).To(Equal("whoami")) + Expect(idp.requests.Load()).To(BeEquivalentTo(1)) + }) + + It("counter-check: the same server refuses the session without oauth2", func() { + ts := protectedMCPServer(false, func() string { return "tok-1" }) + modelName := "oauth2-mcp-streamable-negative-test" + DeferCleanup(func() { CloseMCPSessions(modelName) }) + + remote := config.MCPGenericConfig[config.MCPRemoteServers]{ + Servers: config.MCPRemoteServers{"protected": {URL: ts.URL}}, + } + sessions, err := NamedSessionsFromMCPConfig(modelName, remote, config.MCPGenericConfig[config.MCPSTDIOServers]{}, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(sessions).To(HaveLen(1)) + Expect(sessions[0].Session).To(BeNil()) + Expect(sessions[0].Error).To(ContainSubstring("connection failed")) + }) + + It("also authenticates the SSE transport", func() { + ts := protectedMCPServer(true, func() string { return "tok-1" }) + rt, err := newRemoteServerTransport(config.MCPRemoteServer{ + URL: ts.URL, + OAuth2: &config.MCPOAuth2Config{ + TokenURL: idp.server.URL, ClientID: "localai", ClientSecret: "s3cret", + }, + }, httpclient.HardenedTransport()) + Expect(err).NotTo(HaveOccurred()) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) + session, err := client.Connect(ctx, &mcp.SSEClientTransport{ + Endpoint: ts.URL, + HTTPClient: httpclient.New(httpclient.WithTransport(rt)), + }, nil) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = session.Close() }) + + res, err := session.ListTools(ctx, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Tools).To(HaveLen(1)) + Expect(idp.requests.Load()).To(BeEquivalentTo(1)) + }) + }) +}) diff --git a/core/http/endpoints/mcp/tools.go b/core/http/endpoints/mcp/tools.go index 02ea46037..5f9e08702 100644 --- a/core/http/endpoints/mcp/tools.go +++ b/core/http/endpoints/mcp/tools.go @@ -228,10 +228,14 @@ func SessionsFromMCPConfig( // Get the list of all the tools that the Agent will be esposed to for _, server := range remote.Servers { xlog.Debug("[MCP remote server] Configuration", "server", server) - // Create HTTP client with custom roundtripper for bearer token injection + authTransport, err := newRemoteServerTransport(server, httpclient.HardenedTransport()) + if err != nil { + xlog.Error("Invalid MCP server authentication", "error", err, "url", server.URL) + continue + } httpClient := httpclient.New( httpclient.WithTimeout(config.DefaultMCPToolTimeout), - httpclient.WithTransport(newBearerTokenRoundTripper(server.Token, httpclient.HardenedTransport())), + httpclient.WithTransport(authTransport), ) transport := &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: httpClient} @@ -339,9 +343,19 @@ func NamedSessionsFromMCPConfig( for serverName, server := range remote.Servers { xlog.Debug("[MCP remote server] Configuration", "name", serverName, "server", server) + authTransport, err := newRemoteServerTransport(server, httpclient.HardenedTransport()) + if err != nil { + xlog.Error("Invalid MCP server authentication", "error", err, "name", serverName, "url", server.URL) + allSessions = append(allSessions, NamedSession{ + Name: serverName, + Type: "remote", + Error: fmt.Sprintf("authentication setup failed: %v", err), + }) + continue + } httpClient := httpclient.New( httpclient.WithTimeout(config.DefaultMCPToolTimeout), - httpclient.WithTransport(newBearerTokenRoundTripper(server.Token, httpclient.HardenedTransport())), + httpclient.WithTransport(authTransport), ) transport := &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: httpClient} diff --git a/docs/content/features/mcp.md b/docs/content/features/mcp.md index d898434a3..5b05f37ce 100644 --- a/docs/content/features/mcp.md +++ b/docs/content/features/mcp.md @@ -28,7 +28,7 @@ The Model Context Protocol is a standard for connecting AI models to external to - **Real-time Tool Access**: Connect to external MCP servers for live data - **Multiple Server Support**: Configure both remote HTTP and local stdio servers - **Cached Connections**: Efficient tool caching for better performance -- **Secure Authentication**: Support for bearer token authentication +- **Secure Authentication**: Static bearer tokens or OAuth2 `client_credentials` with automatic token refresh - **Multi-endpoint Support**: Works with OpenAI Chat, Anthropic Messages, and Open Responses APIs - **Selective Server Activation**: Use `metadata.mcp_servers` to enable only specific servers per request - **Server-side Tool Execution**: Tools are executed on the server and results fed back to the model automatically @@ -93,10 +93,52 @@ In the interactive model editor, **Remote MCP Servers** and **MCP STDIO Servers* Configure HTTP-based MCP servers: - **`url`**: The MCP server endpoint URL -- **`token`**: Bearer token for authentication (optional) +- **`token`**: Static bearer token for authentication (optional) +- **`oauth2`**: OAuth2 `client_credentials` settings (optional, mutually exclusive with `token`); see [OAuth2 client credentials](#oauth2-client-credentials-for-remote-servers) Remote model MCP connections originate from the LocalAI process. If LocalAI runs in Docker, the URL must therefore resolve and be reachable **from the LocalAI container**, not only from the host browser. For another service in the same Compose project, use its Compose service name and container port. Host-only DNS names, VPN DNS, and private routes must also be made available inside the container. +#### OAuth2 client credentials for remote servers + +When an MCP server sits behind an OpenID Connect / OAuth2 provider (for example authentik, Keycloak, Entra ID or Auth0), LocalAI can obtain short-lived access tokens with the `client_credentials` grant instead of using a long-lived static `token`: + +```yaml +mcp: + remote: | + { + "mcpServers": { + "internal-tools": { + "url": "https://mcp.example.com/mcp", + "oauth2": { + "token_url": "https://auth.example.com/application/o/token/", + "client_id": "localai", + "client_secret_env": "MCP_INTERNAL_TOOLS_SECRET", + "scopes": ["mcp:tools"], + "endpoint_params": {"audience": "mcp-gateway"} + } + } + } + } +``` + +| Field | Description | +|---|---| +| `token_url` | Token endpoint of the identity provider (required) | +| `client_id` / `client_id_env` | Client ID, literally or as the name of an environment variable (exactly one is required) | +| `client_secret` / `client_secret_env` | Client secret, literally or as the name of an environment variable (exactly one is required). Prefer `client_secret_env` so the secret is not stored in the model YAML | +| `scopes` | Scopes to request (optional) | +| `endpoint_params` | Extra form parameters for the token request, e.g. `audience` or `resource` (RFC 8707) (optional) | + +Behaviour: + +- The token is fetched on the first request to the server, cached, and sent as `Authorization: Bearer ` on every request, for both streamable HTTP and SSE transports. +- It is refreshed proactively once **half** of its lifetime (`expires_in`) has elapsed. If the provider omits `expires_in`, a lifetime of 5 minutes is assumed. +- If a refresh fails, the current token keeps being used until 30 seconds before it expires (refresh is retried at most every 5 seconds). After that, requests to the MCP server fail with an error; LocalAI never falls back to an unauthenticated request. +- Configuring both `token` and `oauth2` for the same server is rejected when the model configuration is loaded, as are missing or duplicated `client_id`/`client_secret` sources. An environment variable named in `*_env` that is unset or empty makes the connection to that server fail, and the server is reported with an error in the server listing. +- The environment variables are read by the process that connects to the MCP server. In distributed mode that is the agent worker, so set them there. + +Servers configured with a static `token` behave exactly as before. + #### STDIO Servers (`stdio`) Configure local command-based MCP servers: