diff --git a/.dockerignore b/.dockerignore index d37e17333..99648bb51 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,5 @@ !./**/*.txt !/pkg/chatserver/openapi.json !/pkg/config/builtin-agents/*.yaml +!/pkg/tools/builtin/mcpcatalog/servers.json !/pkg/tui/styles/themes/*.yaml \ No newline at end of file diff --git a/agent-schema.json b/agent-schema.json index 6176efc02..df38485eb 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -1374,6 +1374,7 @@ "description": "Type of tool", "enum": [ "mcp", + "mcp_catalog", "script", "think", "memory", @@ -1636,6 +1637,7 @@ "type": { "enum": [ "mcp", + "mcp_catalog", "script", "think", "memory", diff --git a/examples/mcp_catalog.yaml b/examples/mcp_catalog.yaml new file mode 100644 index 000000000..c4a1db6ef --- /dev/null +++ b/examples/mcp_catalog.yaml @@ -0,0 +1,33 @@ +#!/usr/bin/env docker agent run + +# An agent that can discover, enable and use any remote streamable-http +# MCP server from the Docker MCP Catalog on demand. +# +# How it works: +# - The mcp_catalog toolset adds 4 meta-tools: +# * search_remote_mcp_servers — find servers by keyword +# * enable_remote_mcp_server — activate one (no network yet) +# * list_remote_mcp_servers — show what's currently active +# * disable_remote_mcp_server — turn one off again +# - Tools from servers that have not been enabled stay hidden, so the +# prompt is not flooded with hundreds of tool definitions. +# - When the model first calls a tool from an enabled server, the +# underlying connection is established and any required OAuth +# authorization URL is surfaced via the elicitation pipeline. +# - For api_key servers (e.g. Apify, Brave Search, Tavily, …), make +# sure the documented env var is exported before enabling the +# server. The catalog tells you exactly which one to set. + +agents: + root: + model: anthropic/claude-sonnet-4-6 + description: Agent that can on-demand connect to remote MCP servers from the Docker MCP Catalog. + instruction: | + You can discover and activate remote MCP servers on demand. + Use search_remote_mcp_servers to find a server matching the + user's intent, then enable_remote_mcp_server to activate it. + Be conservative: enable only the servers you actually need for + the task at hand. Disable a server with disable_remote_mcp_server + once you are done with it. + toolsets: + - type: mcp_catalog diff --git a/pkg/teamloader/registry.go b/pkg/teamloader/registry.go index 593d44612..51fc2386d 100644 --- a/pkg/teamloader/registry.go +++ b/pkg/teamloader/registry.go @@ -27,6 +27,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/fetch" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" "github.com/docker/docker-agent/pkg/tools/builtin/lsp" + "github.com/docker/docker-agent/pkg/tools/builtin/mcpcatalog" "github.com/docker/docker-agent/pkg/tools/builtin/memory" "github.com/docker/docker-agent/pkg/tools/builtin/modelpicker" "github.com/docker/docker-agent/pkg/tools/builtin/openapi" @@ -98,6 +99,7 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry { r.Register("filesystem", createFilesystemTool) r.Register("fetch", createFetchTool) r.Register("mcp", createMCPTool) + r.Register("mcp_catalog", createMCPCatalogTool) r.Register("api", createAPITool) r.Register("a2a", createA2ATool) r.Register("lsp", createLSPTool) @@ -444,6 +446,10 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon } } +func createMCPCatalogTool(_ context.Context, _ latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return mcpcatalog.New(runConfig.EnvProvider()), nil +} + func createA2ATool(ctx context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { expander := js.NewJsExpander(runConfig.EnvProvider()) diff --git a/pkg/tools/builtin/mcpcatalog/mcpcatalog.go b/pkg/tools/builtin/mcpcatalog/mcpcatalog.go new file mode 100644 index 000000000..a90f7b14f --- /dev/null +++ b/pkg/tools/builtin/mcpcatalog/mcpcatalog.go @@ -0,0 +1,664 @@ +// Package mcpcatalog exposes the Docker MCP Catalog's remote +// streamable-http servers as a single agent-side toolset that supports +// on-demand activation. +// +// The toolset surfaces five meta-tools to the model: +// +// - search_remote_mcp_servers — case-insensitive fuzzy search over the +// curated catalog (id / title / description / category / tags). +// - list_remote_mcp_servers — show currently enabled servers. +// - enable_remote_mcp_server — instantiate an *mcp.Toolset for a server +// (defers the actual TCP connect / OAuth handshake until Tools() is +// next enumerated). +// - disable_remote_mcp_server — stop the toolset and remove its tools. +// - reset_remote_mcp_server_auth — drop persisted OAuth credentials so +// the next enable triggers a fresh authorization flow. +// +// Activated servers' tools are merged into Tools(); tool list changes are +// reported via a tools.ChangeNotifier handler so the runtime refreshes +// the LLM's tool catalogue as soon as a server is enabled or disabled. +// +// Known limitation: the runtime's MCP-prompt discovery looks for +// `*mcp.Toolset` directly via tools.As, so prompts exposed by servers +// activated through this catalog are not surfaced via /prompts. Tools +// (the primary interface) work fine; the prompt feature would need a +// separate plumb-through interface to walk into container toolsets. +// +// On-demand semantics: the expensive parts — DNS, TCP, MCP handshake, +// OAuth flow — happen the first time Tools() is called for a freshly +// enabled server. The handshake runs through the same lifecycle.Supervisor +// the YAML-declared `mcp.remote` toolset uses, so OAuth elicitation and +// tool-list-change notifications behave identically. +package mcpcatalog + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/js" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tools/mcp" +) + +const ( + ToolNameSearch = "search_remote_mcp_servers" + ToolNameEnable = "enable_remote_mcp_server" + ToolNameDisable = "disable_remote_mcp_server" + ToolNameList = "list_remote_mcp_servers" + ToolNameResetAuth = "reset_remote_mcp_server_auth" +) + +// Toolset implements on-demand activation of remote (streamable-http) MCP +// servers from the Docker MCP Catalog. +type Toolset struct { + catalog *Catalog + byID map[string]Server + expander *js.Expander + env environment.Provider + + mu sync.RWMutex + // enabled holds the per-server StartableToolSet wrapper. Wrapping the + // inner *mcp.Toolset in a StartableToolSet gives us: + // - single-flight, idempotent Start() (so Tools() can call it on + // every enumeration without re-running the MCP handshake); + // - de-duplicated Start failure warnings (once per failure streak, + // reset by a subsequent success); + // - the same lifecycle wrapper the agent uses for YAML-declared + // toolsets, so the inner mcp.Toolset is treated identically. + enabled map[string]*tools.StartableToolSet + + // elicitationHandler / oauthSuccessHandler / managedOAuth / + // toolsChangedHandler are captured before any server is enabled + // (the runtime calls these via tools.As[...] from + // configureToolsetHandlers at the start of every turn). They are + // re-applied to each new mcp.Toolset on enable so OAuth elicitation, + // OAuth-success refreshes, the managed-vs-unmanaged flag and + // tool-list change notifications behave identically to a YAML- + // declared `mcp.remote` toolset. + elicitationHandler tools.ElicitationHandler + oauthSuccessHandler func() + toolsChangedHandler func() + managedOAuth bool + managedOAuthSet bool // distinguishes "default" from "explicitly false" + + // removeOAuthToken drops a persisted OAuth token by resource URL. + // Defaults to mcp.RemoveOAuthToken; tests inject a stub to avoid + // touching the OS keyring. + removeOAuthToken func(resourceURL string) error +} + +var ( + _ tools.ToolSet = (*Toolset)(nil) + _ tools.Startable = (*Toolset)(nil) + _ tools.Instructable = (*Toolset)(nil) + _ tools.Describer = (*Toolset)(nil) + _ tools.ChangeNotifier = (*Toolset)(nil) + _ tools.Elicitable = (*Toolset)(nil) + _ tools.OAuthCapable = (*Toolset)(nil) +) + +// New returns a Toolset backed by the embedded catalog. envProvider is used +// to resolve ${ENV_VAR} placeholders in catalog headers (e.g. the Apify +// `Authorization: Bearer ${APIFY_API_KEY}` header) at enable time, mirroring +// how a YAML-declared `mcp.remote` toolset works. +func New(envProvider environment.Provider) *Toolset { + cat := MustLoad() + byID := make(map[string]Server, len(cat.Servers)) + for _, s := range cat.Servers { + byID[s.ID] = s + } + return &Toolset{ + catalog: cat, + byID: byID, + expander: js.NewJsExpander(envProvider), + env: envProvider, + enabled: make(map[string]*tools.StartableToolSet), + removeOAuthToken: mcp.RemoveOAuthToken, + } +} + +// Describe returns a short, user-visible label for the /tools dialog. +func (t *Toolset) Describe() string { + return fmt.Sprintf("mcp_catalog(remote streamable-http, %d servers)", t.catalog.Count) +} + +// Instructions tell the model how to discover and activate servers. +func (t *Toolset) Instructions() string { + return `## Remote MCP Catalog + +You have access to a curated catalog of remote MCP servers (Docker MCP +Catalog, streamable-http only). They are NOT active by default. + +Workflow: + 1. Call ` + ToolNameSearch + ` with a keyword to discover matching servers. + Use any term related to the user's intent ("notion", "stripe", + "docs", "search", "browser", …). + 2. Call ` + ToolNameEnable + ` with the server's "id" to activate it. + This adds the server's tools to your set on the *next* turn. + Authentication (OAuth or API key) is deferred — it is triggered when + the host enumerates the server's tools, which happens once enabling + completes. For api_key servers, make sure the listed env var(s) are + set in the user's shell BEFORE enabling, otherwise the server will + refuse the connection. + 3. Use the newly activated tools as you would any other. + 4. Call ` + ToolNameDisable + ` to remove a server when no longer needed. + 5. If a previously authorized OAuth server starts rejecting requests + (token revoked, scopes changed, signed in to the wrong account), + call ` + ToolNameResetAuth + ` to wipe the persisted credentials. + The next enable will trigger a fresh authorization URL. + +Prefer enabling only the servers you actually need — every server adds +tools to the prompt and contributes to context usage.` +} + +// Start is a no-op: the catalog is embedded and no servers are auto-enabled. +// Lifecycle for individual MCP toolsets is managed when Enable / Disable +// are invoked, with first-use lazy start happening inside Tools(). +func (t *Toolset) Start(context.Context) error { return nil } + +// Stop tears down every enabled MCP toolset. Errors are logged but do not +// abort the loop so a misbehaving server can't block agent shutdown. +func (t *Toolset) Stop(ctx context.Context) error { + t.mu.Lock() + enabled := t.enabled + t.enabled = make(map[string]*tools.StartableToolSet) + t.mu.Unlock() + + for id, ts := range enabled { + if err := ts.Stop(ctx); err != nil { + slog.WarnContext(ctx, "Failed to stop remote MCP toolset", "id", id, "error", err) + } + } + return nil +} + +// SetElicitationHandler is captured here and re-attached to every freshly +// activated MCP toolset so OAuth flows can prompt the user. +func (t *Toolset) SetElicitationHandler(handler tools.ElicitationHandler) { + t.mu.Lock() + t.elicitationHandler = handler + enabled := t.snapshotEnabled() + t.mu.Unlock() + for _, ts := range enabled { + if e, ok := tools.As[tools.Elicitable](ts); ok { + e.SetElicitationHandler(handler) + } + } +} + +// SetOAuthSuccessHandler is captured here and re-attached to every freshly +// activated MCP toolset so the runtime refreshes its tool list once OAuth +// completes. +func (t *Toolset) SetOAuthSuccessHandler(handler func()) { + t.mu.Lock() + t.oauthSuccessHandler = handler + enabled := t.snapshotEnabled() + t.mu.Unlock() + for _, ts := range enabled { + if o, ok := tools.As[tools.OAuthCapable](ts); ok { + o.SetOAuthSuccessHandler(handler) + } + } +} + +// SetManagedOAuth forwards the managed-OAuth flag to every enabled +// toolset; new toolsets pick it up at enable time. +func (t *Toolset) SetManagedOAuth(managed bool) { + t.mu.Lock() + t.managedOAuth = managed + t.managedOAuthSet = true + enabled := t.snapshotEnabled() + t.mu.Unlock() + for _, ts := range enabled { + if o, ok := tools.As[tools.OAuthCapable](ts); ok { + o.SetManagedOAuth(managed) + } + } +} + +// SetToolsChangedHandler is invoked by the runtime to be notified when +// the set of available tools changes. We forward to the activated MCP +// toolsets *and* call it ourselves on every Enable / Disable so the +// runtime sees the meta-tool surface change too. +func (t *Toolset) SetToolsChangedHandler(handler func()) { + t.mu.Lock() + t.toolsChangedHandler = handler + enabled := t.snapshotEnabled() + t.mu.Unlock() + for _, ts := range enabled { + if n, ok := tools.As[tools.ChangeNotifier](ts); ok { + n.SetToolsChangedHandler(handler) + } + } +} + +// snapshotEnabled returns the currently enabled toolsets as a fresh slice. +// Caller MUST hold t.mu (read or write). Used to forward setter calls +// outside the critical section. +func (t *Toolset) snapshotEnabled() []*tools.StartableToolSet { + out := make([]*tools.StartableToolSet, 0, len(t.enabled)) + for _, ts := range t.enabled { + out = append(out, ts) + } + return out +} + +// Tools returns the meta-tools plus every tool exposed by an activated +// remote MCP server. Tools from unactivated servers are intentionally +// hidden so they don't bloat the prompt. +// +// First-call lazy start: each enabled server is Start()'d on its first +// enumeration. On startup the runtime probes tools with a non-interactive +// context (mcp.WithoutInteractivePrompts), so OAuth-pending servers fail +// fast with mcp.IsAuthorizationRequired and are silently deferred. On +// interactive turns, Start() blocks on OAuth elicitation as the user +// expects, and the resulting tools join the result set on the next +// enumeration. +func (t *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) { + result := []tools.Tool{ + { + Name: ToolNameSearch, + Category: "mcp_catalog", + Description: "Search the Docker MCP Catalog for remote streamable-http MCP servers matching a keyword. Returns id, title, description, auth requirements and category for each hit.", + Parameters: tools.MustSchemaFor[SearchArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.handleSearch), + Annotations: tools.ToolAnnotations{ + Title: "Search remote MCP servers", + ReadOnlyHint: true, + }, + }, + { + Name: ToolNameList, + Category: "mcp_catalog", + Description: "List currently enabled remote MCP servers and their connection state.", + Parameters: tools.MustSchemaFor[ListArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.handleList), + Annotations: tools.ToolAnnotations{ + Title: "List enabled remote MCP servers", + ReadOnlyHint: true, + }, + }, + { + Name: ToolNameEnable, + Category: "mcp_catalog", + Description: "Activate a remote MCP server from the catalog by id. Connection (and any required OAuth flow or API-key check) is deferred until the host next lists the agent's tools.", + Parameters: tools.MustSchemaFor[EnableArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.handleEnable), + Annotations: tools.ToolAnnotations{ + Title: "Enable remote MCP server", + }, + }, + { + Name: ToolNameDisable, + Category: "mcp_catalog", + Description: "Disable a previously enabled remote MCP server, dropping its tools from the active set.", + Parameters: tools.MustSchemaFor[DisableArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.handleDisable), + Annotations: tools.ToolAnnotations{ + Title: "Disable remote MCP server", + }, + }, + { + Name: ToolNameResetAuth, + Category: "mcp_catalog", + Description: "Clear persisted OAuth credentials (access token, refresh token, dynamic-client-registration data) for a catalog server. The next enable will trigger a fresh authorization flow. No-op for api_key/none servers.", + Parameters: tools.MustSchemaFor[ResetAuthArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.handleResetAuth), + Annotations: tools.ToolAnnotations{ + Title: "Reset remote MCP server auth", + DestructiveHint: new(true), + }, + }, + } + + t.mu.RLock() + enabled := make([]enabledServer, 0, len(t.enabled)) + for id, ts := range t.enabled { + enabled = append(enabled, enabledServer{id: id, ts: ts}) + } + t.mu.RUnlock() + + for _, e := range enabled { + if err := ctx.Err(); err != nil { + return nil, err + } + + if !e.ts.IsStarted() { + if err := e.ts.Start(ctx); err != nil { + // Auth-required is an *expected* deferral when probing + // with a non-interactive context (startup tool count) or + // when the elicitation bridge is not yet ready. Silent + // — the next interactive turn will retry and surface + // the OAuth dialog naturally. + if mcp.IsAuthorizationRequired(err) { + slog.DebugContext(ctx, "Remote MCP server requires authorization; deferred to next turn", + "id", e.id) + continue + } + // Real failure: log once per streak (StartableToolSet + // dedupes) so a misbehaving server doesn't flood logs. + if e.ts.ShouldReportFailure() { + slog.WarnContext(ctx, "Failed to start enabled remote MCP server", + "id", e.id, "error", err) + } else { + slog.DebugContext(ctx, "Remote MCP server still unavailable", + "id", e.id, "error", err) + } + continue + } + } + + serverTools, err := e.ts.Tools(ctx) + if err != nil { + slog.WarnContext(ctx, "Failed to list tools for enabled remote MCP server", + "id", e.id, "error", err) + continue + } + result = append(result, serverTools...) + } + + return result, nil +} + +// enabledServer pairs an id with its toolset for stable iteration outside +// the lock. It exists so callers can correlate "the server that failed +// to start" with its catalog id without re-reading the map. +type enabledServer struct { + id string + ts *tools.StartableToolSet +} + +// SearchArgs is the input schema for the search meta-tool. +type SearchArgs struct { + // Query is the keyword to look for. Empty matches everything. + Query string `json:"query" jsonschema:"Search keyword (matches id, title, description, category and tags; case-insensitive). Leave empty to list every catalog server."` +} + +// SearchResult is one row in the search response. +type SearchResult struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category,omitempty"` + Tags []string `json:"tags,omitempty"` + Auth string `json:"auth"` + URL string `json:"url"` + Enabled bool `json:"enabled"` +} + +func (t *Toolset) handleSearch(_ context.Context, args SearchArgs) (*tools.ToolCallResult, error) { + q := strings.ToLower(strings.TrimSpace(args.Query)) + + t.mu.RLock() + defer t.mu.RUnlock() + + matches := make([]SearchResult, 0) + for _, s := range t.catalog.Servers { + if q != "" && !matchesQuery(s, q) { + continue + } + _, isEnabled := t.enabled[s.ID] + matches = append(matches, SearchResult{ + ID: s.ID, + Title: s.Title, + Description: s.Description, + Category: s.Category, + Tags: s.Tags, + Auth: s.Auth.Type, + URL: s.URL, + Enabled: isEnabled, + }) + } + + if len(matches) == 0 { + return tools.ResultError(fmt.Sprintf("no remote MCP servers match %q (catalog has %d entries)", args.Query, t.catalog.Count)), nil + } + + sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID }) + + out, err := json.Marshal(matches) + if err != nil { + return nil, err + } + return tools.ResultSuccess(fmt.Sprintf("found %d server(s):\n%s", len(matches), string(out))), nil +} + +// matchesQuery returns true if any of the searchable string fields contains q. +// q is expected to be already lower-cased and trimmed. +func matchesQuery(s Server, q string) bool { + for _, field := range []string{s.ID, s.Title, s.Description, s.Category} { + if strings.Contains(strings.ToLower(field), q) { + return true + } + } + for _, tag := range s.Tags { + if strings.Contains(strings.ToLower(tag), q) { + return true + } + } + return false +} + +// EnableArgs is the input schema for enable_remote_mcp_server. +type EnableArgs struct { + ID string `json:"id" jsonschema:"Catalog id of the server to enable (use search_remote_mcp_servers to find it)."` +} + +func (t *Toolset) handleEnable(ctx context.Context, args EnableArgs) (*tools.ToolCallResult, error) { + id := strings.TrimSpace(args.ID) + server, ok := t.byID[id] + if !ok { + return tools.ResultError(fmt.Sprintf("unknown server id %q (use %s first to discover available ids)", id, ToolNameSearch)), nil + } + + t.mu.Lock() + if _, exists := t.enabled[id]; exists { + t.mu.Unlock() + return tools.ResultSuccess(fmt.Sprintf("server %q is already enabled", id)), nil + } + + // Pre-flight: warn (don't block) if an api_key server is missing its env var. + // We do not block because the user may set the variable later, or rely on + // the model to surface the error from the first tool call. + missing := t.missingAPIKeyEnv(ctx, server) + + headers := t.expander.ExpandMap(ctx, server.Headers) + mcpToolset := mcp.NewRemoteToolset(id, server.URL, server.Transport, headers, nil) + + // Re-attach the captured handlers so OAuth flows behave identically to + // a YAML-declared mcp.remote toolset. Apply BEFORE wrapping so we hit + // the *mcp.Toolset's typed setters directly without a tools.As walk. + if t.elicitationHandler != nil { + mcpToolset.SetElicitationHandler(t.elicitationHandler) + } + if t.oauthSuccessHandler != nil { + mcpToolset.SetOAuthSuccessHandler(t.oauthSuccessHandler) + } + if t.toolsChangedHandler != nil { + mcpToolset.SetToolsChangedHandler(t.toolsChangedHandler) + } + if t.managedOAuthSet { + mcpToolset.SetManagedOAuth(t.managedOAuth) + } + + wrapped := tools.NewStartable(mcpToolset) + t.enabled[id] = wrapped + notify := t.toolsChangedHandler + t.mu.Unlock() + + // Notify the runtime that the meta-tool surface itself changed. + if notify != nil { + notify() + } + + msg := strings.Builder{} + fmt.Fprintf(&msg, "enabled %q (%s) — connection deferred until the host next enumerates tools.\n", id, server.Title) + fmt.Fprintf(&msg, "endpoint: %s\n", server.URL) + switch server.Auth.Type { + case "oauth": + msg.WriteString("auth: OAuth — an authorization URL will be elicited on the next turn.\n") + case "api_key": + if len(missing) > 0 { + fmt.Fprintf(&msg, "auth: API key — WARNING: the following env vars are NOT set: %s. Set them and re-enable, otherwise tool calls will fail.\n", strings.Join(missing, ", ")) + } else { + msg.WriteString("auth: API key — env vars present, ready to use.\n") + } + default: + msg.WriteString("auth: none — ready to use.\n") + } + return tools.ResultSuccess(msg.String()), nil +} + +// missingAPIKeyEnv returns the names of api_key env vars that are not +// available from the toolset's env provider. Empty result means "all good". +// Returns nil for non api_key servers. +func (t *Toolset) missingAPIKeyEnv(ctx context.Context, s Server) []string { + if s.Auth.Type != "api_key" || t.env == nil { + return nil + } + var missing []string + for _, sec := range s.Auth.Secrets { + if sec.Env == "" { + continue + } + if v, ok := t.env.Get(ctx, sec.Env); !ok || v == "" { + missing = append(missing, sec.Env) + } + } + return missing +} + +// DisableArgs is the input schema for disable_remote_mcp_server. +type DisableArgs struct { + ID string `json:"id" jsonschema:"Catalog id of the server to disable."` +} + +func (t *Toolset) handleDisable(ctx context.Context, args DisableArgs) (*tools.ToolCallResult, error) { + id := strings.TrimSpace(args.ID) + + t.mu.Lock() + wrapped, exists := t.enabled[id] + if !exists { + t.mu.Unlock() + return tools.ResultError(fmt.Sprintf("server %q is not enabled", id)), nil + } + delete(t.enabled, id) + notify := t.toolsChangedHandler + t.mu.Unlock() + + if err := wrapped.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) { + // Stop failures aren't fatal — the entry is already gone from + // t.enabled. Just log and tell the model the server is off. + slog.WarnContext(ctx, "Failed to stop remote MCP toolset on disable", "id", id, "error", err) + } + + if notify != nil { + notify() + } + + return tools.ResultSuccess(fmt.Sprintf("disabled %q", id)), nil +} + +// ListArgs is the input schema for list_remote_mcp_servers (no params). +type ListArgs struct{} + +// EnabledServer reports the runtime state of a single enabled MCP server. +type EnabledServer struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Auth string `json:"auth"` + Started bool `json:"started"` +} + +func (t *Toolset) handleList(_ context.Context, _ ListArgs) (*tools.ToolCallResult, error) { + t.mu.RLock() + defer t.mu.RUnlock() + + enabled := make([]EnabledServer, 0, len(t.enabled)) + for id, ts := range t.enabled { + s := t.byID[id] + enabled = append(enabled, EnabledServer{ + ID: id, + Title: s.Title, + URL: s.URL, + Auth: s.Auth.Type, + Started: ts.IsStarted(), + }) + } + sort.Slice(enabled, func(i, j int) bool { return enabled[i].ID < enabled[j].ID }) + + out, err := json.Marshal(enabled) + if err != nil { + return nil, err + } + return tools.ResultSuccess(fmt.Sprintf("%d enabled server(s):\n%s", len(enabled), string(out))), nil +} + +// ResetAuthArgs is the input schema for reset_remote_mcp_server_auth. +type ResetAuthArgs struct { + ID string `json:"id" jsonschema:"Catalog id of the server whose persisted OAuth credentials should be cleared."` +} + +func (t *Toolset) handleResetAuth(ctx context.Context, args ResetAuthArgs) (*tools.ToolCallResult, error) { + id := strings.TrimSpace(args.ID) + server, ok := t.byID[id] + if !ok { + return tools.ResultError(fmt.Sprintf("unknown server id %q (use %s first to discover available ids)", id, ToolNameSearch)), nil + } + + if server.Auth.Type != "oauth" { + return tools.ResultSuccess(fmt.Sprintf("server %q uses %s auth — nothing to reset.", id, server.Auth.Type)), nil + } + + // Stop and forget any live MCP toolset for this server. The active + // supervisor still holds the (about-to-be-revoked) token in memory, so + // without stopping it the user would keep talking to the old session + // until it died on its own. Re-enabling triggers a fresh handshake. + t.mu.Lock() + wrapped, wasEnabled := t.enabled[id] + if wasEnabled { + delete(t.enabled, id) + } + notify := t.toolsChangedHandler + t.mu.Unlock() + + if wasEnabled { + if err := wrapped.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.WarnContext(ctx, "Failed to stop remote MCP toolset on auth reset", "id", id, "error", err) + } + } + + // We've already mutated t.enabled (the server is no longer in the + // active set), so the tools surface has changed regardless of whether + // the keyring removal below succeeds. Notify *before* the keyring call + // so a transient keyring failure can't desync the runtime's tool list. + if wasEnabled && notify != nil { + notify() + } + + if err := t.removeOAuthToken(server.URL); err != nil { + return tools.ResultError(fmt.Sprintf("failed to clear OAuth credentials for %q: %v", id, err)), nil + } + + msg := strings.Builder{} + fmt.Fprintf(&msg, "cleared OAuth credentials for %q (%s).\n", id, server.URL) + if wasEnabled { + msg.WriteString("the server was enabled and has been disabled; re-enable it to start a fresh authorization flow.\n") + } else { + msg.WriteString("enable the server to start a fresh authorization flow.\n") + } + return tools.ResultSuccess(msg.String()), nil +} diff --git a/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go b/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go new file mode 100644 index 000000000..8a84602fb --- /dev/null +++ b/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go @@ -0,0 +1,727 @@ +package mcpcatalog + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools" + mcptools "github.com/docker/docker-agent/pkg/tools/mcp" +) + +type stubEnv struct{ vars map[string]string } + +func (s stubEnv) Get(_ context.Context, name string) (string, bool) { + v, ok := s.vars[name] + return v, ok +} + +func TestLoadCatalog(t *testing.T) { + cat, err := Load() + require.NoError(t, err) + assert.Equal(t, "Docker MCP Catalog", cat.Source) + assert.NotEmpty(t, cat.SourceURL) + assert.Positive(t, cat.Count) + assert.Equal(t, len(cat.Servers), cat.Count) + + // Every server in the catalog must be remote streamable-http and have a URL. + for _, s := range cat.Servers { + assert.NotEmpty(t, s.ID, "server id must not be empty") + assert.Equal(t, "streamable-http", s.Transport, "server %s has unexpected transport", s.ID) + assert.NotEmpty(t, s.URL, "server %s has no URL", s.ID) + // auth.type must be one of the three documented values. + switch s.Auth.Type { + case "oauth", "api_key", "none": + default: + t.Fatalf("server %s has invalid auth.type %q", s.ID, s.Auth.Type) + } + } +} + +func TestSearchTool(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ctx := t.Context() + + res, err := ts.handleSearch(ctx, SearchArgs{Query: "stripe"}) + require.NoError(t, err) + require.False(t, res.IsError) + assert.Contains(t, strings.ToLower(res.Output), "stripe") + + // Empty query returns the whole catalog. + res, err = ts.handleSearch(ctx, SearchArgs{Query: ""}) + require.NoError(t, err) + require.False(t, res.IsError) + first := strings.SplitN(res.Output, "\n", 2)[0] + assert.Contains(t, first, "found ") + body := strings.SplitN(res.Output, "\n", 2)[1] + var parsed []SearchResult + require.NoError(t, json.Unmarshal([]byte(body), &parsed)) + assert.Len(t, parsed, ts.catalog.Count) + + // Unknown query returns an error result (not a Go error). + res, err = ts.handleSearch(ctx, SearchArgs{Query: "xxxxxx_no_such_server_xxxxxx"}) + require.NoError(t, err) + assert.True(t, res.IsError) +} + +func TestEnableDisableLifecycle(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ctx := t.Context() + + // Pick the first OAuth-style server in the catalog as a known good fixture. + var oauthID string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "oauth" { + oauthID = s.ID + break + } + } + require.NotEmpty(t, oauthID, "test fixture: catalog should contain at least one OAuth server") + + // Track tools-changed callbacks. Use atomic.Int32 to satisfy -race even + // though every call site here happens to be on the same goroutine. + var changes atomic.Int32 + ts.SetToolsChangedHandler(func() { changes.Add(1) }) + + // Before enabling: only meta-tools. + toolList, err := ts.Tools(ctx) + require.NoError(t, err) + names := toolNames(toolList) + assert.ElementsMatch(t, []string{ + ToolNameSearch, ToolNameList, ToolNameEnable, ToolNameDisable, ToolNameResetAuth, + }, names) + + // Enable: a callback should fire and the underlying mcp.Toolset should + // be present in t.enabled. We deliberately do NOT exercise the network + // path — Tools(ctx) on the lazily-instantiated toolset would attempt a + // connection. Just check the bookkeeping. + res, err := ts.handleEnable(ctx, EnableArgs{ID: oauthID}) + require.NoError(t, err) + require.False(t, res.IsError, "enable failed: %s", res.Output) + assert.Contains(t, res.Output, "OAuth") + assert.Equal(t, int32(1), changes.Load(), "enable should fire tools-changed handler exactly once") + + ts.mu.RLock() + _, exists := ts.enabled[oauthID] + ts.mu.RUnlock() + assert.True(t, exists) + + // Re-enable: idempotent, no extra change notification. + res, err = ts.handleEnable(ctx, EnableArgs{ID: oauthID}) + require.NoError(t, err) + assert.Contains(t, res.Output, "already enabled") + assert.Equal(t, int32(1), changes.Load()) + + // Search now reports it as enabled. + res, err = ts.handleSearch(ctx, SearchArgs{Query: oauthID}) + require.NoError(t, err) + require.False(t, res.IsError) + body := strings.SplitN(res.Output, "\n", 2)[1] + var parsed []SearchResult + require.NoError(t, json.Unmarshal([]byte(body), &parsed)) + var found *SearchResult + for i := range parsed { + if parsed[i].ID == oauthID { + found = &parsed[i] + } + } + require.NotNil(t, found) + assert.True(t, found.Enabled) + + // Disable: removes the entry and fires another change notification. + res, err = ts.handleDisable(ctx, DisableArgs{ID: oauthID}) + require.NoError(t, err) + require.False(t, res.IsError) + assert.Equal(t, int32(2), changes.Load()) + + ts.mu.RLock() + _, exists = ts.enabled[oauthID] + ts.mu.RUnlock() + assert.False(t, exists) + + // Disable again: error result, no extra change. + res, err = ts.handleDisable(ctx, DisableArgs{ID: oauthID}) + require.NoError(t, err) + assert.True(t, res.IsError) + assert.Equal(t, int32(2), changes.Load()) +} + +func TestEnableUnknownServer(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + res, err := ts.handleEnable(t.Context(), EnableArgs{ID: "definitely-not-a-server"}) + require.NoError(t, err) + assert.True(t, res.IsError) + assert.Contains(t, res.Output, "unknown server id") +} + +func TestEnableAPIKeyMissingEnv(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + + var apiKeyID, expectedEnv string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "api_key" && len(s.Auth.Secrets) > 0 && s.Auth.Secrets[0].Env != "" { + apiKeyID = s.ID + expectedEnv = s.Auth.Secrets[0].Env + break + } + } + require.NotEmpty(t, apiKeyID, "test fixture: catalog should contain at least one api_key server with an env var") + + res, err := ts.handleEnable(t.Context(), EnableArgs{ID: apiKeyID}) + require.NoError(t, err) + require.False(t, res.IsError) + assert.Contains(t, res.Output, "WARNING") + assert.Contains(t, res.Output, expectedEnv) +} + +func TestEnableAPIKeyEnvPresent(t *testing.T) { + ts := New(nil) // no env provider — should still work; the warning just doesn't fire. + + var apiKeyID string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "api_key" { + apiKeyID = s.ID + break + } + } + require.NotEmpty(t, apiKeyID) + + res, err := ts.handleEnable(t.Context(), EnableArgs{ID: apiKeyID}) + require.NoError(t, err) + require.False(t, res.IsError) + assert.Contains(t, res.Output, "auth: API key") + assert.NotContains(t, res.Output, "WARNING") +} + +func TestListEnabled(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ctx := t.Context() + + res, err := ts.handleList(ctx, ListArgs{}) + require.NoError(t, err) + assert.Contains(t, res.Output, "0 enabled") + + id := ts.catalog.Servers[0].ID + _, err = ts.handleEnable(ctx, EnableArgs{ID: id}) + require.NoError(t, err) + + res, err = ts.handleList(ctx, ListArgs{}) + require.NoError(t, err) + assert.Contains(t, res.Output, "1 enabled") + assert.Contains(t, res.Output, id) +} + +func TestStopReleasesEverything(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ctx := t.Context() + + id := ts.catalog.Servers[0].ID + _, err := ts.handleEnable(ctx, EnableArgs{ID: id}) + require.NoError(t, err) + + require.NoError(t, ts.Stop(ctx)) + + ts.mu.RLock() + defer ts.mu.RUnlock() + assert.Empty(t, ts.enabled) +} + +func toolNames(list []tools.Tool) []string { + out := make([]string, len(list)) + for i, t := range list { + out[i] = t.Name + } + return out +} + +func TestSetManagedOAuthPersistence(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ctx := t.Context() + + // Setting before any server is enabled must persist so that the next + // enabled server inherits the flag (regression: an earlier version + // dropped the value because it had no field on the Toolset). + ts.SetManagedOAuth(true) + ts.mu.RLock() + assert.True(t, ts.managedOAuth) + assert.True(t, ts.managedOAuthSet) + ts.mu.RUnlock() + + id := ts.catalog.Servers[0].ID + _, err := ts.handleEnable(ctx, EnableArgs{ID: id}) + require.NoError(t, err) + + ts.mu.RLock() + mcpTS, exists := ts.enabled[id] + ts.mu.RUnlock() + require.True(t, exists) + assert.NotNil(t, mcpTS) +} + +func TestConcurrentEnableDisable(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ctx := t.Context() + + require.GreaterOrEqual(t, len(ts.catalog.Servers), 2, "need at least 2 servers for concurrency test") + id1 := ts.catalog.Servers[0].ID + id2 := ts.catalog.Servers[1].ID + + var wg sync.WaitGroup + enableErrs := make(chan error, 2) + + wg.Add(2) + go func() { + defer wg.Done() + _, err := ts.handleEnable(ctx, EnableArgs{ID: id1}) + if err != nil { + enableErrs <- err + } + }() + go func() { + defer wg.Done() + _, err := ts.handleEnable(ctx, EnableArgs{ID: id2}) + if err != nil { + enableErrs <- err + } + }() + wg.Wait() + close(enableErrs) + for err := range enableErrs { + require.NoError(t, err) + } + + ts.mu.RLock() + _, exists1 := ts.enabled[id1] + _, exists2 := ts.enabled[id2] + ts.mu.RUnlock() + assert.True(t, exists1) + assert.True(t, exists2) + + disableErrs := make(chan error, 2) + wg.Add(2) + go func() { + defer wg.Done() + _, err := ts.handleDisable(ctx, DisableArgs{ID: id1}) + if err != nil { + disableErrs <- err + } + }() + go func() { + defer wg.Done() + _, err := ts.handleDisable(ctx, DisableArgs{ID: id2}) + if err != nil { + disableErrs <- err + } + }() + wg.Wait() + close(disableErrs) + for err := range disableErrs { + require.NoError(t, err) + } + + ts.mu.RLock() + _, exists1 = ts.enabled[id1] + _, exists2 = ts.enabled[id2] + ts.mu.RUnlock() + assert.False(t, exists1) + assert.False(t, exists2) +} + +func TestToolsContextCancellation(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + + id := ts.catalog.Servers[0].ID + _, err := ts.handleEnable(t.Context(), EnableArgs{ID: id}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err = ts.Tools(ctx) + assert.ErrorIs(t, err, context.Canceled) +} + +// TestToolsExposesEnabledServerTools is the regression test for the +// "enabled-but-never-started" bug. It spins up an HTTP server that speaks +// just enough MCP for an Initialize+ListTools handshake, points a catalog +// entry at it, and asserts that after enable_remote_mcp_server the +// returned Tools() includes the server's tool — proving the inner MCP +// toolset really is started lazily and its tools merge with the meta +// surface. +func TestToolsExposesEnabledServerTools(t *testing.T) { + srv := newFakeMCPServer(t) + defer srv.Close() + + ts := New(stubEnv{vars: map[string]string{}}) + + // Inject a synthetic catalog entry that points at the test server. + const id = "test-server" + server := Server{ + ID: id, + Title: "Test", + URL: srv.URL, + Transport: "streamable-http", + Auth: Auth{Type: "none"}, + } + ts.catalog.Servers = append(ts.catalog.Servers, server) + ts.byID[id] = server + + ctx := t.Context() + res, err := ts.handleEnable(ctx, EnableArgs{ID: id}) + require.NoError(t, err) + require.False(t, res.IsError, "enable: %s", res.Output) + + // Tools() must lazily start the inner toolset and include its tools. + toolList, err := ts.Tools(ctx) + require.NoError(t, err) + + names := toolNames(toolList) + // Meta-tools are always there. + for _, meta := range []string{ToolNameSearch, ToolNameList, ToolNameEnable, ToolNameDisable} { + assert.Contains(t, names, meta) + } + // And so is the tool exposed by the fake MCP server. + assert.Contains(t, names, "test-server_echo", + "enabled MCP server's tool must show up after Tools() lazily starts it") + + // Subsequent calls remain cheap (cached). + toolList2, err := ts.Tools(ctx) + require.NoError(t, err) + assert.Len(t, toolList2, len(toolList)) + + // Cleanup so the test doesn't leak the supervisor's watch goroutine. + require.NoError(t, ts.Stop(ctx)) +} + +// TestResetAuthForwardsToTokenStore verifies that reset_remote_mcp_server_auth +// places the right call with the right URL. +func TestResetAuthForwardsToTokenStore(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + + var removedURLs []string + ts.removeOAuthToken = func(url string) error { + removedURLs = append(removedURLs, url) + return nil + } + + var oauthServer Server + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "oauth" { + oauthServer = s + break + } + } + require.NotEmpty(t, oauthServer.ID, "need at least one oauth server in catalog") + + res, err := ts.handleResetAuth(t.Context(), ResetAuthArgs{ID: oauthServer.ID}) + require.NoError(t, err) + require.False(t, res.IsError, "reset auth: %s", res.Output) + assert.Contains(t, res.Output, "cleared OAuth credentials") + assert.Equal(t, []string{oauthServer.URL}, removedURLs, + "removeOAuthToken must be called once with the catalog URL") +} + +// TestResetAuthUnknownServer confirms unknown ids surface a friendly error +// without touching the token store. +func TestResetAuthUnknownServer(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + called := 0 + ts.removeOAuthToken = func(string) error { called++; return nil } + + res, err := ts.handleResetAuth(t.Context(), ResetAuthArgs{ID: "definitely-not-a-server"}) + require.NoError(t, err) + assert.True(t, res.IsError) + assert.Contains(t, res.Output, "unknown server id") + assert.Zero(t, called, "token store must not be touched for unknown ids") +} + +// TestResetAuthNoOpForNonOAuth confirms that resetting auth for an +// api_key/none server is a no-op that doesn't reach the token store. +func TestResetAuthNoOpForNonOAuth(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + called := 0 + ts.removeOAuthToken = func(string) error { called++; return nil } + + var apiKeyID string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "api_key" { + apiKeyID = s.ID + break + } + } + require.NotEmpty(t, apiKeyID) + + res, err := ts.handleResetAuth(t.Context(), ResetAuthArgs{ID: apiKeyID}) + require.NoError(t, err) + require.False(t, res.IsError) + assert.Contains(t, res.Output, "nothing to reset") + assert.Zero(t, called, "api_key servers must not touch the OAuth token store") +} + +// TestResetAuthDisablesEnabledServer makes sure resetting auth for a +// currently-enabled server stops its toolset (so the next enable does a +// fresh handshake) AND fires the tools-changed handler. +func TestResetAuthDisablesEnabledServer(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ts.removeOAuthToken = func(string) error { return nil } + + var changes atomic.Int32 + ts.SetToolsChangedHandler(func() { changes.Add(1) }) + + var oauthID string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "oauth" { + oauthID = s.ID + break + } + } + require.NotEmpty(t, oauthID) + + ctx := t.Context() + _, err := ts.handleEnable(ctx, EnableArgs{ID: oauthID}) + require.NoError(t, err) + assert.Equal(t, int32(1), changes.Load()) + + ts.mu.RLock() + _, present := ts.enabled[oauthID] + ts.mu.RUnlock() + require.True(t, present, "server should be enabled before reset") + + res, err := ts.handleResetAuth(ctx, ResetAuthArgs{ID: oauthID}) + require.NoError(t, err) + require.False(t, res.IsError, "reset: %s", res.Output) + assert.Contains(t, res.Output, "has been disabled") + + ts.mu.RLock() + _, stillThere := ts.enabled[oauthID] + ts.mu.RUnlock() + assert.False(t, stillThere, "server must be removed from enabled after reset") + + assert.Equal(t, int32(2), changes.Load(), + "reset on an enabled server must fire tools-changed exactly once more") +} + +// TestResetAuthSurfacesStoreErrors confirms that errors from the token +// store are surfaced to the caller as IsError results (not panics). +func TestResetAuthSurfacesStoreErrors(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ts.removeOAuthToken = func(string) error { return errors.New("keyring on fire") } + + var oauthID string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "oauth" { + oauthID = s.ID + break + } + } + require.NotEmpty(t, oauthID) + + res, err := ts.handleResetAuth(t.Context(), ResetAuthArgs{ID: oauthID}) + require.NoError(t, err) + assert.True(t, res.IsError) + assert.Contains(t, res.Output, "keyring on fire") +} + +// TestResetAuthNotifiesEvenWhenKeyringFails verifies the state-vs-notification +// invariant on the failure path: if the server was enabled, we have already +// removed it from t.enabled and stopped the inner toolset before calling +// the keyring; the runtime's tool list has therefore changed regardless of +// whether the keyring removal eventually succeeds. Notify must fire. +func TestResetAuthNotifiesEvenWhenKeyringFails(t *testing.T) { + ts := New(stubEnv{vars: map[string]string{}}) + ts.removeOAuthToken = func(string) error { return errors.New("keyring on fire") } + + var changes atomic.Int32 + ts.SetToolsChangedHandler(func() { changes.Add(1) }) + + var oauthID string + for _, s := range ts.catalog.Servers { + if s.Auth.Type == "oauth" { + oauthID = s.ID + break + } + } + require.NotEmpty(t, oauthID) + + ctx := t.Context() + _, err := ts.handleEnable(ctx, EnableArgs{ID: oauthID}) + require.NoError(t, err) + require.Equal(t, int32(1), changes.Load(), "enable should fire once") + + res, err := ts.handleResetAuth(ctx, ResetAuthArgs{ID: oauthID}) + require.NoError(t, err) + assert.True(t, res.IsError, "keyring failure must be surfaced") + + ts.mu.RLock() + _, stillEnabled := ts.enabled[oauthID] + ts.mu.RUnlock() + assert.False(t, stillEnabled, "server must be removed even when keyring removal fails") + + assert.Equal(t, int32(2), changes.Load(), + "reset must notify the runtime that tools changed even if keyring removal fails afterwards") +} + +// TestToolsAuthRequiredIsDeferred verifies the on-demand semantics: a +// server requiring OAuth that is probed in a non-interactive context +// must not error out. Tools() returns the meta-surface only and the +// server is silently retried on the next interactive turn. +func TestToolsAuthRequiredIsDeferred(t *testing.T) { + srv := newAuthRequiredMCPServer(t) + defer srv.Close() + + ts := New(stubEnv{vars: map[string]string{}}) + const id = "auth-required-server" + server := Server{ + ID: id, + Title: "AuthRequired", + URL: srv.URL, + Transport: "streamable-http", + Auth: Auth{Type: "oauth"}, + } + ts.catalog.Servers = append(ts.catalog.Servers, server) + ts.byID[id] = server + + ctx := t.Context() + _, err := ts.handleEnable(ctx, EnableArgs{ID: id}) + require.NoError(t, err) + + // Probe with the same context the runtime uses at startup: no + // interactive prompts allowed. We expect Tools() to swallow the + // AuthorizationRequired error and still return the meta-tools. + probeCtx := mcptools.WithoutInteractivePrompts(ctx) + toolList, err := ts.Tools(probeCtx) + require.NoError(t, err, "auth-required servers must not break Tools()") + + names := toolNames(toolList) + for _, meta := range []string{ToolNameSearch, ToolNameList, ToolNameEnable, ToolNameDisable} { + assert.Contains(t, names, meta) + } + // The auth-required server contributes no tools yet. + assert.NotContains(t, names, id+"_anything") + + require.NoError(t, ts.Stop(ctx)) +} + +// --- minimal fake MCP server helpers ----------------------------------- +// +// The MCP SDK's streamable-HTTP transport speaks JSON-RPC 2.0 framed in +// Server-Sent Events. We only need to respond to two methods (initialize +// and tools/list) for a successful handshake, then immediately close the +// stream so the client moves on. + +func newFakeMCPServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/", mcpHandler(t, false)) + return httptest.NewServer(mux) +} + +func newAuthRequiredMCPServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + // 401 with WWW-Authenticate so the OAuth transport notices. + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer resource="https://example.invalid/.well-known/oauth-protected-resource"`) + w.WriteHeader(http.StatusUnauthorized) + }) + return httptest.NewServer(mux) +} + +// mcpHandler returns an http.HandlerFunc that responds to a single +// initialize+tools/list+(notifications) sequence over streamable-HTTP. +// This is *just* enough to satisfy the MCP SDK's client during its +// initial handshake; it is NOT a complete server implementation. +func mcpHandler(t *testing.T, _ bool) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + + body, err := readJSONRPC(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Notifications carry no id — the MCP SDK sends notifications/initialized + // after the initialize response. Reply 202 Accepted and stop. + if body.ID == nil { + w.WriteHeader(http.StatusAccepted) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Mcp-Session-Id", "test-session") + + switch body.Method { + case "initialize": + writeJSONRPC(t, w, body.ID, map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{}, + "serverInfo": map[string]any{ + "name": "fake", + "version": "0.0.1", + }, + }) + case "tools/list": + writeJSONRPC(t, w, body.ID, map[string]any{ + "tools": []map[string]any{ + { + "name": "echo", + "description": "echoes its input", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{"type": "string"}, + }, + }, + }, + }, + }) + default: + writeJSONRPC(t, w, body.ID, map[string]any{}) + } + } +} + +type jsonrpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +func readJSONRPC(r *http.Request) (*jsonrpcRequest, error) { + defer r.Body.Close() + var req jsonrpcRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + if req.JSONRPC != "2.0" { + return nil, errors.New("missing jsonrpc=2.0") + } + return &req, nil +} + +func writeJSONRPC(t *testing.T, w http.ResponseWriter, id json.RawMessage, result any) { + t.Helper() + resp := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": result, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("encode response: %v", err) + } +} diff --git a/pkg/tools/builtin/mcpcatalog/servers.go b/pkg/tools/builtin/mcpcatalog/servers.go new file mode 100644 index 000000000..cf685e659 --- /dev/null +++ b/pkg/tools/builtin/mcpcatalog/servers.go @@ -0,0 +1,86 @@ +package mcpcatalog + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +//go:embed servers.json +var serversJSON []byte + +// Catalog mirrors the on-disk JSON file. It exposes the curated subset of +// the Docker MCP Catalog that consists exclusively of remote servers +// reachable over the streamable-http transport — the format docker-agent +// can talk to directly without a local subprocess or the MCP gateway. +type Catalog struct { + Source string `json:"source"` + SourceURL string `json:"source_url"` + SchemaVersion int `json:"schema_version"` + Filter string `json:"filter"` + Count int `json:"count"` + Servers []Server `json:"servers"` +} + +// Server is a curated, on-demand-activatable remote MCP server description. +// +// Headers may contain ${ENV_VAR} placeholders that are resolved at request +// time against the agent's env provider — exactly like a YAML-declared +// `mcp` toolset with `remote.headers`. This lets API-key servers like +// Apify pull APIFY_API_KEY from the user's shell at activation time. +type Server struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Transport string `json:"transport"` + Category string `json:"category,omitempty"` + Tags []string `json:"tags,omitempty"` + Icon string `json:"icon,omitempty"` + Readme string `json:"readme,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Auth Auth `json:"auth"` +} + +// Auth describes how to authenticate against a remote MCP server. +// +// Type is one of: +// - "none" — no credentials required +// - "oauth" — OAuth flow handled by the underlying mcp.Toolset +// - "api_key" — caller-provided secret(s) (env vars listed in Secrets) +type Auth struct { + Type string `json:"type"` + Providers []OAuthProvider `json:"providers,omitempty"` + Secrets []SecretSpec `json:"secrets,omitempty"` +} + +type OAuthProvider struct { + Provider string `json:"provider"` + Env string `json:"env"` + Secret string `json:"secret"` +} + +type SecretSpec struct { + Name string `json:"name"` + Env string `json:"env"` + Example string `json:"example,omitempty"` +} + +// Load returns the embedded catalog. The result is freshly decoded on each +// call so callers can mutate the returned value freely. +func Load() (*Catalog, error) { + var c Catalog + if err := json.Unmarshal(serversJSON, &c); err != nil { + return nil, fmt.Errorf("decoding embedded MCP catalog: %w", err) + } + return &c, nil +} + +// MustLoad is like Load but panics on error. Intended for package init. +func MustLoad() *Catalog { + c, err := Load() + if err != nil { + panic(err) + } + return c +} diff --git a/pkg/tools/builtin/mcpcatalog/servers.json b/pkg/tools/builtin/mcpcatalog/servers.json new file mode 100644 index 000000000..2877542af --- /dev/null +++ b/pkg/tools/builtin/mcpcatalog/servers.json @@ -0,0 +1,1129 @@ +{ + "source": "Docker MCP Catalog", + "source_url": "https://desktop.docker.com/mcp/catalog/v3/catalog.json", + "schema_version": 3, + "filter": "type=remote AND remote.transport_type=streamable-http", + "count": 45, + "servers": [ + { + "id": "apify", + "title": "Apify Remote", + "description": "Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation. You can extract structured data from social media, e-commerce, search engines, maps, travel sites, or any other website.", + "url": "https://mcp.apify.com", + "transport": "streamable-http", + "category": "automation", + "tags": [ + "automation", + "web-scraping", + "data-extraction", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=apify.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/apify.md", + "headers": { + "Authorization": "Bearer ${APIFY_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "apify.api_key", + "env": "APIFY_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "astro-docs", + "title": "Astro Docs", + "description": "Access the latest Astro web framework documentation, guides, and API references.", + "url": "https://mcp.docs.astro.build/mcp", + "transport": "streamable-http", + "category": "documentation", + "tags": [ + "documentation", + "astro", + "web-framework", + "remote" + ], + "icon": "https://astro.build/favicon.svg", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/astro-docs.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "atlassian-remote", + "title": "Atlassian", + "description": "Interact with Jira, Confluence, and Compass to manage projects, create documentation, and track services.", + "url": "https://mcp.atlassian.com/v1/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "project-management", + "documentation", + "collaboration", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=atlassian.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/atlassian-remote.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "atlassian-remote", + "env": "ATLASSIAN_REMOTE_PERSONAL_ACCESS_TOKEN", + "secret": "atlassian-remote.personal_access_token" + } + ] + } + }, + { + "id": "audioscrape", + "title": "Audioscrape", + "description": "Search and analyze audio content from a massive database of podcasts, interviews, and other talks.", + "url": "https://mcp.audioscrape.com", + "transport": "streamable-http", + "category": "ai", + "tags": [ + "ai", + "rag", + "audio", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=audioscrape.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/audioscrape.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "audioscrape", + "env": "AUDIOSCRAPE_PERSONAL_ACCESS_TOKEN", + "secret": "audioscrape.personal_access_token" + } + ] + } + }, + { + "id": "brightdata", + "title": "Bright Data", + "description": "One MCP for the Web. Easily search, crawl, navigate, and extract websites without getting blocked. Ideal for discovering and retrieving structured insights from any public source - effortlessly and ethically. The Web MCP is your gateway to giving AI assistants true web capabilities. No more outdated responses, no more \"I can't access real-time information\" - just seamless, reliable web access that actually works. Built by Bright Data, the world's #1 web data platform, this MCP server ensures your AI never gets blocked, rate-limited, or served CAPTCHAs.", + "url": "https://mcp.brightdata.com/mcp", + "transport": "streamable-http", + "category": "web-scraping", + "tags": [ + "web-scraping", + "data", + "search", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=brightdata.com&sz=128", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/brightdata.md", + "headers": {}, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "brightdata.api_token", + "env": "BRIGHTDATA_API_TOKEN", + "example": "" + } + ] + } + }, + { + "id": "carbon-voice", + "title": "Carbon Voice", + "description": "Communicate asynchronously with voice messages featuring AI transcription, summaries, and automated action item extraction.", + "url": "https://mcp.carbonvoice.app", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "voice", + "automation", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=carbonvoice.app&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/carbon-voice.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "carbon-voice", + "env": "CARBON_VOICE_PERSONAL_ACCESS_TOKEN", + "secret": "carbon-voice.personal_access_token" + } + ] + } + }, + { + "id": "close", + "title": "Close", + "description": "Streamline sales processes with integrated calling, email, SMS, and automated workflows for small and scaling businesses.", + "url": "https://mcp.close.com/mcp", + "transport": "streamable-http", + "category": "crm", + "tags": [ + "crm", + "sales", + "customer-management", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=close.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/close.md", + "headers": { + "Authorization": "Bearer ${CLOSE_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "close.api_key", + "env": "CLOSE_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "dappier-remote", + "title": "Dappier Remote", + "description": "Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier.", + "url": "https://mcp.dappier.com/mcp", + "transport": "streamable-http", + "category": "ai", + "tags": [ + "ai", + "rag", + "knowledge-base", + "remote" + ], + "icon": "https://avatars.githubusercontent.com/u/152645347?s=200&v=4", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/dappier-remote.md", + "headers": { + "Authorization": "Bearer ${DAPPIER_REMOTE_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "dappier-remote.api_key", + "env": "DAPPIER_REMOTE_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "docker-docs", + "title": "Docker Docs", + "description": "Search and retrieve Docker documentation to help with containerization, Docker Compose, Docker Hub, and related topics.", + "url": "https://mcp-docs.docker.com/mcp", + "transport": "streamable-http", + "category": "documentation", + "tags": [ + "documentation", + "docker", + "containers", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=docker.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/docker-docs.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "excalidraw-remote", + "title": "Excalidraw", + "description": "Create hand-drawn style diagrams and sketches with streaming animations. Renders interactive Excalidraw diagrams directly in chat with smooth draw-on effects.", + "url": "https://excalidraw-mcp-app.vercel.app/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "collaboration", + "diagramming", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=excalidraw.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/excalidraw-remote.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "find-a-domain", + "title": "Find-A-Domain", + "description": "Tools for finding domain names.", + "url": "https://api.findadomain.dev/mcp", + "transport": "streamable-http", + "category": "tools", + "tags": [ + "tools", + "domain", + "productivity", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=findadomain.dev&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/find-a-domain.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "firefly", + "title": "Firefly", + "description": "Transcribe, summarize, and analyze meetings with AI-powered note-taking across video conferencing platforms.", + "url": "https://api.fireflies.ai/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "meetings", + "transcription", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=fireflies.ai&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/firefly.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "firefly", + "env": "FIREFLY_PERSONAL_ACCESS_TOKEN", + "secret": "firefly.personal_access_token" + } + ] + } + }, + { + "id": "gitmcp", + "title": "GitMCP", + "description": "Tools for interacting with Git repositories.", + "url": "https://gitmcp.io/docs", + "transport": "streamable-http", + "category": "development", + "tags": [ + "git", + "version-control", + "development", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=gitmcp.io&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/gitmcp.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "grafbase", + "title": "Grafbase", + "description": "Build and deploy high-performance GraphQL APIs with federation, edge computing, and enterprise-grade governance.", + "url": "https://api.grafbase.com/mcp", + "transport": "streamable-http", + "category": "devops", + "tags": [ + "devops", + "graphql", + "database", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=grafbase.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/grafbase.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "grafbase", + "env": "GRAFBASE_PERSONAL_ACCESS_TOKEN", + "secret": "grafbase.personal_access_token" + } + ] + } + }, + { + "id": "granola-remote", + "title": "Granola", + "description": "Access meeting notes and conversations from Granola to use as context for tasks like creating tickets, drafting summaries, and scaffolding features.", + "url": "https://mcp.granola.ai/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "meetings", + "notes", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=granola.ai&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/granola-remote.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "granola-remote", + "env": "GRANOLA_PERSONAL_ACCESS_TOKEN", + "secret": "granola-remote.personal_access_token" + } + ] + } + }, + { + "id": "hubspot", + "title": "HubSpot", + "description": "Unite marketing, sales, and customer service with AI-powered automation, lead management, and comprehensive analytics.", + "url": "https://app.hubspot.com/mcp/v1/http", + "transport": "streamable-http", + "category": "crm", + "tags": [ + "crm", + "marketing", + "sales", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=hubspot.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/hubspot.md", + "headers": { + "Authorization": "Bearer ${HUBSPOT_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "hubspot.api_key", + "env": "HUBSPOT_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "hugging-face", + "title": "Hugging Face", + "description": "Tools for interacting with Hugging Face models, datasets, research papers, and more.", + "url": "https://huggingface.co/mcp", + "transport": "streamable-http", + "category": "ai", + "tags": [ + "ai", + "machine-learning", + "models", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=hf.co&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/hugging-face.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "incident-io", + "title": "Incident.io", + "description": "Manage incidents, analyze alerts, check on-call schedules, and run operational analysis directly from your AI assistant.", + "url": "https://mcp.incident.io/mcp", + "transport": "streamable-http", + "category": "monitoring", + "tags": [ + "monitoring", + "incident-management", + "on-call", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=incident.io&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/incident-io.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "incident-io", + "env": "INCIDENT_IO_PERSONAL_ACCESS_TOKEN", + "secret": "incident-io.personal_access_token" + } + ] + } + }, + { + "id": "instant", + "title": "Instant", + "description": "Build real-time, offline-first applications with a modern Firebase-like database and instant synchronization.", + "url": "https://mcp.instantdb.com/mcp", + "transport": "streamable-http", + "category": "database", + "tags": [ + "database", + "realtime", + "backend", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=instantdb.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/instant.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "instant", + "env": "INSTANT_PERSONAL_ACCESS_TOKEN", + "secret": "instant.personal_access_token" + } + ] + } + }, + { + "id": "javadocs", + "title": "Javadocs", + "description": "Access to Java, Kotlin, and Scala library documentation.", + "url": "https://www.javadocs.dev/mcp", + "transport": "streamable-http", + "category": "documentation", + "tags": [ + "documentation", + "java", + "api", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=docs.oracle.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/javadocs.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "linear", + "title": "Linear", + "description": "Track issues, plan sprints, and manage product development with a fast, keyboard-first interface designed for software teams.", + "url": "https://mcp.linear.app/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "project-management", + "issue-tracking", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=linear.app&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/linear.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "linear", + "env": "LINEAR_PERSONAL_ACCESS_TOKEN", + "secret": "linear.personal_access_token" + } + ] + } + }, + { + "id": "manifold", + "title": "Manifold", + "description": "Tools for accessing the Manifold Markets online prediction market platform.", + "url": "https://api.manifold.markets/v0/mcp", + "transport": "streamable-http", + "category": "analytics", + "tags": [ + "analytics", + "forecasting", + "prediction-markets", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=manifold.markets&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/manifold.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "mercado-libre", + "title": "Mercado Libre", + "description": "Provides access to Mercado Libre E-Commerce API.", + "url": "https://mcp.mercadolibre.com", + "transport": "streamable-http", + "category": "commerce", + "tags": [ + "commerce", + "marketplace", + "latin-america", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=mercadolibre.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/mercado-libre.md", + "headers": { + "Authorization": "Bearer ${MERCADO_LIBRE_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "mercado-libre.api_key", + "env": "MERCADO_LIBRE_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "mercado-pago", + "title": "Mercado Pago", + "description": "Provides access to Mercado Pago Marketplace API.", + "url": "https://mcp.mercadopago.com", + "transport": "streamable-http", + "category": "payments", + "tags": [ + "payments", + "finance", + "latin-america", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=mercadopago.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/mercado-pago.md", + "headers": { + "Authorization": "Bearer ${MERCADO_PAGO_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "mercado-pago.api_key", + "env": "MERCADO_PAGO_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "microsoft-learn", + "title": "Microsoft Learn", + "description": "Add trusted and up-to-date content from Microsoft Learn to your AI agent.", + "url": "https://learn.microsoft.com/api/mcp", + "transport": "streamable-http", + "category": "documentation", + "tags": [ + "microsoft-learn", + "documentation", + "remote" + ], + "icon": "https://avatars.githubusercontent.com/microsoft?s=64", + "readme": "", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "miro-remote", + "title": "Miro", + "description": "Create diagrams, manage documents, and explore board content in collaborative visual workspaces.", + "url": "https://mcp.miro.com/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "collaboration", + "diagramming", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=miro.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/miro-remote.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "miro-remote", + "env": "MIRO_REMOTE_PERSONAL_ACCESS_TOKEN", + "secret": "miro-remote.personal_access_token" + } + ] + } + }, + { + "id": "morningstar-mcp-server", + "title": "Morningstar", + "description": "The Morningstar MCP Server makes it simple to power your AI apps and tools with Morningstars trusted content. It provides access to a growing library of AI-ready capabilities, including global analyst research, market analysis, and key investment data.", + "url": "https://mcp.morningstar.com/mcp", + "transport": "streamable-http", + "category": "Fintech", + "tags": [ + "Fintech", + "remote" + ], + "icon": "https://developer.morningstar.com/logo-brand.svg", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/morningstar-mcp-server.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "morningstar-mcp-server", + "env": "MORNINGSTAR_MCP_SERVER_PERSONAL_ACCESS_TOKEN", + "secret": "morningstar-mcp-server.personal_access_token" + } + ] + } + }, + { + "id": "needle", + "title": "Needle Remote", + "description": "Production-ready RAG service to search and retrieve data from your documents.", + "url": "https://mcp.needle.app/mcp", + "transport": "streamable-http", + "category": "ai", + "tags": [ + "ai", + "rag", + "knowledge-base", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=needle.app&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/needle.md", + "headers": { + "Authorization": "Bearer ${NEEDLE_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "needle.api_key", + "env": "NEEDLE_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "notion-remote", + "title": "Notion", + "description": "Manage projects, create documentation, and organize information in collaborative workspaces.", + "url": "https://mcp.notion.com/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "documentation", + "collaboration", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=notion.so&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/notion-remote.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "notion-remote", + "env": "NOTION_REMOTE_PERSONAL_ACCESS_TOKEN", + "secret": "notion-remote.personal_access_token" + } + ] + } + }, + { + "id": "octagon", + "title": "Octagon", + "description": "Access comprehensive market intelligence, analyze SEC filings, earnings transcripts, and financial data to uncover investment opportunities.", + "url": "https://mcp.octagonagents.com/mcp", + "transport": "streamable-http", + "category": "analytics", + "tags": [ + "analytics", + "market-intelligence", + "data", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=octagonai.co&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/octagon.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "octagon", + "env": "OCTAGON_PERSONAL_ACCESS_TOKEN", + "secret": "octagon.personal_access_token" + } + ] + } + }, + { + "id": "openmesh", + "title": "OpenMesh", + "description": "Discover and connect to a curated marketplace of MCP servers for extending AI agent capabilities.", + "url": "https://api.openmesh.dev/mcp", + "transport": "streamable-http", + "category": "infrastructure", + "tags": [ + "infrastructure", + "discovery", + "mesh", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=openmesh.network&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/openmesh.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "openzeppelin-cairo", + "title": "OpenZeppelin Cairo Contracts", + "description": "Access to OpenZeppelin Cairo Contracts.", + "url": "https://mcp.openzeppelin.com/contracts/cairo/mcp", + "transport": "streamable-http", + "category": "blockchain", + "tags": [ + "blockchain", + "cairo", + "smart-contracts", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=openzeppelin.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/openzeppelin-cairo.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "openzeppelin-solidity", + "title": "OpenZeppelin Solidity Contracts", + "description": "Access to OpenZeppelin Solidity Contracts.", + "url": "https://mcp.openzeppelin.com/contracts/solidity/mcp", + "transport": "streamable-http", + "category": "blockchain", + "tags": [ + "blockchain", + "solidity", + "smart-contracts", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=openzeppelin.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/openzeppelin-solidity.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "openzeppelin-stellar", + "title": "OpenZeppelin Stellar Contracts", + "description": "Access to OpenZeppelin Stellar Contracts.", + "url": "https://mcp.openzeppelin.com/contracts/stellar/mcp", + "transport": "streamable-http", + "category": "blockchain", + "tags": [ + "blockchain", + "stellar", + "smart-contracts", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=openzeppelin.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/openzeppelin-stellar.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "openzeppelin-stylus", + "title": "OpenZeppelin Stylus Contracts", + "description": "Access to OpenZeppelin Stylus Contracts.", + "url": "https://mcp.openzeppelin.com/contracts/stylus/mcp", + "transport": "streamable-http", + "category": "blockchain", + "tags": [ + "blockchain", + "stylus", + "smart-contracts", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=openzeppelin.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/openzeppelin-stylus.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "polar-signals", + "title": "Polar Signals", + "description": "MCP server for Polar Signals Cloud continuous profiling platform, enabling AI assistants to analyze CPU performance, memory usage, and identify optimization opportunities in production systems.", + "url": "https://api.polarsignals.com/api/mcp/", + "transport": "streamable-http", + "category": "devops", + "tags": [ + "devops", + "profiling", + "performance", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=polarsignals.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/polar-signals.md", + "headers": { + "Authorization": "Bearer ${POLAR_SIGNALS_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "polar-signals.api_key", + "env": "POLAR_SIGNALS_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "prisma-postgres", + "title": "Prisma Postgres", + "description": "Access PostgreSQL databases through Prisma's type-safe ORM with automated migrations and intuitive query building.", + "url": "https://mcp.prisma.io/mcp", + "transport": "streamable-http", + "category": "database", + "tags": [ + "database", + "postgres", + "orm", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=prisma.io&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/prisma-postgres.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "prisma-postgres", + "env": "PRISMA_POSTGRES_PERSONAL_ACCESS_TOKEN", + "secret": "prisma-postgres.personal_access_token" + } + ] + } + }, + { + "id": "pulumi-remote", + "title": "Pulumi", + "description": "Create, deploy, and manage cloud infrastructure using your favorite language.", + "url": "https://mcp.ai.pulumi.com/mcp", + "transport": "streamable-http", + "category": "devops", + "tags": [ + "devops", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=pulumi.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/pulumi-remote.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "pulumi-remote", + "env": "PULUMI_REMOTE_PERSONAL_ACCESS_TOKEN", + "secret": "pulumi-remote.personal_access_token" + } + ] + } + }, + { + "id": "remote-mcp", + "title": "Remote MCP", + "description": "Tools for finding remote MCP servers.", + "url": "https://mcp.remote-mcp.com", + "transport": "streamable-http", + "category": "tools", + "tags": [ + "tools", + "mcp", + "directory", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=remote-mcp.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/remote-mcp.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "rube", + "title": "Rube", + "description": "Access to Rube's catalog of remote MCP servers.", + "url": "https://rube.app/mcp", + "transport": "streamable-http", + "category": "tools", + "tags": [ + "tools", + "automation", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=composio.dev&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/rube.md", + "headers": { + "Authorization": "Bearer ${RUBE_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "rube.api_key", + "env": "RUBE_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "semgrep", + "title": "Semgrep", + "description": "MCP server for using Semgrep to scan code for security vulnerabilities.", + "url": "https://mcp.semgrep.ai/mcp", + "transport": "streamable-http", + "category": "security", + "tags": [ + "security", + "code-analysis", + "sast", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=semgrep.ai&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/semgrep.md", + "headers": {}, + "auth": { + "type": "none" + } + }, + { + "id": "short-io", + "title": "Short.io", + "description": "Access to Short.io's link shortener and analytics tools.", + "url": "https://ai-assistant.short.io/mcp", + "transport": "streamable-http", + "category": "tools", + "tags": [ + "tools", + "url-shortener", + "analytics", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=short.io&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/short-io.md", + "headers": { + "Authorization": "Bearer ${SHORT_IO_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "short-io.api_key", + "env": "SHORT_IO_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "stripe-remote", + "title": "Stripe", + "description": "Interact with Stripe services over the Stripe API.", + "url": "https://mcp.stripe.com", + "transport": "streamable-http", + "category": "payments", + "tags": [ + "payments", + "finance", + "billing", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=stripe.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/stripe-remote.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "stripe-remote", + "env": "STRIPE_REMOTE_PERSONAL_ACCESS_TOKEN", + "secret": "stripe-remote.personal_access_token" + } + ] + } + }, + { + "id": "telnyx", + "title": "Telnyx", + "description": "Enables interaction with powerful telephony, messaging, and AI assistant APIs.", + "url": "https://api.telnyx.com/v2/mcp", + "transport": "streamable-http", + "category": "communication", + "tags": [ + "communication", + "telephony", + "sms", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=telnyx.com&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/telnyx.md", + "headers": { + "Authorization": "Bearer ${TELNYX_API_KEY}" + }, + "auth": { + "type": "api_key", + "secrets": [ + { + "name": "telnyx.api_key", + "env": "TELNYX_API_KEY", + "example": "" + } + ] + } + }, + { + "id": "waystation", + "title": "WayStation", + "description": "Connect AI to your productivity ecosystem with integrations across Notion, Slack, Asana, Monday.com, and 50+ other business tools.", + "url": "https://waystation.ai/mcp", + "transport": "streamable-http", + "category": "productivity", + "tags": [ + "productivity", + "workflow", + "automation", + "remote" + ], + "icon": "https://www.google.com/s2/favicons?domain=waystation.ai&sz=64", + "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/waystation.md", + "headers": {}, + "auth": { + "type": "oauth", + "providers": [ + { + "provider": "waystation", + "env": "WAYSTATION_PERSONAL_ACCESS_TOKEN", + "secret": "waystation.personal_access_token" + } + ] + } + } + ] +}