feat(mcp): support OAuth2 client_credentials for remote MCP servers

Remote MCP servers could only be authenticated with a static bearer
token. Deployments that put their MCP servers behind an OIDC provider
(authentik, Keycloak, Entra ID, ...) issue short-lived service tokens
instead, so LocalAI had to be given a long-lived key or could not
connect at all.

A remote server entry now accepts an optional "oauth2" block
(token_url, client_id/client_id_env, client_secret/client_secret_env,
scopes, endpoint_params). The token is fetched with the
client_credentials grant by an http.RoundTripper installed on the MCP
client's HTTP client, so streamable HTTP and SSE transports both use it.
The token is cached and refreshed at half of its lifetime; if a refresh
fails the current token is kept until 30s before expiry, after which
requests fail with an error instead of going out unauthenticated.

"token" and "oauth2" are mutually exclusive and inconsistent blocks are
rejected when the model config is loaded. Static tokens behave as
before. MCPRemoteServer now implements fmt.Stringer so the existing
debug logs that print whole server entries no longer write the token
or client secret in clear text.

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
Assisted-by: Claude Code:claude-opus-5-5
This commit is contained in:
Stefan Walcz committed 2026-10-03 20:20:13 +02:00
1 parent 9e373dba33
commit 954b9d25f7
6 files changed
+770 -5

No files matched your search

+76
View File
@@ -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"))
})
})
+70
View File
@@ -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
+189
View File
@@ -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
}
@@ -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 <want()>".
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))
})
})
})
+17 -3
View File
@@ -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}
+44 -2
View File
@@ -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 <token>` 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: