Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions cmd/root/debug_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,19 @@ func newDebugOAuthLoginCmd() *cobra.Command {
return err
}

serverURL, err := findMCPRemoteURL(cfg, mcpName)
remote, err := findMCPRemote(cfg, mcpName)
if err != nil {
return err
}

w := cmd.OutOrStdout()
fmt.Fprintf(w, "Starting OAuth login for %s (%s)...\n", mcpName, serverURL)
fmt.Fprintf(w, "Starting OAuth login for %s (%s)...\n", mcpName, remote.URL)

if err := mcp.PerformOAuthLogin(ctx, serverURL); err != nil {
if err := mcp.PerformOAuthLogin(ctx, remote); err != nil {
return fmt.Errorf("OAuth login failed: %w", err)
}

fmt.Fprintf(w, "✅ OAuth login successful for %s\n", serverURL)
fmt.Fprintf(w, "✅ OAuth login successful for %s\n", remote.URL)
return nil
},
}
Expand All @@ -203,20 +203,26 @@ func newDebugOAuthLoginCmd() *cobra.Command {
return cmd
}

// findMCPRemoteURL looks up the remote URL for the named MCP server in the config.
// It matches by name (top-level mcps key or toolset name), by URL substring,
// or returns the only remote MCP if there is exactly one.
func findMCPRemoteURL(cfg *latest.Config, name string) (string, error) {
// Collect all remote MCP URLs with their identifiers.
// findMCPRemote looks up the full remote configuration for the named MCP
// server in the config. It matches by exact name (top-level mcps key or
// toolset name) or by exact URL equality; a URL substring or prefix does
// not match.
//
// The returned latest.Remote is passed to PerformOAuthLogin verbatim:
// Remote.URL is used as-is for the OAuth probe, resource indicator, and
// token-store key, and Remote.OAuth carries any explicit client
// credentials/scopes configured for this MCP server.
func findMCPRemote(cfg *latest.Config, name string) (latest.Remote, error) {
// Collect all remote MCP entries with their identifiers.
type mcpEntry struct {
label string
url string
label string
remote latest.Remote
}
var all []mcpEntry

for k, m := range cfg.MCPs {
if m.Remote.URL != "" {
all = append(all, mcpEntry{label: k, url: m.Remote.URL})
all = append(all, mcpEntry{label: k, remote: m.Remote})
}
}
for _, agent := range cfg.Agents {
Expand All @@ -226,22 +232,22 @@ func findMCPRemoteURL(cfg *latest.Config, name string) (string, error) {
if label == "" {
label = ts.Remote.URL
}
all = append(all, mcpEntry{label: label, url: ts.Remote.URL})
all = append(all, mcpEntry{label: label, remote: ts.Remote})
}
}
}

// Exact match by name/label.
for _, e := range all {
if e.label == name {
return e.url, nil
return e.remote, nil
}
}

// Exact match by URL.
for _, e := range all {
if e.url == name {
return e.url, nil
if e.remote.URL == name {
return e.remote, nil
}
}

Expand All @@ -251,7 +257,7 @@ func findMCPRemoteURL(cfg *latest.Config, name string) (string, error) {
labels = append(labels, e.label)
}
if len(labels) > 0 {
return "", fmt.Errorf("MCP %q not found; available: %v", name, labels)
return latest.Remote{}, fmt.Errorf("MCP %q not found; available: %v", name, labels)
}
return "", fmt.Errorf("MCP %q not found; no remote MCPs found in config", name)
return latest.Remote{}, fmt.Errorf("MCP %q not found; no remote MCPs found in config", name)
}
164 changes: 164 additions & 0 deletions cmd/root/debug_oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package root

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/config/latest"
)

// TestFindMCPRemote_MatchesTopLevelMCPByName covers the top-level `mcps:`
// map lookup path and proves the full latest.Remote (URL and OAuth config)
// is returned, not just the URL, so PerformOAuthLogin sees the same
// explicit client credentials/scopes the runtime would use.
func TestFindMCPRemote_MatchesTopLevelMCPByName(t *testing.T) {
t.Parallel()

oauthConfig := &latest.RemoteOAuthConfig{ClientID: "configured-client", Scopes: []string{"scope-a"}}
cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"atlassian": {Toolset: latest.Toolset{
Type: "mcp",
Remote: latest.Remote{URL: "https://mcp.atlassian.com/v1/mcp/authv2", OAuth: oauthConfig},
}},
},
}

remote, err := findMCPRemote(cfg, "atlassian")
require.NoError(t, err)
assert.Equal(t, "https://mcp.atlassian.com/v1/mcp/authv2", remote.URL)
require.NotNil(t, remote.OAuth)
assert.Equal(t, "configured-client", remote.OAuth.ClientID)
assert.Equal(t, []string{"scope-a"}, remote.OAuth.Scopes)
}

// TestFindMCPRemote_MatchesAgentEmbeddedToolsetByName covers the
// agent.Toolsets lookup path (an MCP toolset declared inline on an agent
// rather than in the top-level `mcps:` map).
func TestFindMCPRemote_MatchesAgentEmbeddedToolsetByName(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
Agents: latest.Agents{
{
Toolsets: []latest.Toolset{
{Type: "mcp", Name: "inline-mcp", Remote: latest.Remote{URL: "https://example.test/mcp"}},
},
},
},
}

remote, err := findMCPRemote(cfg, "inline-mcp")
require.NoError(t, err)
assert.Equal(t, "https://example.test/mcp", remote.URL)
}

// TestFindMCPRemote_MatchesByURL proves the exact-URL lookup path works
// when the caller passes a URL instead of a name.
func TestFindMCPRemote_MatchesByURL(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"atlassian": {Toolset: latest.Toolset{
Type: "mcp",
Remote: latest.Remote{URL: "https://mcp.atlassian.com/v1/mcp/authv2"},
}},
},
}

remote, err := findMCPRemote(cfg, "https://mcp.atlassian.com/v1/mcp/authv2")
require.NoError(t, err)
assert.Equal(t, "https://mcp.atlassian.com/v1/mcp/authv2", remote.URL)
}

// TestFindMCPRemote_NameMatchWinsOverURLMatch proves name/label matching is
// tried before URL matching, so a name that also happens to be a URL used
// by a different entry does not cause ambiguity.
func TestFindMCPRemote_NameMatchWinsOverURLMatch(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"first": {Toolset: latest.Toolset{Type: "mcp", Remote: latest.Remote{URL: "https://first.example.test/mcp"}}},
"second": {Toolset: latest.Toolset{Type: "mcp", Remote: latest.Remote{URL: "first"}}},
},
}

remote, err := findMCPRemote(cfg, "first")
require.NoError(t, err)
assert.Equal(t, "https://first.example.test/mcp", remote.URL, "the entry labeled \"first\" must win over the entry whose URL is literally \"first\"")
}

// TestFindMCPRemote_URLPrefixDoesNotMatch proves matching is exact URL
// equality, not substring/prefix matching: a truncated form of a
// configured URL must not match and must return the not-found error.
func TestFindMCPRemote_URLPrefixDoesNotMatch(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"atlassian": {Toolset: latest.Toolset{
Type: "mcp",
Remote: latest.Remote{URL: "https://mcp.atlassian.com/v1/mcp/authv2"},
}},
},
}

_, err := findMCPRemote(cfg, "https://mcp.atlassian.com/v1/mcp")
require.Error(t, err)
assert.Contains(t, err.Error(), "https://mcp.atlassian.com/v1/mcp")
assert.Contains(t, err.Error(), "atlassian")
}

// TestFindMCPRemote_NotFound_ListsAvailableNames covers the not-found error
// path when at least one remote MCP exists: the error must list the
// available names to help the user pick a valid one.
func TestFindMCPRemote_NotFound_ListsAvailableNames(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"atlassian": {Toolset: latest.Toolset{Type: "mcp", Remote: latest.Remote{URL: "https://mcp.atlassian.com/mcp"}}},
},
}

_, err := findMCPRemote(cfg, "does-not-exist")
require.Error(t, err)
assert.Contains(t, err.Error(), "does-not-exist")
assert.Contains(t, err.Error(), "atlassian")
}

// TestFindMCPRemote_NotFound_NoRemoteMCPsInConfig covers the not-found error
// path when the config has no remote MCPs at all.
func TestFindMCPRemote_NotFound_NoRemoteMCPsInConfig(t *testing.T) {
t.Parallel()

cfg := &latest.Config{}

_, err := findMCPRemote(cfg, "anything")
require.Error(t, err)
assert.Contains(t, err.Error(), "no remote MCPs found in config")
}

// TestFindMCPRemote_IgnoresNonMCPToolsetsAndEmptyRemotes proves stdio MCPs
// (Remote.URL empty) and non-mcp toolsets on agents are skipped rather than
// matched or surfaced as ambiguous candidates.
func TestFindMCPRemote_IgnoresNonMCPToolsetsAndEmptyRemotes(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"stdio-mcp": {Toolset: latest.Toolset{Type: "mcp", Command: "some-binary"}},
},
Agents: latest.Agents{
{Toolsets: []latest.Toolset{{Type: "shell", Name: "shell-tool"}}},
},
}

_, err := findMCPRemote(cfg, "stdio-mcp")
require.Error(t, err)
assert.Contains(t, err.Error(), "no remote MCPs found in config")
}
5 changes: 4 additions & 1 deletion pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1756,7 +1756,10 @@ func writeJSONRPC(t *testing.T, w http.ResponseWriter, id json.RawMessage, resul
// - the MCP endpoint challenges with 401 + WWW-Authenticate (or at
// least surfaces a reachable origin),
// - <baseURL>/.well-known/oauth-protected-resource is reachable (200
// or 404 — either is fine, the WWW-Authenticate fallback covers 404),
// or 404 — either is fine: it is only the last of the ordered
// protected-resource metadata candidates pkg/tools/mcp/oauth_login.go
// walks, after the challenge's exact resource_metadata and the RFC
// 9728 §3.1 path-insertion URL),
// - the authorization-server metadata advertises an HTTPS
// `registration_endpoint` (Dynamic Client Registration is REQUIRED
// by pkg/tools/mcp/oauth_login.go: without it docker-agent cannot
Expand Down
Loading
Loading