mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add user_oidc auth type for MCP servers (#24793)
Adds a 5th MCP server authentication mode, `user_oidc` ("User OIDC
Identity"), that forwards the calling user's OIDC access token from
`user_links.oauth_access_token` to the upstream MCP server as
`Authorization: Bearer <token>`.
The token is read from `user_links` and refreshed transparently via
`oauth2.TokenSource` before each MCP request. No new per-MCP-server
secret storage and no per-user connect/disconnect step.
**Limitation**: only users who logged in via OIDC have a forwardable
token. Users authenticated via password or GitHub will see requests sent
without an `Authorization` header, and the upstream MCP server is
expected to respond with 401. A pluggable token source (e.g. CLI-minted
E2E tokens) is left as future work.
<details>
<summary>Implementation notes</summary>
- Schema: new
`coderd/database/migrations/000481_mcp_user_oidc_auth.{up,down}.sql`
relaxes the `mcp_server_configs.auth_type` CHECK constraint to include
`user_oidc`. Down migration deletes affected rows before restoring the
old constraint.
- SDK validation: `codersdk/mcp.go` extends `oneof` for
`CreateMCPServerConfigRequest` and `UpdateMCPServerConfigRequest`.
- Handler: `coderd/mcp.go` adds `case "user_oidc":` to the
field-clearing switch on update. The existing list and detail handlers
already report `auth_connected = true` for any non-`oauth2` auth type.
- Header construction: `coderd/x/chatd/mcpclient/mcpclient.go`
introduces a `UserOIDCTokenSource` interface and adds the `user_oidc`
case to `buildAuthHeaders`. `ConnectAll` / `connectOne` /
`buildAuthHeaders` gain `userID uuid.UUID, oidcSrc UserOIDCTokenSource`
parameters.
- Wiring: `coderd/x/chatd/chatd.go` adds `OIDCTokenSource` to `Config` /
`Server` and passes `chat.OwnerID` plus the source through `ConnectAll`.
`coderd/coderd.go` constructs the source next to the `chatd.New` call
when `options.OIDCConfig` is non-nil.
- Token source: `oidcMCPTokenSource` lives in `coderd/mcp.go`. It reads
the user's OIDC link, refreshes via `oauth2.TokenSource`, and writes the
refreshed token back to `user_links`. Logic is duplicated from
`provisionerdserver.ObtainOIDCAccessToken` to avoid an MCP ->
provisionerdserver dependency. The two copies must be kept in sync; a
comment on `oidcMCPTokenSource` records this.
- Frontend: `MCPServerAdminPanel.tsx` adds the new dropdown option, an
explanatory helper block (no admin-configurable fields), and a Storybook
story (`CreateServerUserOIDC`).
- Tests:
- `mcpclient_test.go`: `TestConnectAll_UserOIDCAuth`,
`TestConnectAll_UserOIDCAuth_NoLink`,
`TestConnectAll_UserOIDCAuth_NilSource`. All existing tests updated for
the new signature.
- `mcp_test.go`: extends `TestMCPServerConfigsAuthConnected` to assert
`auth_connected=true` for `user_oidc`; adds
`TestMCPServerConfigsUserOIDCClearsFields` and
`TestMCPServerConfigsUserOIDCDirect`.
- Docs: `docs/ai-coder/agents/platform-controls/mcp-servers.md`
describes the new mode and its OIDC-only limitation.
</details>
This PR was created by Coder Agents.
---------
Co-authored-by: Coder Agents <agents@coder.com>
This commit is contained in:
co-authored by
Coder Agents
parent
6b9637d85a
commit
d889ba1842
@@ -162,6 +162,7 @@ type Server struct {
|
||||
pubsub pubsub.Pubsub
|
||||
webpushDispatcher webpush.Dispatcher
|
||||
providerAPIKeys chatprovider.ProviderAPIKeys
|
||||
oidcTokenSource mcpclient.UserOIDCTokenSource
|
||||
debugSvc *chatdebug.Service
|
||||
debugSvcFactory func() *chatdebug.Service
|
||||
debugSvcReady atomic.Bool
|
||||
@@ -3840,6 +3841,12 @@ type Config struct {
|
||||
UsageTracker *workspacestats.UsageTracker
|
||||
Clock quartz.Clock
|
||||
PrometheusRegistry prometheus.Registerer
|
||||
|
||||
// OIDCTokenSource resolves the calling user's OIDC access
|
||||
// token for MCP servers configured with auth_type=user_oidc.
|
||||
// May be nil if the deployment has no OIDC provider; servers
|
||||
// using user_oidc will then send no Authorization header.
|
||||
OIDCTokenSource mcpclient.UserOIDCTokenSource
|
||||
}
|
||||
|
||||
// New creates a new chat processor. The processor polls for pending
|
||||
@@ -3898,6 +3905,7 @@ func New(cfg Config) *Server {
|
||||
pubsub: cfg.Pubsub,
|
||||
webpushDispatcher: cfg.WebpushDispatcher,
|
||||
providerAPIKeys: cfg.ProviderAPIKeys,
|
||||
oidcTokenSource: cfg.OIDCTokenSource,
|
||||
debugSvcFactory: func() *chatdebug.Service {
|
||||
debugSvc := chatdebug.NewService(
|
||||
cfg.Database,
|
||||
@@ -6472,7 +6480,7 @@ func (p *Server) runChat(
|
||||
// Refresh expired OAuth2 tokens before connecting.
|
||||
mcpTokens = p.refreshExpiredMCPTokens(ctx, logger, mcpConnectConfigs, mcpTokens)
|
||||
mcpTools, mcpCleanup = mcpclient.ConnectAll(
|
||||
ctx, logger, mcpConnectConfigs, mcpTokens,
|
||||
ctx, logger, mcpConnectConfigs, mcpTokens, chat.OwnerID, p.oidcTokenSource,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -49,6 +49,18 @@ const connectTimeout = 10 * time.Second
|
||||
// take before being canceled.
|
||||
const toolCallTimeout = 60 * time.Second
|
||||
|
||||
// UserOIDCTokenSource resolves the OIDC access token for the calling
|
||||
// user. Implementations attempt to refresh tokens that are expired
|
||||
// or close to expiring and MUST return ("", nil) when the user has
|
||||
// no OIDC link or a refresh attempt failed for any reason. A
|
||||
// non-nil error is reserved for unexpected infrastructure failures
|
||||
// (e.g. database errors) and skips header construction entirely.
|
||||
// The empty-token-on-refresh-failure behavior matches
|
||||
// provisionerdserver.ObtainOIDCAccessToken.
|
||||
type UserOIDCTokenSource interface {
|
||||
OIDCAccessToken(ctx context.Context, userID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
// ConnectAll connects to all configured MCP servers, discovers
|
||||
// their tools, and returns them as fantasy.AgentTool values.
|
||||
// Tools are sorted by their prefixed name so callers
|
||||
@@ -60,6 +72,8 @@ func ConnectAll(
|
||||
logger slog.Logger,
|
||||
configs []database.MCPServerConfig,
|
||||
tokens []database.MCPServerUserToken,
|
||||
userID uuid.UUID,
|
||||
oidcSrc UserOIDCTokenSource,
|
||||
) ([]fantasy.AgentTool, func()) {
|
||||
// Index tokens by server config ID so auth header
|
||||
// construction is O(1) per server.
|
||||
@@ -95,7 +109,7 @@ func ConnectAll(
|
||||
|
||||
eg.Go(func() error {
|
||||
serverTools, mcpClient, connectErr := connectOne(
|
||||
ctx, logger, cfg, tokensByConfigID,
|
||||
ctx, logger, cfg, tokensByConfigID, userID, oidcSrc,
|
||||
)
|
||||
if connectErr != nil {
|
||||
logger.Warn(ctx,
|
||||
@@ -159,8 +173,10 @@ func connectOne(
|
||||
logger slog.Logger,
|
||||
cfg database.MCPServerConfig,
|
||||
tokensByConfigID map[uuid.UUID]database.MCPServerUserToken,
|
||||
userID uuid.UUID,
|
||||
oidcSrc UserOIDCTokenSource,
|
||||
) ([]fantasy.AgentTool, *client.Client, error) {
|
||||
headers := buildAuthHeaders(ctx, logger, cfg, tokensByConfigID)
|
||||
headers := buildAuthHeaders(ctx, logger, cfg, tokensByConfigID, userID, oidcSrc)
|
||||
|
||||
tr, err := createTransport(cfg, headers)
|
||||
if err != nil {
|
||||
@@ -285,6 +301,8 @@ func buildAuthHeaders(
|
||||
logger slog.Logger,
|
||||
cfg database.MCPServerConfig,
|
||||
tokensByConfigID map[uuid.UUID]database.MCPServerUserToken,
|
||||
userID uuid.UUID,
|
||||
oidcSrc UserOIDCTokenSource,
|
||||
) map[string]string {
|
||||
// Using map[string]string rather than http.Header because
|
||||
// the mcp-go transport options accept map[string]string.
|
||||
@@ -347,6 +365,40 @@ func buildAuthHeaders(
|
||||
}
|
||||
}
|
||||
}
|
||||
case "user_oidc":
|
||||
// Forward the calling user's OIDC access token from
|
||||
// user_links as Authorization: Bearer <token>. The token
|
||||
// source is responsible for refreshing tokens that are
|
||||
// expired or close to expiring before returning them.
|
||||
if oidcSrc == nil || userID == uuid.Nil {
|
||||
logger.Warn(ctx,
|
||||
"user_oidc auth requested but no token source available",
|
||||
slog.F("server_slug", cfg.Slug),
|
||||
)
|
||||
break
|
||||
}
|
||||
token, err := oidcSrc.OIDCAccessToken(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn(ctx,
|
||||
"failed to obtain user OIDC token for MCP server",
|
||||
slog.F("server_slug", cfg.Slug),
|
||||
slog.Error(err),
|
||||
)
|
||||
break
|
||||
}
|
||||
if token == "" {
|
||||
// The user has no OIDC link, or a non-fatal refresh
|
||||
// failure occurred. Fall through with no header and let
|
||||
// the upstream MCP server decide how to respond
|
||||
// (typically 401). Logged at debug so password and
|
||||
// GitHub users don't generate noise for every chat turn.
|
||||
logger.Debug(ctx,
|
||||
"no user OIDC token available for MCP server",
|
||||
slog.F("server_slug", cfg.Slug),
|
||||
)
|
||||
break
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
case "none", "":
|
||||
// No auth headers needed.
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestConnectAll_DiscoverTools(t *testing.T) {
|
||||
ts := newTestMCPServer(t, echoTool(), greetTool())
|
||||
|
||||
cfg := makeConfig("myserver", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
// Two tools should be discovered, namespaced with the server slug.
|
||||
@@ -121,7 +121,7 @@ func TestConnectAll_CallTool(t *testing.T) {
|
||||
ts := newTestMCPServer(t, echoTool())
|
||||
|
||||
cfg := makeConfig("srv", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestConnectAll_ToolAllowList(t *testing.T) {
|
||||
// Only allow the "echo" tool.
|
||||
cfg.ToolAllowList = []string{"echo"}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
@@ -165,7 +165,7 @@ func TestConnectAll_ToolDenyList(t *testing.T) {
|
||||
// Deny the "greet" tool, so only "echo" remains.
|
||||
cfg.ToolDenyList = []string{"greet"}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
@@ -179,7 +179,7 @@ func TestConnectAll_ConnectionFailure(t *testing.T) {
|
||||
|
||||
cfg := makeConfig("bad", "http://127.0.0.1:0/does-not-exist")
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
assert.Empty(t, tools, "no tools should be returned for an unreachable server")
|
||||
@@ -200,6 +200,7 @@ func TestConnectAll_MultipleServers(t *testing.T) {
|
||||
ctx, logger,
|
||||
[]database.MCPServerConfig{cfg1, cfg2},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -225,6 +226,7 @@ func TestConnectAll_NoToolsAfterFiltering(t *testing.T) {
|
||||
logger,
|
||||
[]database.MCPServerConfig{cfg},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
|
||||
require.Empty(t, tools)
|
||||
@@ -252,6 +254,7 @@ func TestConnectAll_DeterministicOrder(t *testing.T) {
|
||||
makeConfig("srv2", ts2.URL),
|
||||
},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -280,6 +283,7 @@ func TestConnectAll_DeterministicOrder(t *testing.T) {
|
||||
makeConfig("aaa", other.URL),
|
||||
},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -312,6 +316,7 @@ func TestConnectAll_DeterministicOrder(t *testing.T) {
|
||||
logger,
|
||||
[]database.MCPServerConfig{cfg1, cfg2},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -375,6 +380,7 @@ func TestConnectAll_AuthHeaders(t *testing.T) {
|
||||
ctx, logger,
|
||||
[]database.MCPServerConfig{cfg},
|
||||
[]database.MCPServerUserToken{token},
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -429,7 +435,7 @@ func TestConnectAll_DisabledServer(t *testing.T) {
|
||||
cfg := makeConfig("disabled", ts.URL)
|
||||
cfg.Enabled = false
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
assert.Empty(t, tools)
|
||||
}
|
||||
@@ -444,7 +450,7 @@ func TestConnectAll_CallToolInvalidInput(t *testing.T) {
|
||||
ts := newTestMCPServer(t, echoTool())
|
||||
|
||||
cfg := makeConfig("srv", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -469,7 +475,7 @@ func TestConnectAll_ToolInfoParameters(t *testing.T) {
|
||||
ts := newTestMCPServer(t, echoTool())
|
||||
|
||||
cfg := makeConfig("srv", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -511,7 +517,7 @@ func TestConnectAll_NilRequiredBecomesEmptySlice(t *testing.T) {
|
||||
|
||||
ts := newTestMCPServer(t, noRequiredTool)
|
||||
cfg := makeConfig("srv", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -563,6 +569,7 @@ func TestConnectAll_APIKeyAuth(t *testing.T) {
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(
|
||||
ctx, logger, []database.MCPServerConfig{cfg}, nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -619,6 +626,7 @@ func TestConnectAll_CustomHeadersAuth(t *testing.T) {
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(
|
||||
ctx, logger, []database.MCPServerConfig{cfg}, nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -655,6 +663,7 @@ func TestConnectAll_CustomHeadersInvalidJSON(t *testing.T) {
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(
|
||||
ctx, logger, []database.MCPServerConfig{cfg}, nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -664,6 +673,158 @@ func TestConnectAll_CustomHeadersInvalidJSON(t *testing.T) {
|
||||
assert.Equal(t, "badjson__echo", tools[0].Info().Name)
|
||||
}
|
||||
|
||||
// staticOIDCSource implements mcpclient.UserOIDCTokenSource for tests
|
||||
// without requiring a real OIDC provider or database round-trip.
|
||||
type staticOIDCSource struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticOIDCSource) OIDCAccessToken(_ context.Context, _ uuid.UUID) (string, error) {
|
||||
return s.token, s.err
|
||||
}
|
||||
|
||||
// TestConnectAll_UserOIDCAuth verifies that the user_oidc auth type
|
||||
// forwards the calling user's OIDC access token from the
|
||||
// UserOIDCTokenSource as Authorization: Bearer <token>.
|
||||
func TestConnectAll_UserOIDCAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
seenHeaders []string
|
||||
)
|
||||
|
||||
srv := mcpserver.NewMCPServer("oidc-server", "1.0.0")
|
||||
srv.AddTools(mcpserver.ServerTool{
|
||||
Tool: mcp.NewTool("whoami",
|
||||
mcp.WithDescription("Returns the auth header"),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
auth := req.Header.Get("Authorization")
|
||||
mu.Lock()
|
||||
seenHeaders = append(seenHeaders, auth)
|
||||
mu.Unlock()
|
||||
return mcp.NewToolResultText("auth:" + auth), nil
|
||||
},
|
||||
})
|
||||
|
||||
httpSrv := mcpserver.NewStreamableHTTPServer(srv)
|
||||
ts := httptest.NewServer(httpSrv)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := makeConfig("oidc-srv", ts.URL)
|
||||
cfg.AuthType = "user_oidc"
|
||||
userID := uuid.New()
|
||||
src := staticOIDCSource{token: "fake-oidc-token"}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(
|
||||
ctx, logger, []database.MCPServerConfig{cfg}, nil,
|
||||
userID, src,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
resp, err := tools[0].Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-oidc",
|
||||
Name: "oidc-srv__whoami",
|
||||
Input: "{}",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.Equal(t, "auth:Bearer fake-oidc-token", resp.Content)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.NotEmpty(t, seenHeaders)
|
||||
assert.Equal(t, "Bearer fake-oidc-token", seenHeaders[len(seenHeaders)-1])
|
||||
}
|
||||
|
||||
// TestConnectAll_UserOIDCAuth_NoLink verifies that when the token
|
||||
// source returns ("", nil) (the user has no OIDC link), the request
|
||||
// is still made but with no Authorization header. The MCP server is
|
||||
// then free to respond with 401 or proceed unauthenticated.
|
||||
func TestConnectAll_UserOIDCAuth_NoLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
seenHeaders []string
|
||||
)
|
||||
|
||||
srv := mcpserver.NewMCPServer("oidc-server-nolink", "1.0.0")
|
||||
srv.AddTools(mcpserver.ServerTool{
|
||||
Tool: mcp.NewTool("whoami",
|
||||
mcp.WithDescription("Returns the auth header"),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
auth := req.Header.Get("Authorization")
|
||||
mu.Lock()
|
||||
seenHeaders = append(seenHeaders, auth)
|
||||
mu.Unlock()
|
||||
return mcp.NewToolResultText("auth:" + auth), nil
|
||||
},
|
||||
})
|
||||
|
||||
httpSrv := mcpserver.NewStreamableHTTPServer(srv)
|
||||
ts := httptest.NewServer(httpSrv)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := makeConfig("oidc-nolink", ts.URL)
|
||||
cfg.AuthType = "user_oidc"
|
||||
src := staticOIDCSource{token: "", err: nil}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(
|
||||
ctx, logger, []database.MCPServerConfig{cfg}, nil,
|
||||
uuid.New(), src,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
resp, err := tools[0].Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-oidc-nolink",
|
||||
Name: "oidc-nolink__whoami",
|
||||
Input: "{}",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.Equal(t, "auth:", resp.Content)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.NotEmpty(t, seenHeaders)
|
||||
assert.Empty(t, seenHeaders[len(seenHeaders)-1])
|
||||
}
|
||||
|
||||
// TestConnectAll_UserOIDCAuth_NilSource verifies that a nil token
|
||||
// source (e.g. deployment with no OIDC provider) yields no
|
||||
// Authorization header rather than panicking.
|
||||
func TestConnectAll_UserOIDCAuth_NilSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
ts := newTestMCPServer(t, echoTool())
|
||||
|
||||
cfg := makeConfig("oidc-nilsrc", ts.URL)
|
||||
cfg.AuthType = "user_oidc"
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(
|
||||
ctx, logger, []database.MCPServerConfig{cfg}, nil,
|
||||
uuid.New(), nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
assert.Equal(t, "oidc-nilsrc__echo", tools[0].Info().Name)
|
||||
}
|
||||
|
||||
// TestConnectAll_ParallelConnections verifies that connecting to
|
||||
// multiple MCP servers simultaneously returns all discovered
|
||||
// tools with the correct server slug prefixes.
|
||||
@@ -684,6 +845,7 @@ func TestConnectAll_ParallelConnections(t *testing.T) {
|
||||
ctx, logger,
|
||||
[]database.MCPServerConfig{cfg1, cfg2, cfg3},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -744,7 +906,7 @@ func TestConnectAll_ExpiredToken(t *testing.T) {
|
||||
Expiry: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true},
|
||||
}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token})
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
// The server accepts any auth, so the tool is still discovered
|
||||
@@ -777,7 +939,7 @@ func TestConnectAll_EmptyAccessToken(t *testing.T) {
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token})
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
// Tool is still discovered (server doesn't require auth), but
|
||||
@@ -807,7 +969,7 @@ func TestConnectAll_MCPToolIdentifier(t *testing.T) {
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
@@ -853,6 +1015,7 @@ func TestConnectAll_MCPToolIdentifier_MultipleServers(t *testing.T) {
|
||||
ctx, logger,
|
||||
[]database.MCPServerConfig{cfg1, cfg2},
|
||||
nil,
|
||||
uuid.Nil, nil,
|
||||
)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -909,7 +1072,7 @@ func TestConnectAll_EmbeddedResourceText(t *testing.T) {
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := makeConfig("embed-txt", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -976,7 +1139,7 @@ func TestConnectAll_EmbeddedResourceBlob(t *testing.T) {
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := makeConfig("embed-blob", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1056,7 +1219,7 @@ func TestConnectAll_ResourceLink(t *testing.T) {
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := makeConfig("res-link", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1100,7 +1263,7 @@ func TestConnectAll_CallToolError(t *testing.T) {
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := makeConfig("err-srv", ts.URL)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1124,7 +1287,7 @@ func TestModelIntent_Info_WrapsSchema(t *testing.T) {
|
||||
cfg := makeConfig("intent-srv", ts.URL)
|
||||
cfg.ModelIntent = true
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1160,7 +1323,7 @@ func TestModelIntent_Info_NoWrapWhenDisabled(t *testing.T) {
|
||||
cfg := makeConfig("no-intent", ts.URL)
|
||||
cfg.ModelIntent = false
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1183,7 +1346,7 @@ func TestModelIntent_Run_UnwrapsProperties(t *testing.T) {
|
||||
cfg := makeConfig("unwrap-srv", ts.URL)
|
||||
cfg.ModelIntent = true
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1208,7 +1371,7 @@ func TestModelIntent_Run_UnwrapsFlat(t *testing.T) {
|
||||
cfg := makeConfig("flat-srv", ts.URL)
|
||||
cfg.ModelIntent = true
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1233,7 +1396,7 @@ func TestModelIntent_Run_PassthroughWhenDisabled(t *testing.T) {
|
||||
cfg := makeConfig("pass-srv", ts.URL)
|
||||
cfg.ModelIntent = false
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
@@ -1258,7 +1421,7 @@ func TestModelIntent_Run_FallbackOnBadJSON(t *testing.T) {
|
||||
cfg := makeConfig("bad-srv", ts.URL)
|
||||
cfg.ModelIntent = true
|
||||
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
|
||||
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user