diff --git a/README.md b/README.md index 0b0317e5..002f96a0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ | **Semantic convergence** | char → Jaccard → embedding cosine cascade for Self-Refine, with LRU/TTL cache and quality regression detection. | | **Production-ready** | gRPC + TLS 1.3, JWT + RBAC, AES-256-GCM, rate limiting, audit logging, 50+ Prometheus metrics. | | **Kubernetes-native** | Operator with 17 CRDs and an autonomous AIOps pipeline (54+ remediation actions), SLO monitoring, post-mortems. | -| **Extensible** | Plugins with Ed25519 signature verification, multi-registry skills (skills.sh, ClawHub, ChatCLI.dev), lifecycle hooks, MCP client (stdio + SSE). | +| **Extensible** | Plugins with Ed25519 signature verification, multi-registry skills (skills.sh, ClawHub, ChatCLI.dev), lifecycle hooks, MCP client (stdio, SSE, HTTP + OAuth). | --- @@ -528,7 +528,7 @@ Credentials are stored with **AES-256-GCM** at `~/.chatcli/auth-profiles.json`. | Feature | Description | |---|---| | **Native tool calling** | Native APIs from OpenAI, Anthropic, Bedrock, Google, ZAI, MiniMax, Moonshot, OpenRouter. `ephemeral` cache for Anthropic. Automatic XML fallback for providers without native support. | -| **MCP (Model Context Protocol)** | Client via stdio and SSE for expanded context. Server (`chatcli mcp-server`) exposes the FULL surface: every built-in tool with read-only annotations, the agent/coder loops with per-call provider/model routing and quality-harness toggles, provider discovery, and all installed skills served as MCP prompts. Exposure policy via `CHATCLI_MCP_TOOLS` (all/safe/allowlist). ACP server (`chatcli acp`) with chat/agent/coder session modes, live streaming and cancellation, for editors (Zed) and agent-to-agent use. | +| **MCP (Model Context Protocol)** | Client via stdio, SSE, and streamable HTTP for expanded context. Remote (HTTP/SSE) servers gated behind OAuth 2.1 are supported end-to-end: on a `401` the client discovers the authorization server (RFC 9728/8414), registers dynamically (RFC 7591), runs a PKCE browser flow, and refreshes tokens transparently (stored AES-256-GCM in the encrypted auth store). Authorize with `/mcp login `, or let the agent call the `@mcp-login` tool when a call reports "authorization required". Server (`chatcli mcp-server`) exposes the FULL surface: every built-in tool with read-only annotations, the agent/coder loops with per-call provider/model routing and quality-harness toggles, provider discovery, and all installed skills served as MCP prompts. Exposure policy via `CHATCLI_MCP_TOOLS` (all/safe/allowlist). ACP server (`chatcli acp`) with chat/agent/coder session modes, live streaming and cancellation, for editors (Zed) and agent-to-agent use. | | **Chat Gateway** | Runs as a messaging daemon (Telegram, Slack, Discord, WhatsApp, webhook): each message runs through the agent loop and progress is streamed back to the chat. Voice messages are transcribed (local-first whisper) and answered in voice by default (`CHATCLI_GATEWAY_VOICE_REPLY=auto\|always\|never`); each conversation controls it by asking in natural language ("answer me in audio" / "stop sending audio") via the `@voice` tool, with the preference persisted. | | **Embedded voice (TTS)** | `CHATCLI_TTS_PROVIDER=embedded` — offline Kokoro neural voice, no API key and no cgo: downloads the sherpa-onnx engine + model once (~150MB) and works the same on Linux/macOS/Windows. Routes pt-BR/English by reply language (`CHATCLI_TTS_VOICE=bm_george`, `CHATCLI_TTS_VOICE_PT=pm_alex`); the other backends (say/espeak, self-hosted, OpenAI/Groq/Gemini) remain available. | | **Embedded transcription (STT)** | Offline multilingual Whisper via sherpa-onnx, no API key and no cgo — and the automatic fallback: with nothing configured, the gateway downloads the engine + an ONNX model once (~200MB for `base`; `CHATCLI_TRANSCRIPTION_MODEL=tiny\|base\|small\|…`) at startup and transcribes voice notes auto-detecting the spoken language. OGG/Opus voice notes (Telegram/WhatsApp) decode in pure Go — no ffmpeg needed; only residual formats (mp3/m4a) require ffmpeg, and the gateway preflight + `/gateway status` warn with your platform's install command. `CHATCLI_TRANSCRIPTION_PROVIDER=embedded` forces it over the other backends (local whisper CLI, self-hosted, Groq/OpenAI), which remain available. | @@ -565,7 +565,7 @@ chatcli/ lessonq/ Reflexion durable queue (WAL + worker pool + DLQ) workers/ 14 agents + dispatcher + FileLockManager hooks/ Lifecycle events (shell/webhook) - mcp/ MCP client (stdio + SSE) + mcp/ MCP client (stdio, SSE, HTTP + OAuth) plugins/ Plugin manager + signature verification scheduler/ Chronos — durable scheduler (WAL + cron + DAG + daemon) condition/ 10 evaluators (shell, http, k8s, docker, tcp, llm, ...) diff --git a/README_PT.md b/README_PT.md index 6e1b4479..c18eacba 100644 --- a/README_PT.md +++ b/README_PT.md @@ -65,7 +65,7 @@ | **Convergência semântica** | Cascade char → Jaccard → embedding cosine para Self-Refine, com cache LRU/TTL e quality regression detection. | | **Production-ready** | gRPC + TLS 1.3, JWT + RBAC, AES-256-GCM, rate limiting, audit logging, 50+ métricas Prometheus. | | **Kubernetes-native** | Operador com 17 CRDs e pipeline AIOps autônomo (54+ ações de remediação), SLO monitoring, post-mortems. | -| **Extensível** | Plugins com verificação Ed25519, skills multi-registry (skills.sh, ClawHub, ChatCLI.dev), hooks de lifecycle, MCP client (stdio + SSE). | +| **Extensível** | Plugins com verificação Ed25519, skills multi-registry (skills.sh, ClawHub, ChatCLI.dev), hooks de lifecycle, MCP client (stdio, SSE, HTTP + OAuth). | --- @@ -527,7 +527,7 @@ Credenciais armazenadas com **AES-256-GCM** em `~/.chatcli/auth-profiles.json`. | Feature | Descrição | |---|---| | **Tool calling nativo** | APIs nativas de OpenAI, Anthropic, Bedrock, Google, ZAI, MiniMax, Moonshot, OpenRouter. Cache `ephemeral` para Anthropic. XML fallback automático para providers sem suporte nativo. | -| **MCP (Model Context Protocol)** | Client via stdio e SSE para contexto expandido. Server (`chatcli mcp-server`) expõe chat, agent, coder e built-in tools; modo ACP (`chatcli acp`) para editores. | +| **MCP (Model Context Protocol)** | Client via stdio, SSE e streamable HTTP para contexto expandido. Servidores remotos (HTTP/SSE) protegidos por OAuth 2.1 têm suporte fim-a-fim: num `401` o client descobre o authorization server (RFC 9728/8414), registra-se dinamicamente (RFC 7591), roda o fluxo PKCE no navegador e renova tokens de forma transparente (guardados com AES-256-GCM no cofre de auth criptografado). Autorize com `/mcp login `, ou deixe o agente chamar a tool `@mcp-login` quando uma chamada reportar "autorização necessária". Server (`chatcli mcp-server`) expõe chat, agent, coder e built-in tools; modo ACP (`chatcli acp`) para editores. | | **Chat Gateway** | Roda como daemon de mensageria (Telegram, Slack, Discord, WhatsApp, webhook): cada mensagem passa pelo agent loop e o progresso é transmitido de volta ao chat. Mensagens de voz são transcritas (whisper local-first) e respondidas em voz por padrão (`CHATCLI_GATEWAY_VOICE_REPLY=auto\|always\|never`); cada conversa controla isso pedindo em linguagem natural ("responde em áudio" / "para de mandar áudio") via tool `@voice`, com preferência persistida. | | **Voz embarcada (TTS)** | `CHATCLI_TTS_PROVIDER=embedded` — voz neural Kokoro offline, sem API key e sem cgo: baixa o engine sherpa-onnx + modelo uma única vez (~150MB) e funciona igual em Linux/macOS/Windows. Roteia pt-BR/inglês por idioma da resposta (`CHATCLI_TTS_VOICE=bm_george`, `CHATCLI_TTS_VOICE_PT=pm_alex`); demais backends (say/espeak, self-hosted, OpenAI/Groq/Gemini) seguem disponíveis. | | **Transcrição embarcada (STT)** | Whisper multilíngue offline via sherpa-onnx, sem API key e sem cgo — é o fallback automático: sem nada configurado, o gateway baixa o engine + modelo ONNX uma única vez (~200MB no `base`; `CHATCLI_TRANSCRIPTION_MODEL=tiny\|base\|small\|…`) já no startup e transcreve notas de voz detectando o idioma falado. Voice notes OGG/Opus (Telegram/WhatsApp) são decodificadas em puro Go — sem ffmpeg; só formatos residuais (mp3/m4a) pedem ffmpeg, e o preflight do gateway + `/gateway status` avisam com o comando de instalação da sua plataforma. `CHATCLI_TRANSCRIPTION_PROVIDER=embedded` força-o sobre outros backends (whisper CLI local, self-hosted, Groq/OpenAI), que seguem disponíveis. | @@ -564,7 +564,7 @@ chatcli/ lessonq/ Reflexion durable queue (WAL + worker pool + DLQ) workers/ 14 agentes + dispatcher + FileLockManager hooks/ Lifecycle events (shell/webhook) - mcp/ MCP client (stdio + SSE) + mcp/ MCP client (stdio, SSE, HTTP + OAuth) plugins/ Plugin manager + signature verification scheduler/ Chronos — scheduler durável (WAL + cron + DAG + daemon) condition/ 10 evaluators (shell, http, k8s, docker, tcp, llm, ...) diff --git a/auth/reuse.go b/auth/reuse.go new file mode 100644 index 00000000..6a36c168 --- /dev/null +++ b/auth/reuse.go @@ -0,0 +1,119 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package auth + +import ( + "context" + "net/http" + "net/url" + "strings" + "time" + + "github.com/diillson/chatcli/utils" + "go.uber.org/zap" +) + +// This file exposes a minimal, stable surface over otherwise-internal OAuth +// primitives so subsystems outside the provider logins (notably MCP OAuth, +// whose authorization/token endpoints are DISCOVERED at runtime rather than +// hard-coded per provider) can reuse the exact same, already-hardened +// building blocks instead of re-implementing them. Names are deliberately +// distinct from the internal helpers they wrap (not just case variants). + +// browserLauncher is the function OpenInBrowser delegates to. It is a package +// variable (defaulting to the real cross-platform opener) purely so tests can +// substitute a stub and avoid actually launching a browser. +var browserLauncher = openBrowser + +// OpenInBrowser opens the user's default browser at rawURL using the same +// cross-platform launch logic the provider login flows use. Returns an error +// when no browser could be launched so the caller can fall back to printing +// the URL. +func OpenInBrowser(rawURL string) error { + return browserLauncher(rawURL) +} + +// TokenExchangeRequest holds the form parameters for an OAuth token exchange. +// It is a concrete type (rather than a free-form map) so the exported surface +// stays type-safe; empty fields are omitted from the request body. +type TokenExchangeRequest struct { + GrantType string + ClientID string + Code string + CodeVerifier string + RedirectURI string + RefreshToken string + Resource string + Scope string +} + +// pairs returns the non-empty (key, value) parameters in a stable order. +func (r TokenExchangeRequest) pairs() [][2]string { + all := [][2]string{ + {"grant_type", r.GrantType}, + {"client_id", r.ClientID}, + {"code", r.Code}, + {"code_verifier", r.CodeVerifier}, + {"redirect_uri", r.RedirectURI}, + {"refresh_token", r.RefreshToken}, + {"resource", r.Resource}, + {"scope", r.Scope}, + } + out := make([][2]string, 0, len(all)) + for _, kv := range all { + if kv[1] != "" { + out = append(out, kv) + } + } + return out +} + +func (r TokenExchangeRequest) toPayload() map[string]any { + m := make(map[string]any, 8) + for _, kv := range r.pairs() { + m[kv[0]] = kv[1] + } + return m +} + +func (r TokenExchangeRequest) toForm() url.Values { + form := url.Values{} + for _, kv := range r.pairs() { + form.Set(kv[0], kv[1]) + } + return form +} + +// ExchangeToken performs an OAuth token exchange with a JSON body against +// tokenURL and returns the parsed token response. Used by providers that +// accept a JSON token request (Anthropic/OpenAI-style). +func ExchangeToken(ctx context.Context, logger *zap.Logger, tokenURL string, req TokenExchangeRequest) (*OAuthTokenResponse, error) { + return exchangeOAuthToken(ctx, logger, tokenURL, req.toPayload()) +} + +// ExchangeTokenForm performs an OAuth token exchange with an +// application/x-www-form-urlencoded body — the encoding RFC 6749 §4.1.3 +// mandates for the token endpoint and what standards-compliant authorization +// servers (e.g. AWS, Cognito, Keycloak) require. This is the correct exchange +// for MCP OAuth, whose authorization servers are generic OAuth 2.1 endpoints +// rather than the JSON-accepting LLM-provider endpoints. +func ExchangeTokenForm(ctx context.Context, logger *zap.Logger, tokenURL string, req TokenExchangeRequest) (*OAuthTokenResponse, error) { + hc := utils.NewHTTPClient(logger, 30*time.Second) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(req.toForm().Encode())) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("Accept", "application/json") + return doTokenExchange(hc, httpReq) +} + +// TokenExpiryMilli converts an OAuth expires_in (seconds) into an absolute +// epoch-millisecond expiry, applying the shared 5-minute safety margin so all +// credentials refresh consistently ahead of the real upstream expiry. +func TokenExpiryMilli(expiresIn int64) int64 { + return calcExpiresAtMilli(expiresIn) +} diff --git a/auth/reuse_test.go b/auth/reuse_test.go new file mode 100644 index 00000000..5a295b29 --- /dev/null +++ b/auth/reuse_test.go @@ -0,0 +1,159 @@ +/* + * ChatCLI - tests for the reusable OAuth surface + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" +) + +func TestOpenInBrowser(t *testing.T) { + old := browserLauncher + t.Cleanup(func() { browserLauncher = old }) + var got string + browserLauncher = func(u string) error { got = u; return nil } + if err := OpenInBrowser("http://127.0.0.1/x"); err != nil { + t.Fatalf("OpenInBrowser: %v", err) + } + if got != "http://127.0.0.1/x" { + t.Fatalf("launcher got %q", got) + } +} + +func TestTokenExpiryMilli(t *testing.T) { + if TokenExpiryMilli(3600) <= 0 { + t.Fatalf("expected positive expiry") + } + // Zero/negative expires_in falls back to a default future expiry. + if TokenExpiryMilli(0) <= 0 { + t.Fatalf("expected fallback expiry for 0") + } +} + +func TestExchangeTokenOmitsEmptyFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["scope"]; ok { + t.Errorf("empty scope must be omitted, got %v", body["scope"]) + } + if body["grant_type"] != "authorization_code" { + t.Errorf("grant_type = %v", body["grant_type"]) + } + if body["code"] != "abc" { + t.Errorf("code = %v", body["code"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at", "expires_in": 3600, "token_type": "Bearer", + }) + })) + defer srv.Close() + + tr, err := ExchangeToken(context.Background(), zap.NewNop(), srv.URL, TokenExchangeRequest{ + GrantType: "authorization_code", + ClientID: "c1", + Code: "abc", + }) + if err != nil { + t.Fatalf("ExchangeToken: %v", err) + } + if tr.AccessToken != "at" { + t.Fatalf("access token = %q", tr.AccessToken) + } +} + +func TestExchangeTokenForm(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("content-type = %q, want form-urlencoded", ct) + } + _ = r.ParseForm() + if r.PostForm.Get("grant_type") != "authorization_code" { + t.Errorf("grant_type = %q", r.PostForm.Get("grant_type")) + } + if r.PostForm.Get("code") != "abc" || r.PostForm.Get("resource") != "https://x/mcp" { + t.Errorf("missing form fields: %v", r.PostForm) + } + if _, ok := r.PostForm["scope"]; ok { + t.Errorf("empty scope must be omitted") + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at", "expires_in": 3600}) + })) + defer srv.Close() + + tr, err := ExchangeTokenForm(context.Background(), zap.NewNop(), srv.URL, TokenExchangeRequest{ + GrantType: "authorization_code", + ClientID: "c1", + Code: "abc", + RedirectURI: "http://127.0.0.1:8765/callback", + Resource: "https://x/mcp", + }) + if err != nil { + t.Fatalf("ExchangeTokenForm: %v", err) + } + if tr.AccessToken != "at" { + t.Fatalf("access token = %q", tr.AccessToken) + } +} + +// TestNewOAuthTokenProviderWithRefresh proves the custom refresh function is +// the one used (not the built-in provider switch), and that a Token() call on +// an expired credential triggers it. +func TestNewOAuthTokenProviderWithRefresh(t *testing.T) { + called := 0 + refresh := func(_ context.Context, cred *AuthProfileCredential, _ *zap.Logger) (*AuthProfileCredential, error) { + called++ + cred.Access = "refreshed" + cred.Expires = TokenExpiryMilli(3600) + return cred, nil + } + cred := &AuthProfileCredential{ + CredType: CredentialOAuth, + Provider: ProviderMCP, + Access: "stale", + Refresh: "r1", + Expires: 1, // already expired → forces refresh on first Token() + } + p := NewOAuthTokenProviderWithRefresh(cred, "", "test", refresh, zap.NewNop()) + defer p.Close() + + tok, err := p.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + if tok != "refreshed" || called == 0 { + t.Fatalf("custom refresh not used: tok=%q called=%d", tok, called) + } + if p.Mode() != AuthModeOAuth { + t.Fatalf("mode = %v", p.Mode()) + } + if p.Provider() != ProviderMCP { + t.Fatalf("provider = %v", p.Provider()) + } +} + +// A nil refresh function must fall back to the built-in RefreshOAuth without +// panicking; a non-refreshable credential (no refresh token) just serves its +// current token. +func TestNewOAuthTokenProviderNilRefreshFallback(t *testing.T) { + cred := &AuthProfileCredential{ + CredType: CredentialOAuth, + Provider: ProviderMCP, + Access: "tok", + // no Refresh / Expires → no background loop, no refresh attempted + } + p := NewOAuthTokenProviderWithRefresh(cred, "", "test", nil, zap.NewNop()) + defer p.Close() + tok, err := p.Token(context.Background()) + if err != nil || tok != "tok" { + t.Fatalf("Token = %q err=%v", tok, err) + } +} diff --git a/auth/token_provider.go b/auth/token_provider.go index 4d0e6657..b3332e50 100644 --- a/auth/token_provider.go +++ b/auth/token_provider.go @@ -221,12 +221,22 @@ type oauthTokenProvider struct { // a positive expiry. The returned provider must be Close()'d to release the // goroutine. func newOAuthTokenProvider(cred *AuthProfileCredential, profileID, source string, logger *zap.Logger) *oauthTokenProvider { + return newOAuthTokenProviderWithFn(cred, profileID, source, RefreshOAuth, logger) +} + +// newOAuthTokenProviderWithFn is the shared constructor. The refresh function +// is captured before the background goroutine launches so it can never race +// with a proactive refresh firing against the default RefreshOAuth. +func newOAuthTokenProviderWithFn(cred *AuthProfileCredential, profileID, source string, refresh oauthRefreshFn, logger *zap.Logger) *oauthTokenProvider { + if refresh == nil { + refresh = RefreshOAuth + } p := &oauthTokenProvider{ cred: *cred, profileID: profileID, source: source, logger: logger, - refreshFn: RefreshOAuth, + refreshFn: refresh, refreshLead: defaultRefreshLead, refreshMinWait: defaultRefreshMinWait, refreshErrorBackoff: defaultRefreshErrorBackoff, @@ -239,6 +249,20 @@ func newOAuthTokenProvider(cred *AuthProfileCredential, profileID, source string return p } +// NewOAuthTokenProviderWithRefresh builds a refreshable OAuth TokenProvider +// whose refresh round-trip is supplied by the caller instead of the built-in +// per-provider switch in RefreshOAuth. It exists for subsystems whose token +// endpoint and client_id are discovered at runtime — notably MCP OAuth, where +// each server advertises its own authorization server via RFC 9728/8414 +// discovery and registers a client via RFC 7591. All of the base provider's +// hardening (single-flight refresh coalescing, proactive background renewal, +// and persistence back to the encrypted auth store) is retained. +// +// The returned provider must be Close()'d to release its background goroutine. +func NewOAuthTokenProviderWithRefresh(cred *AuthProfileCredential, profileID, source string, refresh func(ctx context.Context, cred *AuthProfileCredential, logger *zap.Logger) (*AuthProfileCredential, error), logger *zap.Logger) TokenProvider { + return newOAuthTokenProviderWithFn(cred, profileID, source, refresh, logger) +} + func (p *oauthTokenProvider) Token(ctx context.Context) (string, error) { p.mu.Lock() if !p.invalidated && !p.cred.IsExpired() { diff --git a/auth/types.go b/auth/types.go index da77f595..42e5469d 100644 --- a/auth/types.go +++ b/auth/types.go @@ -25,6 +25,12 @@ const ( ProviderOpenAICodex ProviderID = "openai-codex" ProviderGitHubCopilot ProviderID = "github-copilot" ProviderGitHubModels ProviderID = "github-models" + // ProviderMCP is the base provider id for OAuth credentials minted for + // remote MCP servers. Per-server profiles use the id "mcp:" so + // each server keeps an independent credential; the token/authorization + // endpoints and client_id are discovered at runtime and kept alongside + // the credential rather than hard-coded here (see cli/mcp/oauth.go). + ProviderMCP ProviderID = "mcp" ) // AuthMode indica como a autenticação foi resolvida. diff --git a/cli/agent_mode.go b/cli/agent_mode.go index 44715f1a..1d07817c 100644 --- a/cli/agent_mode.go +++ b/cli/agent_mode.go @@ -1286,6 +1286,12 @@ func (a *AgentMode) getToolContextString() string { toolDescriptions = append(toolDescriptions, note) } } + // Independently of tool availability, surface any server blocked on + // OAuth authorization so the model knows it can run @mcp-login to + // unlock it (its tools only appear after a successful login). + if note := buildMCPAuthNote(a.cli.mcpManager.GetServerStatus()); note != "" { + toolDescriptions = append(toolDescriptions, note) + } } indexSection := "" @@ -3370,6 +3376,24 @@ func buildMCPEmptyNote(statuses []mcp.ServerStatus) string { return i18n.T("agent.mcp.note_unavailable") + "\n" } +// buildMCPAuthNote lists servers currently blocked on OAuth authorization and +// instructs the model to run @mcp-login for them. It is appended to the tool +// context in every mode (whether or not other MCP tools are already usable) so +// a server whose tools never loaded — because it demands authorization — is +// still discoverable and actionable to the agent. +func buildMCPAuthNote(statuses []mcp.ServerStatus) string { + var pending []string + for _, s := range statuses { + if s.AuthRequired { + pending = append(pending, s.Name) + } + } + if len(pending) == 0 { + return "" + } + return "\n" + i18n.T("agent.mcp.note_auth_required", strings.Join(pending, ", ")) + "\n" +} + // mcpToolHasRequiredParams reports whether a JSON schema declares any // required input parameter. Used to distinguish a tool that legitimately // accepts {} (e.g. list_allowed_directories) from one whose call lost diff --git a/cli/cli.go b/cli/cli.go index 9d177959..6335bc83 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -498,6 +498,11 @@ func NewChatCLI(ctx context.Context, manager manager.LLMManager, logger *zap.Log // diagnostics, definition, references, symbols, hover. Adapter wired // below over a lazily created, session-scoped server pool. pluginMgr.RegisterBuiltinPlugin(plugins.NewBuiltinLSPPlugin()) + // @mcp-login — authorize an OAuth-protected remote MCP server so its + // tools become usable. The agent invokes it after an mcp_* call fails + // with an "authorization required" error. Adapter wired below over the + // live MCP manager. + pluginMgr.RegisterBuiltinPlugin(plugins.NewBuiltinMCPLoginPlugin()) // @tools — tool-catalog meta-tool behind the deferred catalog: the // model pulls full definitions of indexed tools on demand instead of // every definition riding in every prompt. Adapter wired below. @@ -696,6 +701,8 @@ func NewChatCLI(ctx context.Context, manager manager.LLMManager, logger *zap.Log // Wire the @lsp tool to the session language-server pool (created lazily // on first use; shut down with the session). plugins.SetLSPAdapter(&lspToolAdapter{cli: cli}) + // Wire the @mcp-login tool to the live MCP manager (OAuth authorization). + plugins.SetMCPAuthAdapter(&mcpLoginAdapter{cli: cli}) // Wire the @tools meta-tool to the plugin registry (deferred catalog). plugins.SetToolCatalogAdapter(&toolCatalogPluginAdapter{cli: cli}) plugins.SetChannelsAdapter(&channelsPluginAdapter{cli: cli}) diff --git a/cli/mcp/manager.go b/cli/mcp/manager.go index 8d40bd1f..5e1193b1 100644 --- a/cli/mcp/manager.go +++ b/cli/mcp/manager.go @@ -39,6 +39,11 @@ type ServerConnection struct { Process *os.Process transport mcpTransport // real transport (stdio or SSE) logs *logRing // recent stderr/event lines for /mcp logs + + // oauthChallenge caches the WWW-Authenticate challenge parsed from the + // last 401, so a subsequent /mcp login (or @mcp-login) can jump straight + // to discovery without re-probing the server. + oauthChallenge oauthChallenge } // logRing is a tiny bounded ring buffer for a server's recent log @@ -490,6 +495,99 @@ func (m *Manager) StopOne(ctx context.Context, name string) error { return nil } +// LoginOAuth runs the interactive OAuth authorization for a remote MCP server +// and reconnects it so the fresh credential is used and its tools are +// discovered. It is the programmatic entry point behind both the /mcp login +// command and the @mcp-login agent tool. +// +// It opens a browser and blocks until the user completes the flow, so callers +// must invoke it from an interactive surface — never from an unattended path. +func (m *Manager) LoginOAuth(ctx context.Context, serverName string) error { + m.mu.RLock() + conn, ok := m.servers[serverName] + m.mu.RUnlock() + if !ok { + return fmt.Errorf("%w: %q", ErrServerNotConfigured, serverName) + } + if conn.Config.Transport != TransportStreamableHTTP && conn.Config.Transport != TransportSSE { + return fmt.Errorf("%s", i18n.T("mcp.oauth.not_remote", serverName)) + } + if strings.TrimSpace(conn.Config.URL) == "" { + return fmt.Errorf("%s", i18n.T("mcp.oauth.no_url", serverName)) + } + + m.mu.RLock() + ch := conn.oauthChallenge + connected := conn.Status.Connected + m.mu.RUnlock() + + if err := LoginServer(ctx, serverName, conn.Config.URL, ch, m.logger); err != nil { + return err + } + + // Reconnect on a context DETACHED from the caller's ctx. The caller's ctx + // is cancelled the moment this login call returns (e.g. the @mcp-login + // tool's bounded context), and the HTTP/SSE transport ties its whole + // lifetime to the context it is built with — so reconnecting on ctx would + // kill the transport instantly, surfacing as "context canceled" on the + // next tool call. WithoutCancel keeps request values but drops + // cancellation and deadline; the transport is instead closed explicitly on + // /mcp reload/restart and at session shutdown (StopAll). + reconnectCtx := context.WithoutCancel(ctx) + if connected { + _ = m.StopOne(reconnectCtx, serverName) + } + return m.StartOne(reconnectCtx, serverName) +} + +// LogoutOAuth forgets a server's stored OAuth credential and metadata AND +// stops the running server, so the in-memory token (held by the live transport +// and its background refresher) stops being used immediately and the server's +// tools disappear. A subsequent /mcp login (or /mcp start) re-authorizes. +// +// Without the stop, a running transport keeps serving from its cached token — +// which is why a bare credential delete appeared to "still work". +func (m *Manager) LogoutOAuth(ctx context.Context, serverName string) error { + m.mu.RLock() + conn, ok := m.servers[serverName] + connected := ok && conn.Status.Connected + m.mu.RUnlock() + if !ok { + return fmt.Errorf("%w: %q", ErrServerNotConfigured, serverName) + } + if err := LogoutServer(serverName, m.logger); err != nil { + return err + } + if connected { + stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + _ = m.StopOne(stopCtx, serverName) + } + return nil +} + +// HasOAuthCredential reports whether the named server has a stored MCP OAuth +// credential from a previous successful login. +func (m *Manager) HasOAuthCredential(serverName string) bool { + return hasStoredMCPOAuth(serverName, m.logger) +} + +// AuthRequiredServers returns, in alphabetical order, the names of servers +// currently blocked on OAuth authorization. Used by the agent prompt and +// /mcp status to surface an actionable login hint. +func (m *Manager) AuthRequiredServers() []string { + m.mu.RLock() + defer m.mu.RUnlock() + var out []string + for name, conn := range m.servers { + if conn.Status.AuthRequired { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + // ServerNames returns the names of all configured servers in // alphabetical order. Used by the /mcp completer to suggest valid // targets and by callers that need to iterate the configured set @@ -964,14 +1062,36 @@ func (m *Manager) startHTTPServer(ctx context.Context, conn *ServerConnection) e return fmt.Errorf("failed to construct HTTP transport: %w", err) } + // Attach a refreshable OAuth token provider when the server has stored + // MCP OAuth credentials from a previous /mcp login. Absent credentials + // leave the provider nil and the transport falls back to the static + // AuthConfig (bearer/basic/header) exactly as before. + if provider, perr := mcpTokenProvider(conn.Config.Name, conn.Config.URL, m.logger); perr == nil && provider != nil { + transport.tokenProvider = provider + } + conn.transport = transport if err := m.initializeServer(conn); err != nil { _ = transport.Close(ctx) conn.transport = nil + // A 401 OAuth challenge is not a hard failure: record it as + // "authorization required" so /mcp status and the agent surface an + // actionable login hint rather than a generic connection error. + if oe, ok := IsOAuthRequired(err); ok { + m.mu.Lock() + conn.Status.AuthRequired = true + conn.oauthChallenge = oe.Challenge + m.mu.Unlock() + } return fmt.Errorf("%s: %w", i18n.T("mcp.transport.http_initialize_failed"), err) } + // Successful (re)connect clears any stale auth-required flag. + m.mu.Lock() + conn.Status.AuthRequired = false + m.mu.Unlock() + if err := m.discoverTools(conn); err != nil { m.logger.Warn("MCP tool discovery failed", zap.String("server", conn.Config.Name), diff --git a/cli/mcp/manager_oauth_test.go b/cli/mcp/manager_oauth_test.go new file mode 100644 index 00000000..036cb998 --- /dev/null +++ b/cli/mcp/manager_oauth_test.go @@ -0,0 +1,156 @@ +/* + * ChatCLI - MCP manager OAuth tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" +) + +// newFakeMCPServer answers initialize + tools/list with a single tool so a +// manager can bring the connection up without a real MCP server. +func newFakeMCPServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Method == "notifications/initialized" { + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + var result json.RawMessage + switch req.Method { + case "initialize": + result = json.RawMessage(`{"protocolVersion":"2024-11-05","serverInfo":{"name":"fake"}}`) + case "tools/list": + result = json.RawMessage(`{"tools":[{"name":"ping","description":"p","inputSchema":{}}]}`) + default: + result = json.RawMessage(`{}`) + } + _ = json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result}) + })) +} + +func managerWithServer(t *testing.T, cfg ServerConfig) *Manager { + t.Helper() + m := NewManagerWithOptions(zap.NewNop(), ChannelManagerOptions{}) + m.servers[cfg.Name] = &ServerConnection{ + Config: cfg, + Status: ServerStatus{Name: cfg.Name}, + logs: newLogRing(mcpLogRingCapacity), + } + return m +} + +// TestLogoutOAuthStopsConnectedServer is the regression guard for the reported +// bug: logging out must stop the running server so its in-memory token is no +// longer used and its tools disappear — not merely delete the stored profile. +func TestLogoutOAuthStopsConnectedServer(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + srv := newFakeMCPServer(t) + defer srv.Close() + + m := managerWithServer(t, ServerConfig{ + Name: "aws", Transport: TransportStreamableHTTP, URL: srv.URL, Enabled: true, + }) + ctx := context.Background() + if err := m.StartOne(ctx, "aws"); err != nil { + t.Fatalf("StartOne: %v", err) + } + if m.ToolCount() == 0 { + t.Fatalf("expected tools after connect") + } + + if err := m.LogoutOAuth(ctx, "aws"); err != nil { + t.Fatalf("LogoutOAuth: %v", err) + } + + for _, s := range m.GetServerStatus() { + if s.Name == "aws" && s.Connected { + t.Fatalf("server should be stopped after logout") + } + } + if m.ToolCount() != 0 { + t.Fatalf("tools should be gone after logout, got %d", m.ToolCount()) + } +} + +// TestReconnectUsesLongLivedContext is the regression guard for the "context +// canceled" bug: a server reconnected after login must be tied to the session +// context, so cancelling the short-lived login context does not kill the +// freshly-connected transport. +func TestReconnectUsesLongLivedContext(t *testing.T) { + srv := newFakeMCPServer(t) + defer srv.Close() + + m := managerWithServer(t, ServerConfig{ + Name: "aws", Transport: TransportStreamableHTTP, URL: srv.URL, Enabled: true, + }) + + // Mimic LoginOAuth's reconnect: a bounded login context, detached via + // WithoutCancel so the transport survives the login call returning. + loginCtx, cancel := context.WithCancel(context.Background()) + if err := m.StartOne(context.WithoutCancel(loginCtx), "aws"); err != nil { + t.Fatalf("StartOne: %v", err) + } + cancel() // login tool returns → its context is cancelled + + // A tool call must still succeed — the transport was detached. + if _, err := m.ExecuteTool(context.Background(), "ping", nil); err != nil { + t.Fatalf("tool call after login-context cancel should succeed, got: %v", err) + } +} + +func TestLogoutOAuthUnknownServer(t *testing.T) { + m := NewManagerWithOptions(zap.NewNop(), ChannelManagerOptions{}) + if err := m.LogoutOAuth(context.Background(), "ghost"); err == nil { + t.Fatalf("expected error for unknown server") + } +} + +func TestLoginOAuthRejectsNonRemoteAndNoURL(t *testing.T) { + // stdio server → OAuth login does not apply. + m := managerWithServer(t, ServerConfig{Name: "local", Transport: TransportStdio, Command: "echo"}) + if err := m.LoginOAuth(context.Background(), "local"); err == nil { + t.Errorf("expected not-remote error for stdio server") + } + // http server without a URL → error before any network work. + m2 := managerWithServer(t, ServerConfig{Name: "web", Transport: TransportStreamableHTTP}) + if err := m2.LoginOAuth(context.Background(), "web"); err == nil { + t.Errorf("expected no-url error") + } + // unknown server. + if err := m2.LoginOAuth(context.Background(), "ghost"); err == nil { + t.Errorf("expected unknown-server error") + } +} + +func TestAuthRequiredServersAndHasCredential(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + m := managerWithServer(t, ServerConfig{Name: "aws", Transport: TransportStreamableHTTP, URL: "https://x/mcp"}) + // Nothing flagged yet. + if len(m.AuthRequiredServers()) != 0 { + t.Fatalf("expected no auth-required servers") + } + // Flag it and confirm it surfaces. + m.mu.Lock() + m.servers["aws"].Status.AuthRequired = true + m.mu.Unlock() + got := m.AuthRequiredServers() + if len(got) != 1 || got[0] != "aws" { + t.Fatalf("AuthRequiredServers = %v, want [aws]", got) + } + // No credential stored in the fresh HOME. + if m.HasOAuthCredential("aws") { + t.Fatalf("expected no stored credential") + } +} diff --git a/cli/mcp/oauth.go b/cli/mcp/oauth.go new file mode 100644 index 00000000..9e79b749 --- /dev/null +++ b/cli/mcp/oauth.go @@ -0,0 +1,687 @@ +/* + * ChatCLI - MCP OAuth (authorization for remote MCP servers) + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + * + * Implements the MCP Authorization flow so ChatCLI can connect to remote + * (Streamable HTTP / SSE) MCP servers that gate access behind OAuth 2.1 — + * the AWS MCP server being the motivating case. The flow follows the MCP + * authorization spec, which composes several RFCs: + * + * 1. The server answers an unauthenticated request with 401 and a + * WWW-Authenticate header carrying a `resource_metadata` pointer + * (RFC 9728, OAuth Protected Resource Metadata). + * 2. We fetch that Protected Resource Metadata to learn which + * authorization server(s) protect the resource. + * 3. We fetch the Authorization Server Metadata (RFC 8414) to learn the + * authorization / token / registration endpoints. + * 4. If we have no client_id for this server yet we register one via + * Dynamic Client Registration (RFC 7591) with a loopback redirect. + * 5. We run the authorization-code + PKCE flow through a localhost + * callback, echoing the `resource` indicator (RFC 8707) so the token + * is bound to this MCP server. + * 6. Tokens are persisted in the encrypted auth store as profile + * "mcp:" and refreshed transparently thereafter. + * + * All the OAuth primitives (PKCE, browser launch, token exchange, the + * refreshable TokenProvider with single-flight + proactive renewal) are + * reused from the auth package so this file only adds the MCP-specific + * discovery + dynamic client registration on top. + */ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/diillson/chatcli/auth" + "github.com/diillson/chatcli/i18n" + "github.com/diillson/chatcli/utils" + "go.uber.org/zap" +) + +// mcpOAuthClientName is the client_name advertised during Dynamic Client +// Registration. Purely cosmetic — shown on the authorization server's consent +// screen and in its registered-clients list. +const mcpOAuthClientName = "ChatCLI" + +// mcpOAuthCallbackTimeout bounds how long we wait for the user to complete the +// browser authorization before giving up. +const mcpOAuthCallbackTimeout = 5 * time.Minute + +// OAuthRequiredError signals that an MCP HTTP/SSE call could not be completed +// because the server demands OAuth authorization the client has not (yet) +// obtained, or holds an expired/invalid token that could not be refreshed. +// +// It is intentionally actionable: the agent surfaces Error() verbatim so the +// model can react by invoking the @mcp-login tool (or telling the user to run +// /mcp login ) instead of treating it as an opaque failure. +type OAuthRequiredError struct { + Server string + Endpoint string + Challenge oauthChallenge +} + +func (e *OAuthRequiredError) Error() string { + return i18n.T("mcp.oauth.required_error", e.Server, e.Server) +} + +// IsOAuthRequired reports whether err (or anything it wraps) is an +// *OAuthRequiredError, and returns it when so. +func IsOAuthRequired(err error) (*OAuthRequiredError, bool) { + var oe *OAuthRequiredError + if errors.As(err, &oe) { + return oe, true + } + return nil, false +} + +// oauthChallenge holds the fields we care about from a Bearer +// WWW-Authenticate challenge (RFC 6750 §3 / RFC 9728). +type oauthChallenge struct { + ResourceMetadata string // resource_metadata="" (RFC 9728) + Realm string + Scope string + Present bool // true when the header carried a Bearer challenge at all +} + +// parseWWWAuthenticate extracts the Bearer challenge parameters from a +// WWW-Authenticate header value. It is lenient: it only understands the Bearer +// scheme (the only one MCP OAuth uses) and ignores anything else. +func parseWWWAuthenticate(header string) oauthChallenge { + var ch oauthChallenge + header = strings.TrimSpace(header) + if header == "" { + return ch + } + // Header shape: `Bearer realm="x", resource_metadata="https://...", scope="a b"`. + // Case-insensitive scheme match; parameters follow after the scheme token. + rest := header + if idx := strings.IndexByte(header, ' '); idx > 0 { + if strings.EqualFold(header[:idx], "Bearer") { + ch.Present = true + rest = header[idx+1:] + } + } else if strings.EqualFold(header, "Bearer") { + ch.Present = true + return ch + } + if !ch.Present { + return ch + } + for _, part := range splitAuthParams(rest) { + k, v, ok := strings.Cut(part, "=") + if !ok { + continue + } + k = strings.ToLower(strings.TrimSpace(k)) + v = strings.Trim(strings.TrimSpace(v), `"`) + switch k { + case "resource_metadata": + ch.ResourceMetadata = v + case "realm": + ch.Realm = v + case "scope": + ch.Scope = v + } + } + return ch +} + +// splitAuthParams splits a comma-separated auth-param list while respecting +// double-quoted values (a quoted scope like "a, b" must not be split). +func splitAuthParams(s string) []string { + var parts []string + var cur strings.Builder + inQuote := false + for _, r := range s { + switch { + case r == '"': + inQuote = !inQuote + cur.WriteRune(r) + case r == ',' && !inQuote: + parts = append(parts, strings.TrimSpace(cur.String())) + cur.Reset() + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + parts = append(parts, strings.TrimSpace(cur.String())) + } + return parts +} + +// protectedResourceMetadata is the RFC 9728 document served at +// /.well-known/oauth-protected-resource. +type protectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported"` +} + +// authServerMetadata is the RFC 8414 / OpenID Connect discovery document. +type authServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` +} + +// oauthServerMeta is the per-server OAuth state we persist so subsequent +// sessions can refresh without re-discovering or re-registering. Tokens live +// separately in the encrypted auth store; this file holds only non-secret +// endpoints plus the (public) registered client_id. +type oauthServerMeta struct { + Server string `json:"server"` + Resource string `json:"resource"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + ClientID string `json:"client_id"` + Scope string `json:"scope,omitempty"` + RedirectURI string `json:"redirect_uri"` +} + +// mcpOAuthProfileID is the auth-store profile id for a server's tokens. +func mcpOAuthProfileID(server string) string { return "mcp:" + server } + +// oauthMetaDir is the directory holding per-server OAuth metadata files. +func oauthMetaDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.no_home")) + } + return filepath.Join(home, ".chatcli", "mcp_oauth"), nil +} + +func oauthMetaPath(server string) (string, error) { + dir, err := oauthMetaDir() + if err != nil { + return "", err + } + // Sanitize the server name into a safe filename. + safe := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + return r + default: + return '_' + } + }, server) + return filepath.Join(dir, safe+".json"), nil +} + +func loadOAuthMeta(server string) (*oauthServerMeta, error) { + path, err := oauthMetaPath(server) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) //#nosec G304 -- path derived from sanitized server name under ~/.chatcli + if err != nil { + return nil, err + } + var meta oauthServerMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func saveOAuthMeta(meta *oauthServerMeta) error { + dir, err := oauthMetaDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + path, err := oauthMetaPath(meta.Server) + if err != nil { + return err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// hasStoredMCPOAuth reports whether we already hold OAuth metadata AND a +// credential for the server (i.e. a prior login succeeded). +func hasStoredMCPOAuth(server string, logger *zap.Logger) bool { + meta, err := loadOAuthMeta(server) + if err != nil || meta == nil || meta.ClientID == "" { + return false + } + cred := auth.GetProfile(mcpOAuthProfileID(server), logger) + return cred != nil && cred.GetAccessToken() != "" +} + +// oauthDiscoverer performs the RFC 9728 → RFC 8414 discovery chain. It is a +// thin struct so the HTTP client and logger are threaded once. +type oauthDiscoverer struct { + hc *http.Client + logger *zap.Logger +} + +func newOAuthDiscoverer(logger *zap.Logger) *oauthDiscoverer { + return &oauthDiscoverer{hc: utils.NewHTTPClient(logger, 30*time.Second), logger: logger} +} + +// discover resolves the authorization-server metadata for an MCP server, +// starting from an optional resource_metadata URL (from the 401 challenge) and +// falling back to the well-known path on the server's own origin. +func (d *oauthDiscoverer) discover(ctx context.Context, serverURL string, ch oauthChallenge) (*authServerMetadata, *protectedResourceMetadata, error) { + prmURL := strings.TrimSpace(ch.ResourceMetadata) + if prmURL == "" { + origin, err := originOf(serverURL) + if err != nil { + return nil, nil, err + } + prmURL = origin + "/.well-known/oauth-protected-resource" + } + + prm, err := fetchJSON[protectedResourceMetadata](ctx, d.hc, prmURL) + if err != nil { + // Some servers skip RFC 9728 and expose the AS directly on their + // origin. Fall back to treating the server origin as the issuer. + d.logger.Debug("MCP OAuth: protected-resource metadata unavailable, trying origin as issuer", + zap.String("prm_url", prmURL), zap.Error(err)) + origin, oerr := originOf(serverURL) + if oerr != nil { + return nil, nil, err + } + asMeta, aerr := d.fetchAuthServerMetadata(ctx, origin) + if aerr != nil { + return nil, nil, fmt.Errorf("%s: %w", i18n.T("mcp.oauth.discovery_failed"), aerr) + } + return asMeta, &protectedResourceMetadata{Resource: origin}, nil + } + if len(prm.AuthorizationServers) == 0 { + return nil, nil, fmt.Errorf("%s", i18n.T("mcp.oauth.no_auth_servers")) + } + + // Use the first advertised authorization server. Spec allows many; the + // first is the server's stated preference. + var lastErr error + for _, as := range prm.AuthorizationServers { + asMeta, err := d.fetchAuthServerMetadata(ctx, as) + if err != nil { + lastErr = err + continue + } + return asMeta, prm, nil + } + return nil, nil, fmt.Errorf("%s: %w", i18n.T("mcp.oauth.discovery_failed"), lastErr) +} + +// fetchAuthServerMetadata tries the RFC 8414 well-known endpoints (OAuth AS +// metadata first, then OpenID Connect discovery) for an issuer URL. Per RFC +// 8414 the well-known segment is inserted between the host and any path +// component; we try both the path-preserving and the simple-append forms to +// interoperate with the range of real servers. +func (d *oauthDiscoverer) fetchAuthServerMetadata(ctx context.Context, issuer string) (*authServerMetadata, error) { + issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + candidates := wellKnownCandidates(issuer) + var lastErr error + for _, u := range candidates { + meta, err := fetchJSON[authServerMetadata](ctx, d.hc, u) + if err != nil { + lastErr = err + continue + } + if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + lastErr = fmt.Errorf("%s", i18n.T("mcp.oauth.incomplete_metadata", u)) + continue + } + return meta, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("%s", i18n.T("mcp.oauth.no_metadata_endpoint")) + } + return nil, lastErr +} + +// wellKnownCandidates builds the ordered list of discovery URLs to try for an +// issuer. For an issuer with a path (e.g. https://host/tenant) RFC 8414 +// mandates host-insertion (https://host/.well-known/.../tenant); we also try +// the naive suffix form and OIDC discovery to be liberal in what we accept. +func wellKnownCandidates(issuer string) []string { + u, err := url.Parse(issuer) + if err != nil { + return []string{issuer + "/.well-known/oauth-authorization-server"} + } + path := strings.Trim(u.Path, "/") + origin := u.Scheme + "://" + u.Host + var out []string + if path == "" { + out = append(out, + origin+"/.well-known/oauth-authorization-server", + origin+"/.well-known/openid-configuration", + ) + } else { + out = append(out, + origin+"/.well-known/oauth-authorization-server/"+path, // RFC 8414 host-insertion + issuer+"/.well-known/oauth-authorization-server", // naive suffix + origin+"/.well-known/openid-configuration/"+path, + issuer+"/.well-known/openid-configuration", + ) + } + return out +} + +// registerClient performs RFC 7591 Dynamic Client Registration against the +// authorization server, returning the issued client_id. Used only when we have +// no client_id for the server yet. +func (d *oauthDiscoverer) registerClient(ctx context.Context, registrationEndpoint, redirectURI, scope string) (string, error) { + if strings.TrimSpace(registrationEndpoint) == "" { + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.no_registration_endpoint")) + } + reqBody := map[string]any{ + "client_name": mcpOAuthClientName, + "redirect_uris": []string{redirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", // public client (PKCE) + } + if scope != "" { + reqBody["scope"] = scope + } + body, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationEndpoint, strings.NewReader(string(body))) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := d.hc.Do(req) //#nosec G704 -- registration endpoint from server-advertised AS metadata + if err != nil { + return "", err + } + defer resp.Body.Close() + var reg struct { + ClientID string `json:"client_id"` + Error string `json:"error"` + ErrDesc string `json:"error_description"` + } + dec := json.NewDecoder(resp.Body) + _ = dec.Decode(®) + if resp.StatusCode < 200 || resp.StatusCode >= 300 || reg.ClientID == "" { + msg := reg.ErrDesc + if msg == "" { + msg = reg.Error + } + if msg == "" { + msg = resp.Status + } + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.registration_failed", msg)) + } + return reg.ClientID, nil +} + +// LoginServer runs the full interactive OAuth authorization for an MCP server +// and persists the resulting credential + metadata. serverURL is the canonical +// MCP endpoint (used as the RFC 8707 resource indicator). ch is the challenge +// parsed from a prior 401, if any (empty is fine — discovery falls back to the +// well-known path). +// +// This opens a browser and blocks until the user completes the flow or the +// timeout elapses, so it must only be called from an interactive surface +// (the /mcp login command or the @mcp-login tool). +func LoginServer(ctx context.Context, serverName, serverURL string, ch oauthChallenge, logger *zap.Logger) error { + disc := newOAuthDiscoverer(logger) + + // Reuse cached metadata when present so we skip discovery/registration on + // re-login; fall back to full discovery otherwise. + meta, _ := loadOAuthMeta(serverName) + + var asMeta *authServerMetadata + var resource string + if meta == nil || meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + am, prm, err := disc.discover(ctx, serverURL, ch) + if err != nil { + return err + } + asMeta = am + resource = firstNonEmpty(prm.Resource, serverURL) + } else { + asMeta = &authServerMetadata{ + Issuer: meta.Issuer, + AuthorizationEndpoint: meta.AuthorizationEndpoint, + TokenEndpoint: meta.TokenEndpoint, + RegistrationEndpoint: meta.RegistrationEndpoint, + } + resource = firstNonEmpty(meta.Resource, serverURL) + } + + scope := chooseScope(ch, asMeta) + + // Bind the loopback listener first so the redirect_uri is fixed before we + // (possibly) register a client against it. + listener, redirectURI, err := bindLoopback() + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("mcp.oauth.listener_failed"), err) + } + defer listener.Close() //nolint:errcheck // best-effort; server.Shutdown owns the listener + + // Ensure we have a client_id. Reuse the stored one only if it was + // registered against the same redirect_uri; otherwise register fresh. + clientID := "" + if meta != nil && meta.ClientID != "" && meta.RedirectURI == redirectURI { + clientID = meta.ClientID + } + if clientID == "" { + clientID, err = disc.registerClient(ctx, asMeta.RegistrationEndpoint, redirectURI, scope) + if err != nil { + return err + } + } + + code, verifier, err := runLoopbackAuth(ctx, listener, loopbackAuthParams{ + authorizationEndpoint: asMeta.AuthorizationEndpoint, + clientID: clientID, + redirectURI: redirectURI, + scope: scope, + resource: resource, + }, logger) + if err != nil { + return err + } + + tr, err := auth.ExchangeTokenForm(ctx, logger, asMeta.TokenEndpoint, auth.TokenExchangeRequest{ + GrantType: "authorization_code", + ClientID: clientID, + Code: code, + RedirectURI: redirectURI, + CodeVerifier: verifier, + Resource: resource, + }) + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("mcp.oauth.token_exchange_failed"), err) + } + if tr.AccessToken == "" { + return fmt.Errorf("%s", i18n.T("mcp.oauth.empty_access_token")) + } + + cred := &auth.AuthProfileCredential{ + CredType: auth.CredentialOAuth, + Provider: auth.ProviderMCP, + Access: tr.AccessToken, + Refresh: tr.RefreshToken, + Expires: auth.TokenExpiryMilli(tr.ExpiresIn), + ClientID: clientID, + } + if err := auth.UpsertProfile(mcpOAuthProfileID(serverName), cred, logger); err != nil { + return err + } + + newMeta := &oauthServerMeta{ + Server: serverName, + Resource: resource, + Issuer: asMeta.Issuer, + AuthorizationEndpoint: asMeta.AuthorizationEndpoint, + TokenEndpoint: asMeta.TokenEndpoint, + RegistrationEndpoint: asMeta.RegistrationEndpoint, + ClientID: clientID, + Scope: scope, + RedirectURI: redirectURI, + } + if err := saveOAuthMeta(newMeta); err != nil { + logger.Warn("MCP OAuth: failed to persist server metadata", zap.String("server", serverName), zap.Error(err)) + } + + logger.Info("MCP OAuth authorization complete", zap.String("server", serverName)) + return nil +} + +// mcpTokenProvider builds a refreshable TokenProvider for a server from its +// stored credential + metadata. Returns (nil, nil) when no credential exists +// yet, so callers can treat "not logged in" distinctly from a hard error. +func mcpTokenProvider(serverName, serverURL string, logger *zap.Logger) (auth.TokenProvider, error) { + meta, err := loadOAuthMeta(serverName) + if err != nil || meta == nil { + return nil, nil + } + cred := auth.GetProfile(mcpOAuthProfileID(serverName), logger) + if cred == nil || cred.GetAccessToken() == "" { + return nil, nil + } + + resource := firstNonEmpty(meta.Resource, serverURL) + refresh := newMCPRefreshFunc(meta.TokenEndpoint, meta.ClientID, resource, meta.Scope) + return auth.NewOAuthTokenProviderWithRefresh(cred, mcpOAuthProfileID(serverName), "auth-store", refresh, logger), nil +} + +// newMCPRefreshFunc returns a refresh callback bound to a server's discovered +// token endpoint + client_id. Mirrors auth.RefreshOAuth but for the +// runtime-discovered MCP case (which the provider-switch in RefreshOAuth +// cannot handle). +func newMCPRefreshFunc(tokenEndpoint, clientID, resource, scope string) func(ctx context.Context, cred *auth.AuthProfileCredential, logger *zap.Logger) (*auth.AuthProfileCredential, error) { + return func(ctx context.Context, cred *auth.AuthProfileCredential, logger *zap.Logger) (*auth.AuthProfileCredential, error) { + if cred == nil { + return nil, fmt.Errorf("%s", i18n.T("mcp.oauth.refresh_nil_cred")) + } + refresh := strings.TrimSpace(cred.Refresh) + if refresh == "" { + return nil, fmt.Errorf("%s", i18n.T("mcp.oauth.refresh_missing")) + } + tr, err := auth.ExchangeTokenForm(ctx, logger, tokenEndpoint, auth.TokenExchangeRequest{ + GrantType: "refresh_token", + ClientID: clientID, + RefreshToken: refresh, + Resource: resource, + Scope: scope, + }) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("mcp.oauth.refresh_failed"), err) + } + if tr.AccessToken != "" { + cred.Access = tr.AccessToken + } + if tr.RefreshToken != "" { + cred.Refresh = tr.RefreshToken + } + if tr.ExpiresIn > 0 { + cred.Expires = auth.TokenExpiryMilli(tr.ExpiresIn) + } + return cred, nil + } +} + +// LogoutServer deletes a server's stored OAuth credential and metadata. +func LogoutServer(serverName string, logger *zap.Logger) error { + _ = auth.DeleteProfile(mcpOAuthProfileID(serverName), logger) + if path, err := oauthMetaPath(serverName); err == nil { + _ = os.Remove(path) + } + return nil +} + +// --- helpers --- + +// originOf returns scheme://host for a URL. +func originOf(rawURL string) (string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.bad_server_url", rawURL)) + } + return u.Scheme + "://" + u.Host, nil +} + +// chooseScope selects the OAuth scope to request: the challenge's scope wins, +// then the AS-advertised scopes, else empty (server default). +func chooseScope(ch oauthChallenge, asMeta *authServerMetadata) string { + if strings.TrimSpace(ch.Scope) != "" { + return ch.Scope + } + if asMeta != nil && len(asMeta.ScopesSupported) > 0 { + return strings.Join(asMeta.ScopesSupported, " ") + } + return "" +} + +// fetchJSON GETs url and decodes the JSON body into T. Non-2xx is an error. +func fetchJSON[T any](ctx context.Context, hc *http.Client, url string) (*T, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := hc.Do(req) //#nosec G704 -- discovery URLs derived from configured server URL / server-advertised metadata + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s", i18n.T("mcp.oauth.http_get_failed", url, resp.StatusCode)) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + +// mcpOAuthLoopbackPort returns the preferred fixed loopback port (env override +// CHATCLI_MCP_OAUTH_PORT), or 0 for an ephemeral port. A fixed port keeps the +// redirect_uri — and therefore the registered client_id — stable across logins. +func mcpOAuthLoopbackPort() string { + if p := strings.TrimSpace(os.Getenv("CHATCLI_MCP_OAUTH_PORT")); p != "" { + return p + } + return "8765" +} + +// bindLoopback binds a localhost listener and returns it with its redirect URI. +// It tries the preferred fixed port first (stable client registration) and +// falls back to an ephemeral port if that port is busy. +func bindLoopback() (net.Listener, string, error) { + port := mcpOAuthLoopbackPort() + ln, err := net.Listen("tcp", "127.0.0.1:"+port) + if err != nil { + ln, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, "", err + } + } + actual := ln.Addr().(*net.TCPAddr).Port + return ln, fmt.Sprintf("http://127.0.0.1:%d/callback", actual), nil +} diff --git a/cli/mcp/oauth_loopback.go b/cli/mcp/oauth_loopback.go new file mode 100644 index 00000000..a11f87e0 --- /dev/null +++ b/cli/mcp/oauth_loopback.go @@ -0,0 +1,146 @@ +/* + * ChatCLI - MCP OAuth loopback authorization-code flow + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + * + * Runs the browser-based authorization-code + PKCE exchange over a localhost + * callback. Mirrors the hardened provider-login loopback (auth/login_flows.go) + * — CSRF state validation, security headers, bounded timeouts — but drives a + * runtime-discovered authorization endpoint and echoes the RFC 8707 resource + * indicator so the issued token is bound to the target MCP server. + */ +package mcp + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/diillson/chatcli/auth" + "github.com/diillson/chatcli/i18n" + "go.uber.org/zap" +) + +// loopbackAuthParams carries the per-login parameters for the authorization +// request. The listener is passed separately (already bound) so the caller can +// register a client against the exact redirect_uri before we authorize. +type loopbackAuthParams struct { + authorizationEndpoint string + clientID string + redirectURI string + scope string + resource string +} + +// runLoopbackAuth opens the browser at the authorization endpoint and waits for +// the callback, returning the authorization code and the PKCE verifier used. +// The listener is owned by the caller for binding, but this function serves and +// shuts it down. +func runLoopbackAuth(ctx context.Context, listener net.Listener, p loopbackAuthParams, logger *zap.Logger) (code string, verifier string, err error) { + pkce, err := auth.GeneratePKCE() + if err != nil { + return "", "", err + } + state, err := auth.GenerateState() + if err != nil { + return "", "", fmt.Errorf("%s: %w", i18n.T("mcp.oauth.state_failed"), err) + } + + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", p.clientID) + q.Set("redirect_uri", p.redirectURI) + q.Set("code_challenge", pkce.Challenge) + q.Set("code_challenge_method", "S256") + q.Set("state", state) + if p.scope != "" { + q.Set("scope", p.scope) + } + if p.resource != "" { + // RFC 8707 resource indicator — binds the token to this MCP server. + q.Set("resource", p.resource) + } + authURL := p.authorizationEndpoint + "?" + q.Encode() + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + srv := &http.Server{ + ReadHeaderTimeout: 30 * time.Second, + IdleTimeout: mcpOAuthCallbackTimeout, + } + srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'none'") + w.Header().Set("Cache-Control", "no-store") + + if r.URL.Path != "/callback" { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("state") != state { + writeCallbackHTML(w, http.StatusForbidden, i18n.T("mcp.oauth.callback_csrf_html")) + errCh <- fmt.Errorf("%s", i18n.T("mcp.oauth.callback_csrf")) + return + } + if oauthErr := r.URL.Query().Get("error"); oauthErr != "" { + desc := r.URL.Query().Get("error_description") + writeCallbackHTML(w, http.StatusBadRequest, i18n.T("mcp.oauth.callback_error_html")) + errCh <- fmt.Errorf("%s", i18n.T("mcp.oauth.callback_server_error", oauthErr, desc)) + return + } + c := r.URL.Query().Get("code") + if c == "" { + writeCallbackHTML(w, http.StatusBadRequest, i18n.T("mcp.oauth.callback_nocode_html")) + errCh <- fmt.Errorf("%s", i18n.T("mcp.oauth.callback_nocode")) + return + } + writeCallbackHTML(w, http.StatusOK, i18n.T("mcp.oauth.callback_success_html")) + codeCh <- c + }) + + go func() { + if serveErr := srv.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed { + errCh <- serveErr + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + fmt.Println(i18n.T("mcp.oauth.opening_browser")) + if openErr := auth.OpenInBrowser(authURL); openErr != nil { + fmt.Println(i18n.T("mcp.oauth.browser_failed")) + fmt.Println(authURL) + } else { + fmt.Println(i18n.T("mcp.oauth.browser_hint")) + fmt.Println(authURL) + } + fmt.Println(i18n.T("mcp.oauth.waiting")) + + timeoutTimer := time.NewTimer(mcpOAuthCallbackTimeout) + defer timeoutTimer.Stop() + + select { + case code = <-codeCh: + return code, pkce.Verifier, nil + case cbErr := <-errCh: + return "", "", fmt.Errorf("%s: %w", i18n.T("mcp.oauth.callback_failed"), cbErr) + case <-timeoutTimer.C: + return "", "", fmt.Errorf("%s", i18n.T("mcp.oauth.callback_timeout", mcpOAuthCallbackTimeout)) + case <-ctx.Done(): + return "", "", ctx.Err() + } +} + +func writeCallbackHTML(w http.ResponseWriter, status int, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = fmt.Fprint(w, body) +} diff --git a/cli/mcp/oauth_more_test.go b/cli/mcp/oauth_more_test.go new file mode 100644 index 00000000..d2632f4c --- /dev/null +++ b/cli/mcp/oauth_more_test.go @@ -0,0 +1,253 @@ +/* + * ChatCLI - MCP OAuth tests (helpers, providers, discovery edges) + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/diillson/chatcli/auth" + "go.uber.org/zap" +) + +func TestOAuthRequiredErrorAndIs(t *testing.T) { + oe := &OAuthRequiredError{Server: "aws", Endpoint: "https://x/mcp"} + if oe.Error() == "" { + t.Fatalf("Error() empty") + } + // errors.As through a wrap. + wrapped := fmt.Errorf("initialize: %w", oe) + got, ok := IsOAuthRequired(wrapped) + if !ok || got.Server != "aws" { + t.Fatalf("IsOAuthRequired failed to unwrap: %v", wrapped) + } + if _, ok := IsOAuthRequired(fmt.Errorf("plain")); ok { + t.Fatalf("plain error should not be OAuthRequired") + } +} + +func TestOAuthHelpers(t *testing.T) { + if mcpOAuthProfileID("aws") != "mcp:aws" { + t.Errorf("profile id wrong") + } + + if _, err := originOf("://bad"); err == nil { + t.Errorf("expected error for bad URL") + } + origin, err := originOf("https://host.example.com/mcp/path") + if err != nil || origin != "https://host.example.com" { + t.Errorf("origin = %q err=%v", origin, err) + } + + // chooseScope precedence: challenge > AS metadata > empty. + if s := chooseScope(oauthChallenge{Scope: "a b"}, &authServerMetadata{ScopesSupported: []string{"x"}}); s != "a b" { + t.Errorf("challenge scope should win, got %q", s) + } + if s := chooseScope(oauthChallenge{}, &authServerMetadata{ScopesSupported: []string{"x", "y"}}); s != "x y" { + t.Errorf("AS scopes should join, got %q", s) + } + if s := chooseScope(oauthChallenge{}, &authServerMetadata{}); s != "" { + t.Errorf("empty scope expected, got %q", s) + } +} + +func TestMCPOAuthLoopbackPort(t *testing.T) { + t.Setenv("CHATCLI_MCP_OAUTH_PORT", "") + if mcpOAuthLoopbackPort() != "8765" { + t.Errorf("default port wrong") + } + t.Setenv("CHATCLI_MCP_OAUTH_PORT", "9999") + if mcpOAuthLoopbackPort() != "9999" { + t.Errorf("env override not honored") + } +} + +func TestOAuthMetaPathSanitizes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + p, err := oauthMetaPath("weird/../name space") + if err != nil { + t.Fatalf("oauthMetaPath: %v", err) + } + // No path separators or spaces may survive into the filename, so the + // sanitized name cannot escape the mcp_oauth directory. + base := filepath.Base(p) + if strings.ContainsAny(base, "/ ") || strings.Contains(p, "name space") { + t.Errorf("unsanitized filename: base=%q full=%q", base, p) + } + if !strings.HasSuffix(base, ".json") { + t.Errorf("missing .json suffix: %q", base) + } +} + +func TestHasStoredAndTokenProviderAbsent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if hasStoredMCPOAuth("nope", zap.NewNop()) { + t.Errorf("expected no stored oauth for fresh HOME") + } + provider, err := mcpTokenProvider("nope", "https://x/mcp", zap.NewNop()) + if err != nil { + t.Fatalf("mcpTokenProvider err: %v", err) + } + if provider != nil { + t.Errorf("expected nil provider when no creds stored") + } + // LogoutServer is a no-op when nothing is stored. + if err := LogoutServer("nope", zap.NewNop()); err != nil { + t.Errorf("LogoutServer should be a no-op, got %v", err) + } + // loadOAuthMeta returns an error for a missing file. + if _, err := loadOAuthMeta("nope"); err == nil { + t.Errorf("expected error loading missing meta") + } +} + +// TestStoredOAuthProvider covers the success branches: with a persisted +// credential + metadata, hasStoredMCPOAuth reports true and mcpTokenProvider +// returns a live provider. +func TestStoredOAuthProvider(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CHATCLI_AUTH_DIR", t.TempDir()) + + if err := saveOAuthMeta(&oauthServerMeta{ + Server: "aws", + Resource: "https://x/mcp", + Issuer: "https://as", + AuthorizationEndpoint: "https://as/authorize", + TokenEndpoint: "https://as/token", + ClientID: "c1", + RedirectURI: "http://127.0.0.1:8765/callback", + }); err != nil { + t.Fatalf("saveOAuthMeta: %v", err) + } + cred := &auth.AuthProfileCredential{ + CredType: auth.CredentialOAuth, + Provider: auth.ProviderMCP, + Access: "at", + Refresh: "rt", + Expires: auth.TokenExpiryMilli(3600), + ClientID: "c1", + } + if err := auth.UpsertProfile(mcpOAuthProfileID("aws"), cred, zap.NewNop()); err != nil { + t.Fatalf("UpsertProfile: %v", err) + } + + if !hasStoredMCPOAuth("aws", zap.NewNop()) { + t.Fatalf("expected hasStoredMCPOAuth true") + } + provider, err := mcpTokenProvider("aws", "https://x/mcp", zap.NewNop()) + if err != nil { + t.Fatalf("mcpTokenProvider: %v", err) + } + if provider == nil { + t.Fatalf("expected non-nil provider") + } + defer provider.Close() + tok, err := provider.Token(context.Background()) + if err != nil || tok != "at" { + t.Fatalf("Token = %q err=%v", tok, err) + } + + // LogoutServer removes both the credential and the metadata file. + if err := LogoutServer("aws", zap.NewNop()); err != nil { + t.Fatalf("LogoutServer: %v", err) + } + if hasStoredMCPOAuth("aws", zap.NewNop()) { + t.Fatalf("expected credential gone after logout") + } +} + +func TestBindLoopback(t *testing.T) { + // Use an ephemeral port to avoid clashing with a real 8765 on the runner. + t.Setenv("CHATCLI_MCP_OAUTH_PORT", "0") + ln, redirect, err := bindLoopback() + if err != nil { + t.Fatalf("bindLoopback: %v", err) + } + defer ln.Close() + if redirect == "" || redirect[:17] != "http://127.0.0.1:" { + t.Errorf("unexpected redirect URI: %q", redirect) + } +} + +func TestWriteCallbackHTML(t *testing.T) { + rec := httptest.NewRecorder() + writeCallbackHTML(rec, http.StatusOK, "ok") + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("content-type = %q", ct) + } + if rec.Body.String() != "ok" { + t.Fatalf("body = %q", rec.Body.String()) + } +} + +func TestRegisterClientErrors(t *testing.T) { + disc := newOAuthDiscoverer(zap.NewNop()) + // No registration endpoint. + if _, err := disc.registerClient(context.Background(), "", "http://127.0.0.1:0/callback", ""); err == nil { + t.Errorf("expected error for empty registration endpoint") + } + // Non-2xx response. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_client_metadata"}) + })) + defer srv.Close() + if _, err := disc.registerClient(context.Background(), srv.URL, "http://127.0.0.1:0/callback", ""); err == nil { + t.Errorf("expected error for non-2xx registration") + } +} + +func TestFetchAuthServerMetadataIncomplete(t *testing.T) { + // Serves metadata missing the token endpoint → must be rejected. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(authServerMetadata{AuthorizationEndpoint: "https://x/authorize"}) + })) + defer srv.Close() + disc := newOAuthDiscoverer(zap.NewNop()) + if _, err := disc.fetchAuthServerMetadata(context.Background(), srv.URL); err == nil { + t.Errorf("expected error for incomplete AS metadata") + } +} + +// TestDiscoverFallbackToOriginIssuer exercises the path where the server does +// not publish RFC 9728 protected-resource metadata and we treat the server +// origin as the issuer. +func TestDiscoverFallbackToOriginIssuer(t *testing.T) { + mux := http.NewServeMux() + var base string + // No /.well-known/oauth-protected-resource handler → 404. + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(authServerMetadata{ + Issuer: base, + AuthorizationEndpoint: base + "/authorize", + TokenEndpoint: base + "/token", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + base = srv.URL + + disc := newOAuthDiscoverer(zap.NewNop()) + asMeta, prm, err := disc.discover(context.Background(), base+"/mcp", oauthChallenge{}) + if err != nil { + t.Fatalf("discover fallback: %v", err) + } + if asMeta.TokenEndpoint != base+"/token" { + t.Errorf("token endpoint = %q", asMeta.TokenEndpoint) + } + if prm.Resource != base { + t.Errorf("resource fallback = %q, want origin %q", prm.Resource, base) + } +} diff --git a/cli/mcp/oauth_test.go b/cli/mcp/oauth_test.go new file mode 100644 index 00000000..f9405acd --- /dev/null +++ b/cli/mcp/oauth_test.go @@ -0,0 +1,201 @@ +/* + * ChatCLI - MCP OAuth tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/diillson/chatcli/auth" + "go.uber.org/zap" +) + +func TestParseWWWAuthenticate(t *testing.T) { + cases := []struct { + name string + header string + present bool + resource string + scope string + }{ + {"empty", "", false, "", ""}, + {"non-bearer", `Basic realm="x"`, false, "", ""}, + { + "full", + `Bearer realm="mcp", resource_metadata="https://as.example.com/.well-known/oauth-protected-resource", scope="a b"`, + true, + "https://as.example.com/.well-known/oauth-protected-resource", + "a b", + }, + {"bare bearer", "Bearer", true, "", ""}, + { + "quoted comma in scope", + `Bearer scope="read, write", resource_metadata="https://x/y"`, + true, + "https://x/y", + "read, write", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ch := parseWWWAuthenticate(c.header) + if ch.Present != c.present { + t.Fatalf("Present = %v, want %v", ch.Present, c.present) + } + if ch.ResourceMetadata != c.resource { + t.Errorf("ResourceMetadata = %q, want %q", ch.ResourceMetadata, c.resource) + } + if ch.Scope != c.scope { + t.Errorf("Scope = %q, want %q", ch.Scope, c.scope) + } + }) + } +} + +func TestWellKnownCandidates(t *testing.T) { + // Issuer without a path → oauth-as + oidc on the origin. + got := wellKnownCandidates("https://as.example.com") + if len(got) != 2 || got[0] != "https://as.example.com/.well-known/oauth-authorization-server" { + t.Fatalf("no-path candidates wrong: %v", got) + } + // Issuer with a path → RFC 8414 host-insertion must come first. + got = wellKnownCandidates("https://as.example.com/tenant1") + if got[0] != "https://as.example.com/.well-known/oauth-authorization-server/tenant1" { + t.Fatalf("host-insertion candidate missing/first wrong: %v", got) + } +} + +func TestOAuthMetaRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + meta := &oauthServerMeta{ + Server: "aws", + Issuer: "https://as.example.com", + AuthorizationEndpoint: "https://as.example.com/authorize", + TokenEndpoint: "https://as.example.com/token", + ClientID: "client-123", + RedirectURI: "http://127.0.0.1:8765/callback", + } + if err := saveOAuthMeta(meta); err != nil { + t.Fatalf("saveOAuthMeta: %v", err) + } + got, err := loadOAuthMeta("aws") + if err != nil { + t.Fatalf("loadOAuthMeta: %v", err) + } + if got.ClientID != "client-123" || got.TokenEndpoint != meta.TokenEndpoint { + t.Fatalf("round-trip mismatch: %+v", got) + } +} + +// TestDiscoverResolvesAuthServer exercises the RFC 9728 → RFC 8414 chain end to +// end against an in-process server that plays both the protected resource and +// its authorization server. +func TestDiscoverResolvesAuthServer(t *testing.T) { + mux := http.NewServeMux() + var base string + mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(protectedResourceMetadata{ + Resource: base + "/mcp", + AuthorizationServers: []string{base}, + ScopesSupported: []string{"mcp"}, + }) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(authServerMetadata{ + Issuer: base, + AuthorizationEndpoint: base + "/authorize", + TokenEndpoint: base + "/token", + RegistrationEndpoint: base + "/register", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + base = srv.URL + + disc := newOAuthDiscoverer(zap.NewNop()) + asMeta, prm, err := disc.discover(context.Background(), base+"/mcp", oauthChallenge{}) + if err != nil { + t.Fatalf("discover: %v", err) + } + if asMeta.TokenEndpoint != base+"/token" || asMeta.AuthorizationEndpoint != base+"/authorize" { + t.Fatalf("unexpected AS metadata: %+v", asMeta) + } + if prm.Resource != base+"/mcp" { + t.Errorf("resource = %q, want %q", prm.Resource, base+"/mcp") + } +} + +func TestRegisterClient(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if body["token_endpoint_auth_method"] != "none" { + t.Errorf("expected public client, got %v", body["token_endpoint_auth_method"]) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"client_id": "dyn-client-9"}) + })) + defer srv.Close() + + disc := newOAuthDiscoverer(zap.NewNop()) + id, err := disc.registerClient(context.Background(), srv.URL, "http://127.0.0.1:8765/callback", "mcp") + if err != nil { + t.Fatalf("registerClient: %v", err) + } + if id != "dyn-client-9" { + t.Fatalf("client_id = %q, want dyn-client-9", id) + } +} + +// TestMCPRefreshFunc verifies the refresh closure posts a refresh_token grant +// with the resource indicator and updates the credential in place. +func TestMCPRefreshFunc(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // MCP token exchange is form-encoded (RFC 6749 §4.1.3). + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("content-type = %q, want form-urlencoded", ct) + } + _ = r.ParseForm() + if r.PostForm.Get("grant_type") != "refresh_token" { + t.Errorf("grant_type = %v, want refresh_token", r.PostForm.Get("grant_type")) + } + if r.PostForm.Get("resource") != "https://srv/mcp" { + t.Errorf("resource = %v, want https://srv/mcp", r.PostForm.Get("resource")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 3600, + "token_type": "Bearer", + }) + })) + defer srv.Close() + + fn := newMCPRefreshFunc(srv.URL, "client-1", "https://srv/mcp", "mcp") + cred := &auth.AuthProfileCredential{ + CredType: auth.CredentialOAuth, + Provider: auth.ProviderMCP, + Access: "old-access", + Refresh: "old-refresh", + } + out, err := fn(context.Background(), cred, zap.NewNop()) + if err != nil { + t.Fatalf("refresh: %v", err) + } + if out.Access != "new-access" || out.Refresh != "new-refresh" { + t.Fatalf("credential not updated: %+v", out) + } + if out.Expires <= 0 { + t.Errorf("expiry not set") + } +} diff --git a/cli/mcp/transport_http.go b/cli/mcp/transport_http.go index 6e628e5f..f975cb9b 100644 --- a/cli/mcp/transport_http.go +++ b/cli/mcp/transport_http.go @@ -50,6 +50,7 @@ import ( "sync/atomic" "time" + "github.com/diillson/chatcli/auth" "github.com/diillson/chatcli/i18n" "go.uber.org/zap" ) @@ -96,6 +97,13 @@ type httpTransport struct { headers map[string]string auth *AuthConfig + // tokenProvider, when set, supplies a refreshable OAuth bearer token and + // takes precedence over the static auth config. It is assigned by the + // manager after construction when the server has stored MCP OAuth + // credentials. A 401 that survives a provider refresh is surfaced as an + // *OAuthRequiredError so the agent can trigger re-authorization. + tokenProvider auth.TokenProvider + closed atomic.Bool // pushOnce and pushDone coordinate the optional push listener. @@ -191,25 +199,37 @@ func (t *httpTransport) Call(method string, params interface{}) (json.RawMessage reqCtx, cancel := context.WithTimeout(t.ctx, deadline) defer cancel() - httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, t.endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err + // buildAndSend constructs a fresh request for each attempt (the body is + // re-read from the same byte slice) and applies auth. It is invoked once + // directly, or via auth.DoWithRefresh which retries it once after a + // transparent token refresh when a 401/403 comes back. + buildAndSend := func(token string) (*http.Response, error) { + httpReq, berr := http.NewRequestWithContext(reqCtx, http.MethodPost, t.endpoint, bytes.NewReader(body)) + if berr != nil { + return nil, berr + } + httpReq.Header.Set("Content-Type", "application/json") + // Order matters in the wild. The 2025-03-26 spec just requires + // both media types to be listed and a substring/`includes` check + // on the server is enough — but several real implementations + // (FastMCP, some Cloudflare Workers MCP gateways) interpret the + // FIRST listed type as the client's preference and return HTTP + // 406 / JSON-RPC -32600 ("Not Acceptable: Client must Accept + // text/event-stream") when SSE is not first. Putting SSE first + // is compatible with both naive first-match and spec-correct + // includes-match servers. + httpReq.Header.Set("Accept", "text/event-stream, application/json") + t.applyAuth(httpReq, token) + t.attachSession(httpReq) + return t.httpClient.Do(httpReq) + } + + var resp *http.Response + if t.tokenProvider != nil { + resp, err = auth.DoWithRefresh(reqCtx, t.tokenProvider, buildAndSend) + } else { + resp, err = buildAndSend("") } - httpReq.Header.Set("Content-Type", "application/json") - // Order matters in the wild. The 2025-03-26 spec just requires - // both media types to be listed and a substring/`includes` check - // on the server is enough — but several real implementations - // (FastMCP, some Cloudflare Workers MCP gateways) interpret the - // FIRST listed type as the client's preference and return HTTP - // 406 / JSON-RPC -32600 ("Not Acceptable: Client must Accept - // text/event-stream") when SSE is not first. Putting SSE first - // is compatible with both naive first-match and spec-correct - // includes-match servers. - httpReq.Header.Set("Accept", "text/event-stream, application/json") - t.applyHeaders(httpReq) - t.attachSession(httpReq) - - resp, err := t.httpClient.Do(httpReq) if err != nil { // Distinguish context-driven cancellation from a real // network error so the caller sees a localized timeout @@ -235,6 +255,17 @@ func (t *httpTransport) Call(method string, params interface{}) (json.RawMessage return nil, nil } + if resp.StatusCode == http.StatusUnauthorized { + // The server demands OAuth authorization we don't have (or a token + // that even a refresh couldn't rescue — DoWithRefresh already retried + // once above when a provider was present). Surface an actionable + // typed error so the agent can trigger @mcp-login / the user can run + // /mcp login. Drain the body first so the connection can be reused. + ch := parseWWWAuthenticate(resp.Header.Get("WWW-Authenticate")) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) + return nil, &OAuthRequiredError{Server: t.serverName, Endpoint: t.endpoint, Challenge: ch} + } + if resp.StatusCode >= 400 { preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("%s", i18n.T("mcp.transport.http_status_error", resp.StatusCode, strings.TrimSpace(string(preview)))) @@ -368,13 +399,31 @@ func (t *httpTransport) dispatchSSEEvent(eventType, data string, expectedID int6 return sseResult{}, false } -// applyHeaders mirrors the SSE transport: custom headers first, then -// auth. The order matches so a user who sets Authorization in -// Headers (legacy) is still overridden by Auth (typed). +// applyHeaders applies the custom headers plus static auth. Used by the push +// GET and session DELETE paths, which fetch the OAuth token best-effort when a +// provider is configured (they are not on the DoWithRefresh retry path). func (t *httpTransport) applyHeaders(req *http.Request) { + token := "" + if t.tokenProvider != nil { + if tok, err := t.tokenProvider.Token(req.Context()); err == nil { + token = tok + } + } + t.applyAuth(req, token) +} + +// applyAuth sets custom headers first, then authorization. When token is +// non-empty (OAuth provider path) it wins; otherwise the static AuthConfig is +// applied. The order mirrors the SSE transport so a user who sets Authorization +// in Headers (legacy) is still overridden by the typed credential. +func (t *httpTransport) applyAuth(req *http.Request, token string) { for k, v := range t.headers { req.Header.Set(k, v) } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + return + } t.auth.ApplyAuth(req) } @@ -418,6 +467,9 @@ func (t *httpTransport) Close(ctx context.Context) error { return nil } t.cancel() + if t.tokenProvider != nil { + t.tokenProvider.Close() + } // Wait for the push listener to drain (if it was ever started). // pushDone is nil when StartPushListener was never called — diff --git a/cli/mcp/transport_http_test.go b/cli/mcp/transport_http_test.go index 630e1ab1..fb8823e1 100644 --- a/cli/mcp/transport_http_test.go +++ b/cli/mcp/transport_http_test.go @@ -25,6 +25,7 @@ import ( "testing" "time" + "github.com/diillson/chatcli/auth" "go.uber.org/zap" ) @@ -199,18 +200,101 @@ func TestHTTPTransport_202NotificationReturnsNilResult(t *testing.T) { func TestHTTPTransport_HTTPErrorStatusIsLocalized(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = io.WriteString(w, `forbidden`) + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `boom`) })) defer srv.Close() tr := newTestTransport(t, srv, nil) _, err := tr.Call("tools/list", nil) if err == nil { - t.Fatalf("expected error on 401") + t.Fatalf("expected error on 500") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error %q should mention status 500", err.Error()) + } +} + +// A 401 is not a generic error: it must surface as an *OAuthRequiredError so +// the agent can trigger authorization, carrying the parsed WWW-Authenticate +// challenge for discovery. +func TestHTTPTransport_UnauthorizedReturnsOAuthRequired(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="https://as.example.com/.well-known/oauth-protected-resource", scope="mcp"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + tr := newTestTransport(t, srv, nil) + _, err := tr.Call("tools/list", nil) + oe, ok := IsOAuthRequired(err) + if !ok { + t.Fatalf("expected *OAuthRequiredError on 401, got %T: %v", err, err) + } + if oe.Challenge.ResourceMetadata != "https://as.example.com/.well-known/oauth-protected-resource" { + t.Errorf("resource_metadata = %q, want the advertised URL", oe.Challenge.ResourceMetadata) + } + if oe.Challenge.Scope != "mcp" { + t.Errorf("scope = %q, want mcp", oe.Challenge.Scope) + } +} + +// TestHTTPTransport_RefreshesOn401AndRetries proves the full mid-session +// recovery path: a stale token gets a 401, DoWithRefresh transparently +// refreshes it against the discovered token endpoint, and the retried call +// succeeds — all through the real transport with a real OAuth TokenProvider. +func TestHTTPTransport_RefreshesOn401AndRetries(t *testing.T) { + // Token endpoint: hands out a fresh access token on refresh_token grant. + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.PostForm.Get("grant_type") != "refresh_token" { + t.Errorf("grant_type = %v", r.PostForm.Get("grant_type")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-token", "refresh_token": "r2", "expires_in": 3600, "token_type": "Bearer", + }) + })) + defer tokenSrv.Close() + + // MCP server: 401 for the stale token, valid JSON-RPC for the fresh one. + mcpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer new-token" { + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="https://as/x"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + var req jsonRPCRequest + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`{"ok":true}`)}) + })) + defer mcpSrv.Close() + + cred := &auth.AuthProfileCredential{ + CredType: auth.CredentialOAuth, + Provider: auth.ProviderMCP, + Access: "stale-token", + Refresh: "r1", + // Future expiry so Token() serves the stale token first; the 401 then + // drives the invalidate+refresh cycle. + Expires: auth.TokenExpiryMilli(3600), + } + provider := auth.NewOAuthTokenProviderWithRefresh( + cred, "", "test", + newMCPRefreshFunc(tokenSrv.URL, "client-1", "https://srv/mcp", "mcp"), + zap.NewNop(), + ) + defer provider.Close() + + tr := newTestTransport(t, mcpSrv, nil) + tr.tokenProvider = provider + + res, err := tr.Call("tools/list", nil) + if err != nil { + t.Fatalf("Call after refresh should succeed, got: %v", err) } - if !strings.Contains(err.Error(), "401") { - t.Errorf("error %q should mention status 401", err.Error()) + if !strings.Contains(string(res), `"ok":true`) { + t.Fatalf("unexpected result: %s", string(res)) } } diff --git a/cli/mcp/transport_sse.go b/cli/mcp/transport_sse.go index d3cf995e..620a928e 100644 --- a/cli/mcp/transport_sse.go +++ b/cli/mcp/transport_sse.go @@ -39,6 +39,7 @@ import ( "sync/atomic" "time" + "github.com/diillson/chatcli/auth" "github.com/diillson/chatcli/i18n" "go.uber.org/zap" ) @@ -76,6 +77,11 @@ type sseTransport struct { headers map[string]string auth *AuthConfig + // tokenProvider, when set, supplies a refreshable OAuth bearer token that + // takes precedence over the static auth config. Assigned by the manager + // after construction when the server has stored MCP OAuth credentials. + tokenProvider auth.TokenProvider + closed atomic.Bool } @@ -108,6 +114,14 @@ func newSSETransport(ctx context.Context, cfg ServerConfig, logger *zap.Logger, auth: cfg.Auth, } + // Wire a refreshable OAuth token provider when the server has stored MCP + // OAuth credentials, BEFORE the supervisor's first connect so the initial + // SSE GET already carries the bearer token. No stored credential leaves + // the provider nil and the static AuthConfig path is used unchanged. + if provider, perr := mcpTokenProvider(cfg.Name, cfg.URL, logger); perr == nil && provider != nil { + t.tokenProvider = provider + } + go t.supervisor() // Wait for the server to send the messages endpoint URL on the @@ -134,6 +148,14 @@ func (t *sseTransport) applyHeaders(req *http.Request) { for k, v := range t.headers { req.Header.Set(k, v) } + // Prefer the refreshable OAuth token when a provider is wired; otherwise + // fall back to the static AuthConfig (bearer/basic/header). + if t.tokenProvider != nil { + if tok, err := t.tokenProvider.Token(req.Context()); err == nil && tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + return + } + } t.auth.ApplyAuth(req) } @@ -412,6 +434,9 @@ func (t *sseTransport) Call(method string, params interface{}) (json.RawMessage, func (t *sseTransport) Close(ctx context.Context) error { t.closed.Store(true) t.cancel() + if t.tokenProvider != nil { + t.tokenProvider.Close() + } select { case <-t.done: return nil diff --git a/cli/mcp/types.go b/cli/mcp/types.go index 2c24c598..17523761 100644 --- a/cli/mcp/types.go +++ b/cli/mcp/types.go @@ -181,4 +181,10 @@ type ServerStatus struct { LastPing time.Time LastError error StartedAt time.Time + + // AuthRequired is set when the server rejected the connection with an + // OAuth challenge (HTTP 401) and no usable credential is available. It + // tells /mcp status and the agent prompt to surface a "run /mcp login" / + // "@mcp-login" hint instead of a generic connection error. + AuthRequired bool } diff --git a/cli/mcp_command.go b/cli/mcp_command.go index 99e90c51..75de546a 100644 --- a/cli/mcp_command.go +++ b/cli/mcp_command.go @@ -82,11 +82,50 @@ func (cli *ChatCLI) handleMCPCommand(ctx context.Context, userInput string) { cli.mcpReload() case "logs": cli.mcpLogs(name) + case "login", "auth": + cli.mcpLogin(ctx, name) + case "logout": + cli.mcpLogout(ctx, name) default: fmt.Println(colorize(" "+i18n.T("mcp.cmd.usage"), ColorYellow)) } } +// mcpLogin runs the interactive OAuth authorization flow for a remote MCP +// server and reconnects it. Mirrors the @mcp-login agent tool for humans. +func (cli *ChatCLI) mcpLogin(ctx context.Context, name string) { + if strings.TrimSpace(name) == "" { + fmt.Println(colorize(" "+i18n.T("mcp.cmd.login_usage"), ColorYellow)) + return + } + fmt.Println(colorize(" "+i18n.T("mcp.cmd.login_starting", name), ColorCyan)) + if err := cli.mcpManager.LoginOAuth(ctx, name); err != nil { + fmt.Println(colorize(" "+i18n.T("mcp.cmd.login_failed", err), ColorRed)) + return + } + tools := 0 + for _, s := range cli.mcpManager.GetServerStatus() { + if s.Name == name { + tools = s.ToolCount + break + } + } + fmt.Println(colorize(" "+i18n.T("mcp.cmd.login_ok", name, tools), ColorGreen)) +} + +// mcpLogout forgets a server's stored OAuth credential and stops the server. +func (cli *ChatCLI) mcpLogout(ctx context.Context, name string) { + if strings.TrimSpace(name) == "" { + fmt.Println(colorize(" "+i18n.T("mcp.cmd.logout_usage"), ColorYellow)) + return + } + if err := cli.mcpManager.LogoutOAuth(ctx, name); err != nil { + fmt.Println(colorize(" "+i18n.T("mcp.cmd.logout_failed", err), ColorRed)) + return + } + fmt.Println(colorize(" "+i18n.T("mcp.cmd.logout_ok", name), ColorGreen)) +} + func (cli *ChatCLI) mcpShowStatus(filter string) { statuses := cli.mcpManager.GetServerStatus() tools := cli.mcpManager.GetTools() @@ -117,6 +156,10 @@ func (cli *ChatCLI) mcpShowStatus(filter string) { icon = "◌" statusColor = ColorYellow statusText = i18n.T("mcp.cmd.status_starting") + case s.AuthRequired: + icon = "🔒" + statusColor = ColorYellow + statusText = i18n.T("mcp.cmd.status_auth_required") default: icon = "○" statusColor = ColorRed @@ -138,6 +181,9 @@ func (cli *ChatCLI) mcpShowStatus(filter string) { if s.LastError != nil { fmt.Println(p + colorize(fmt.Sprintf(" ↳ %v", s.LastError), ColorRed)) } + if s.AuthRequired { + fmt.Println(p + colorize(" ↳ "+i18n.T("mcp.cmd.auth_required_hint", s.Name), ColorYellow)) + } // Metadata block: description, category+tags, trust flag. // Every line is best-effort — we only emit it when the @@ -382,6 +428,8 @@ func (cli *ChatCLI) getMCPSuggestions(d prompt.Document) []prompt.Suggest { {Text: "stop", Description: i18n.T("mcp.cmd.sug_stop")}, {Text: "reload", Description: i18n.T("mcp.cmd.sug_reload")}, {Text: "logs", Description: i18n.T("mcp.cmd.sug_logs")}, + {Text: "login", Description: i18n.T("mcp.cmd.sug_login")}, + {Text: "logout", Description: i18n.T("mcp.cmd.sug_logout")}, } // Tokenize what the user has typed so far (excluding the leading @@ -419,7 +467,7 @@ func (cli *ChatCLI) getMCPSuggestions(d prompt.Document) []prompt.Suggest { func mcpSubcommandTakesServerName(sub string) bool { switch sub { - case "start", "stop", "restart", "logs", "status", "tools": + case "start", "stop", "restart", "logs", "status", "tools", "login", "logout", "auth": return true } return false diff --git a/cli/mcp_command_test.go b/cli/mcp_command_test.go index 11bb4734..3c6b06c3 100644 --- a/cli/mcp_command_test.go +++ b/cli/mcp_command_test.go @@ -55,7 +55,7 @@ func withConfiguredServers(t *testing.T, names ...string) *ChatCLI { func TestMCPCompleterSubcommandSuggestionsAtRoot(t *testing.T) { cli := withConfiguredServers(t) got := suggestTexts(cli.getMCPSuggestions(docFor("/mcp "))) - want := []string{"status", "tools", "restart", "start", "stop", "reload", "logs"} + want := []string{"status", "tools", "restart", "start", "stop", "reload", "logs", "login", "logout"} if len(got) != len(want) { t.Fatalf("got %d suggestions, want %d (%v)", len(got), len(want), got) } diff --git a/cli/mcplogin_adapter.go b/cli/mcplogin_adapter.go new file mode 100644 index 00000000..946462b9 --- /dev/null +++ b/cli/mcplogin_adapter.go @@ -0,0 +1,102 @@ +/* + * ChatCLI - Adapter binding the @mcp-login tool to the live MCP manager. + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + * + * Implements plugins.MCPAuthAdapter over cli.mcpManager: login runs the OAuth + * authorization-code flow and reconnects the server; status and logout report + * and forget per-server credentials. Wired via plugins.SetMCPAuthAdapter at + * startup. + */ +package cli + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/diillson/chatcli/cli/plugins" + "github.com/diillson/chatcli/i18n" +) + +// mcpLoginAdapter is the concrete plugins.MCPAuthAdapter. +type mcpLoginAdapter struct { + cli *ChatCLI +} + +// mcpLoginTimeout bounds the whole interactive login (discovery + registration +// + browser approval + token exchange + reconnect). It is a bit above the +// internal 5-minute callback wait so the browser step is the limiting factor. +const mcpLoginTimeout = 6 * time.Minute + +// Login implements plugins.MCPAuthAdapter. +func (a *mcpLoginAdapter) Login(ctx context.Context, server string) (string, error) { + if a.cli.mcpManager == nil { + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.not_enabled")) + } + server = strings.TrimSpace(server) + + // Give the interactive browser step enough headroom while still aborting + // if the caller's context (the session) is cancelled. + loginCtx, cancel := context.WithTimeout(ctx, mcpLoginTimeout) + defer cancel() + + if err := a.cli.mcpManager.LoginOAuth(loginCtx, server); err != nil { + return "", err + } + + // Report the post-login tool count so the agent knows the server is ready. + tools := 0 + for _, s := range a.cli.mcpManager.GetServerStatus() { + if s.Name == server { + tools = s.ToolCount + break + } + } + return i18n.T("mcp.oauth.login_success", server, tools), nil +} + +// Logout implements plugins.MCPAuthAdapter. +func (a *mcpLoginAdapter) Logout(server string) (string, error) { + if a.cli.mcpManager == nil { + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.not_enabled")) + } + server = strings.TrimSpace(server) + if err := a.cli.mcpManager.LogoutOAuth(context.Background(), server); err != nil { + return "", err + } + return i18n.T("mcp.oauth.logout_success", server), nil +} + +// Status implements plugins.MCPAuthAdapter — a compact per-server summary. +func (a *mcpLoginAdapter) Status() (string, error) { + if a.cli.mcpManager == nil { + return "", fmt.Errorf("%s", i18n.T("mcp.oauth.not_enabled")) + } + statuses := a.cli.mcpManager.GetServerStatus() + if len(statuses) == 0 { + return i18n.T("mcp.oauth.status_no_servers"), nil + } + var b strings.Builder + b.WriteString(i18n.T("mcp.oauth.status_header")) + b.WriteString("\n") + for _, s := range statuses { + state := i18n.T("mcp.oauth.status_connected") + switch { + case s.AuthRequired: + state = i18n.T("mcp.oauth.status_auth_required") + case s.Connected: + // keep connected + case a.cli.mcpManager.HasOAuthCredential(s.Name): + state = i18n.T("mcp.oauth.status_logged_in_disconnected") + default: + state = i18n.T("mcp.oauth.status_disconnected") + } + fmt.Fprintf(&b, "- %s: %s\n", s.Name, state) + } + return strings.TrimRight(b.String(), "\n"), nil +} + +// compile-time assertion that the adapter satisfies the plugin interface. +var _ plugins.MCPAuthAdapter = (*mcpLoginAdapter)(nil) diff --git a/cli/mcplogin_adapter_test.go b/cli/mcplogin_adapter_test.go new file mode 100644 index 00000000..e6b762e9 --- /dev/null +++ b/cli/mcplogin_adapter_test.go @@ -0,0 +1,73 @@ +/* + * ChatCLI - @mcp-login adapter + command tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package cli + +import ( + "context" + "strings" + "testing" + + "github.com/diillson/chatcli/cli/mcp" +) + +func TestBuildMCPAuthNote(t *testing.T) { + // No auth-required servers → empty note. + if note := buildMCPAuthNote([]mcp.ServerStatus{{Name: "a", Connected: true}}); note != "" { + t.Fatalf("expected empty note, got %q", note) + } + // One auth-required server → note names it and mentions the tool. + note := buildMCPAuthNote([]mcp.ServerStatus{ + {Name: "aws", AuthRequired: true}, + {Name: "gh", Connected: true}, + }) + if !strings.Contains(note, "aws") { + t.Fatalf("note should name the server: %q", note) + } +} + +func TestMCPLoginAdapterStatusAndLogout(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cli := withConfiguredServers(t, "srv1", "srv2") + a := &mcpLoginAdapter{cli: cli} + + out, err := a.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if !strings.Contains(out, "srv1") || !strings.Contains(out, "srv2") { + t.Fatalf("status missing servers: %q", out) + } + + // Logout of a configured (not connected) server deletes nothing but + // succeeds and returns a message. + msg, err := a.Logout("srv1") + if err != nil { + t.Fatalf("Logout: %v", err) + } + if strings.TrimSpace(msg) == "" { + t.Fatalf("empty logout message") + } + + // Login on a stdio server is rejected as non-remote (no browser opened). + if _, err := a.Login(context.Background(), "srv1"); err == nil { + t.Fatalf("expected non-remote login error for stdio server") + } +} + +func TestMCPLoginCommandUsagePaths(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cli := withConfiguredServers(t, "srv1") + ctx := context.Background() + + // Empty name → usage branches (must not panic). + cli.mcpLogin(ctx, "") + cli.mcpLogout(ctx, "") + + // Real names: login errors (stdio non-remote), logout succeeds. Both just + // print — we assert they run without panicking and touch the code paths. + cli.mcpLogin(ctx, "srv1") + cli.mcpLogout(ctx, "srv1") +} diff --git a/cli/plugins/builtin_mcplogin.go b/cli/plugins/builtin_mcplogin.go new file mode 100644 index 00000000..508a1d01 --- /dev/null +++ b/cli/plugins/builtin_mcplogin.go @@ -0,0 +1,273 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +/* + * @mcp-login — authorize a remote (OAuth-protected) MCP server so its tools + * become usable. When an mcp_* tool call fails because the server demands + * authorization, the agent is told to invoke this tool with the server name; + * it runs the OAuth authorization-code flow (opening a browser), stores the + * token, and reconnects the server so its tools appear. + * + * Same architecture as @lsp/@knowledge: a thin schema + dispatch layer over a + * package-level adapter supplied via SetMCPAuthAdapter; the cli package wires + * the adapter over the live MCP manager. + */ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync/atomic" +) + +// MCPAuthAdapter is the surface the @mcp-login tool needs from the live +// session. All results are pre-rendered, model-facing text. +type MCPAuthAdapter interface { + // Login runs the interactive OAuth flow for server and reconnects it. + Login(ctx context.Context, server string) (string, error) + // Logout forgets a server's stored OAuth credential. + Logout(server string) (string, error) + // Status summarizes each server's authorization state. + Status() (string, error) +} + +type mcpAuthAdapterHolder struct{ a MCPAuthAdapter } + +var mcpAuthAdapterAtom atomic.Value // mcpAuthAdapterHolder + +// SetMCPAuthAdapter wires the live adapter. Called from the top-level cli +// package at startup; pass nil to detach. +func SetMCPAuthAdapter(a MCPAuthAdapter) { + mcpAuthAdapterAtom.Store(mcpAuthAdapterHolder{a: a}) +} + +func currentMCPAuthAdapter() MCPAuthAdapter { + v := mcpAuthAdapterAtom.Load() + if v == nil { + return nil + } + h, _ := v.(mcpAuthAdapterHolder) + return h.a +} + +// BuiltinMCPLoginPlugin is the @mcp-login tool. +type BuiltinMCPLoginPlugin struct{} + +// NewBuiltinMCPLoginPlugin returns a ready-to-register plugin. +func NewBuiltinMCPLoginPlugin() *BuiltinMCPLoginPlugin { return &BuiltinMCPLoginPlugin{} } + +// Name returns "@mcp-login". +func (*BuiltinMCPLoginPlugin) Name() string { return "@mcp-login" } + +// Description surfaces the tool in /plugin list and the agent tool catalog. +func (*BuiltinMCPLoginPlugin) Description() string { + return "Authorize a remote OAuth-protected MCP server so its tools become usable. Call this when an mcp_* tool fails with an 'authorization required' error, or a server shows as needing login. It runs the OAuth flow (opens a browser for the user to approve), stores the token securely, refreshes it automatically thereafter, and reconnects the server. Subcommands: login {server}, status, logout {server}." +} + +// Usage explains the canonical invocation forms. +func (*BuiltinMCPLoginPlugin) Usage() string { + return ` + +Subcommands: + login {server} run the OAuth flow for the server and reconnect it (opens a browser) + status show each MCP server's authorization state + logout {server} forget a server's stored credential + +When an mcp_* call returns "authorization required for server X", call: + {"cmd":"login","args":{"server":"X"}} +then retry the original tool.` +} + +// Version is semver; bumped when the surface changes. +func (*BuiltinMCPLoginPlugin) Version() string { return "1.0.0" } + +// Path is empty for builtin plugins. +func (*BuiltinMCPLoginPlugin) Path() string { return "" } + +// Schema exposes the structured description the agent prompt builder renders +// into per-subcommand flag lists with examples. +func (*BuiltinMCPLoginPlugin) Schema() string { + serverFlag := map[string]interface{}{"name": "server", "description": "name of the configured MCP server", "type": "string", "required": true} + schema := map[string]interface{}{ + "argsFormat": "JSON envelope {cmd, args} preferred; argv form also accepted", + "subcommands": []map[string]interface{}{ + { + "name": "login", + "description": "run the OAuth authorization flow for the server (opens a browser) and reconnect it so its tools become usable", + "flags": []map[string]interface{}{serverFlag}, + "examples": []string{`{"cmd":"login","args":{"server":"aws"}}`}, + }, + { + "name": "status", + "description": "show each MCP server's authorization state (connected / authorization required / logged in)", + "flags": []map[string]interface{}{}, + "examples": []string{`{"cmd":"status"}`}, + }, + { + "name": "logout", + "description": "forget a server's stored OAuth credential", + "flags": []map[string]interface{}{serverFlag}, + "examples": []string{`{"cmd":"logout","args":{"server":"aws"}}`}, + }, + }, + } + b, _ := json.Marshal(schema) + return string(b) +} + +// Execute implements the plugin contract. +func (p *BuiltinMCPLoginPlugin) Execute(ctx context.Context, args []string) (string, error) { + return p.ExecuteWithStream(ctx, args, nil) +} + +// ExecuteWithStream dispatches to the adapter. This tool produces no +// incremental output, so the stream callback is ignored. +func (p *BuiltinMCPLoginPlugin) ExecuteWithStream(ctx context.Context, args []string, _ func(string)) (string, error) { + adapter := currentMCPAuthAdapter() + if adapter == nil { + return "", errors.New("@mcp-login: MCP is not enabled in this session") + } + + cmd, server, err := parseMCPLoginInvocation(args) + if err != nil { + return "", fmt.Errorf("@mcp-login: %w", err) + } + + switch cmd { + case "login": + if server == "" { + return "", errors.New(`@mcp-login: "server" is required for login. Example: {"cmd":"login","args":{"server":"aws"}}`) + } + return adapter.Login(ctx, server) + case "status": + return adapter.Status() + case "logout": + if server == "" { + return "", errors.New(`@mcp-login: "server" is required for logout`) + } + return adapter.Logout(server) + default: + return "", fmt.Errorf("@mcp-login: unknown cmd %q (valid: login|status|logout)", cmd) + } +} + +// parseMCPLoginInvocation extracts the subcommand and server name from any of +// the shapes the agent may produce. It is deliberately lenient — strict +// parsing here just makes the model retry blindly: +// +// - JSON envelope: {"cmd":"logout","args":{"server":"aws"}} +// - bare JSON: {"server":"aws"} (implies login) +// - positional: logout aws | status | aws +// - flag form: logout --server aws | logout --server=aws +// --cmd logout --server aws +// +// The flag form matters because the agent's tool flattener rewrites a +// {cmd,args} envelope into "--flag value" pairs; treating "--server" as the +// server name (the previous behavior) made every such call fail. +func parseMCPLoginInvocation(args []string) (cmd string, server string, err error) { + payload := strings.TrimSpace(strings.Join(args, " ")) + if payload == "" { + return "status", "", nil + } + + if strings.HasPrefix(payload, "{") { + var raw struct { + Cmd string `json:"cmd"` + Server string `json:"server"` + Name string `json:"name"` + Args struct { + Server string `json:"server"` + Name string `json:"name"` + } `json:"args"` + } + if uerr := json.Unmarshal([]byte(payload), &raw); uerr != nil { + return "", "", fmt.Errorf(`parse envelope: %w. Expected {"cmd":"login","args":{"server":"aws"}}`, uerr) + } + server = firstNonEmptyStr(raw.Args.Server, raw.Args.Name, raw.Server, raw.Name) + cmd = canonicalMCPLoginCmd(raw.Cmd) + if cmd == "" { + if server != "" { + return "login", server, nil + } + return "status", "", nil + } + return cmd, server, nil + } + + // argv / flag form. Scan tokens, honoring --server / --cmd (and =forms), + // and collect the rest as positionals. + var positionals []string + fields := strings.Fields(payload) + for i := 0; i < len(fields); i++ { + f := fields[i] + key, val, hasEq := strings.Cut(f, "=") + key = strings.TrimLeft(key, "-") + switch strings.ToLower(key) { + case "server", "name": + if hasEq { + server = trimQuotes(val) + } else if i+1 < len(fields) { + server = trimQuotes(fields[i+1]) + i++ + } + case "cmd", "command", "action": + if hasEq { + cmd = canonicalMCPLoginCmd(val) + } else if i+1 < len(fields) { + cmd = canonicalMCPLoginCmd(fields[i+1]) + i++ + } + default: + if strings.HasPrefix(f, "-") { + continue // unknown flag — ignore + } + positionals = append(positionals, trimQuotes(f)) + } + } + for _, p := range positionals { + if cmd == "" { + if c := canonicalMCPLoginCmd(p); c != "" { + cmd = c + continue + } + } + if server == "" { + server = p + } + } + if cmd == "" { + if server != "" { + cmd = "login" + } else { + cmd = "status" + } + } + return cmd, server, nil +} + +func canonicalMCPLoginCmd(cmd string) string { + switch strings.ToLower(strings.TrimSpace(cmd)) { + case "login", "auth", "authorize", "connect": + return "login" + case "status", "list", "ls": + return "status" + case "logout", "signout", "forget", "revoke": + return "logout" + default: + return "" + } +} + +func firstNonEmptyStr(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/cli/plugins/builtin_mcplogin_caps.go b/cli/plugins/builtin_mcplogin_caps.go new file mode 100644 index 00000000..b725fd5d --- /dev/null +++ b/cli/plugins/builtin_mcplogin_caps.go @@ -0,0 +1,41 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ + +package plugins + +import "github.com/diillson/chatcli/i18n" + +// Capability advertisements for BuiltinMCPLoginPlugin. + +// IsReadOnly reports true only for the "status" subcommand. login/logout have +// side effects (browser flow, token mint/delete, server reconnect), so they +// stay gated: omitting a read-only claim for them makes the policy engine treat +// them as confirm-first actions. +func (p *BuiltinMCPLoginPlugin) IsReadOnly(args []string) bool { + cmd, _, err := parseMCPLoginInvocation(args) + return err == nil && cmd == "status" +} + +// IsConcurrencySafe reports false: an interactive OAuth flow binds a fixed +// loopback port and reconnects a server — it must not run alongside another +// login or a reconnect of the same server. +func (p *BuiltinMCPLoginPlugin) IsConcurrencySafe(_ []string) bool { return false } + +// DescribeCall surfaces the action so the spinner reads "MCP auth: login aws". +func (p *BuiltinMCPLoginPlugin) DescribeCall(args []string) string { + cmd, server, err := parseMCPLoginInvocation(args) + if err != nil { + return i18n.T("plugins.mcplogin.describe_generic") + } + switch cmd { + case "login": + return i18n.T("plugins.mcplogin.describe_login", server) + case "logout": + return i18n.T("plugins.mcplogin.describe_logout", server) + default: + return i18n.T("plugins.mcplogin.describe_status") + } +} diff --git a/cli/plugins/builtin_mcplogin_test.go b/cli/plugins/builtin_mcplogin_test.go new file mode 100644 index 00000000..ca7245c4 --- /dev/null +++ b/cli/plugins/builtin_mcplogin_test.go @@ -0,0 +1,154 @@ +/* + * ChatCLI - @mcp-login tool tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package plugins + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeMCPAuthAdapter struct { + loggedIn string + loggedOut string + statusText string + loginErr error +} + +func (f *fakeMCPAuthAdapter) Login(_ context.Context, server string) (string, error) { + if f.loginErr != nil { + return "", f.loginErr + } + f.loggedIn = server + return "authorized " + server, nil +} + +func (f *fakeMCPAuthAdapter) Logout(server string) (string, error) { + f.loggedOut = server + return "forgot " + server, nil +} + +func (f *fakeMCPAuthAdapter) Status() (string, error) { + if f.statusText == "" { + return "no servers", nil + } + return f.statusText, nil +} + +func TestParseMCPLoginInvocation(t *testing.T) { + cases := []struct { + name string + args []string + wantCmd string + wantServer string + }{ + {"empty→status", []string{}, "status", ""}, + {"envelope login", []string{`{"cmd":"login","args":{"server":"aws"}}`}, "login", "aws"}, + {"envelope status", []string{`{"cmd":"status"}`}, "status", ""}, + {"envelope logout", []string{`{"cmd":"logout","args":{"server":"gh"}}`}, "logout", "gh"}, + {"bare server object", []string{`{"server":"aws"}`}, "login", "aws"}, + {"top-level server field", []string{`{"cmd":"login","server":"aws"}`}, "login", "aws"}, + {"argv login", []string{"login", "aws"}, "login", "aws"}, + {"argv status", []string{"status"}, "status", ""}, + {"argv bare token", []string{"aws"}, "login", "aws"}, + {"alias auth", []string{"auth", "aws"}, "login", "aws"}, + {"alias forget", []string{"forget", "aws"}, "logout", "aws"}, + // Flattened flag forms — the agent rewrites {cmd,args} into --flag value. + {"flag form logout", []string{"logout", "--server", "aws-mcp"}, "logout", "aws-mcp"}, + {"flag form login", []string{"login", "--server", "aws"}, "login", "aws"}, + {"flag eq form", []string{"logout", "--server=aws-mcp"}, "logout", "aws-mcp"}, + {"cmd+server flags", []string{"--cmd", "logout", "--server", "aws"}, "logout", "aws"}, + {"name alias flag", []string{"login", "--name", "aws"}, "login", "aws"}, + {"quoted server", []string{"login", `--server="aws-mcp"`}, "login", "aws-mcp"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cmd, server, err := parseMCPLoginInvocation(c.args) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if cmd != c.wantCmd || server != c.wantServer { + t.Fatalf("got (%q,%q), want (%q,%q)", cmd, server, c.wantCmd, c.wantServer) + } + }) + } +} + +func TestMCPLoginExecuteDispatch(t *testing.T) { + fake := &fakeMCPAuthAdapter{statusText: "aws: connected"} + SetMCPAuthAdapter(fake) + t.Cleanup(func() { SetMCPAuthAdapter(nil) }) + + p := NewBuiltinMCPLoginPlugin() + + out, err := p.Execute(context.Background(), []string{`{"cmd":"login","args":{"server":"aws"}}`}) + if err != nil || !strings.Contains(out, "authorized aws") { + t.Fatalf("login dispatch: out=%q err=%v", out, err) + } + if fake.loggedIn != "aws" { + t.Fatalf("adapter.Login not called with aws, got %q", fake.loggedIn) + } + + out, err = p.Execute(context.Background(), []string{`{"cmd":"status"}`}) + if err != nil || !strings.Contains(out, "connected") { + t.Fatalf("status dispatch: out=%q err=%v", out, err) + } + + out, err = p.Execute(context.Background(), []string{"logout", "aws"}) + if err != nil || !strings.Contains(out, "forgot aws") { + t.Fatalf("logout dispatch: out=%q err=%v", out, err) + } + + // login without server must error, not reach the adapter. + if _, err = p.Execute(context.Background(), []string{`{"cmd":"login"}`}); err == nil { + t.Fatalf("login without server should error") + } +} + +func TestMCPLoginExecuteErrors(t *testing.T) { + // No adapter wired. + SetMCPAuthAdapter(nil) + p := NewBuiltinMCPLoginPlugin() + if _, err := p.Execute(context.Background(), []string{`{"cmd":"status"}`}); err == nil { + t.Fatalf("expected error when no adapter is wired") + } + + fake := &fakeMCPAuthAdapter{loginErr: errors.New("boom")} + SetMCPAuthAdapter(fake) + t.Cleanup(func() { SetMCPAuthAdapter(nil) }) + if _, err := p.Execute(context.Background(), []string{`{"cmd":"login","args":{"server":"aws"}}`}); err == nil { + t.Fatalf("expected login error to propagate") + } +} + +func TestMCPLoginShapeAndCaps(t *testing.T) { + p := NewBuiltinMCPLoginPlugin() + if p.Name() != "@mcp-login" { + t.Fatalf("name=%q", p.Name()) + } + if p.Path() != "" { + t.Fatalf("builtin must have empty Path") + } + for _, s := range []string{p.Description(), p.Usage(), p.Version(), p.Schema()} { + if strings.TrimSpace(s) == "" { + t.Fatalf("empty metadata field") + } + } + // Read-only gating: status yes, login/logout no. + if !p.IsReadOnly([]string{`{"cmd":"status"}`}) { + t.Errorf("status must be read-only") + } + if p.IsReadOnly([]string{`{"cmd":"login","args":{"server":"aws"}}`}) { + t.Errorf("login must not be read-only") + } + if p.IsConcurrencySafe(nil) { + t.Errorf("must not be concurrency-safe") + } + if strings.TrimSpace(p.DescribeCall([]string{`{"cmd":"login","args":{"server":"aws"}}`})) == "" { + t.Errorf("DescribeCall empty") + } +} diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index c7968c17..6adbcd67 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -1,4 +1,61 @@ { + "mcp.oauth.required_error": "MCP server %q requires OAuth authorization. Call the @mcp-login tool with {\"server\":\"%s\"} to authorize (this opens a browser), then retry the original tool.", + "mcp.oauth.no_home": "cannot resolve home directory for MCP OAuth storage", + "mcp.oauth.discovery_failed": "MCP OAuth discovery failed", + "mcp.oauth.no_auth_servers": "the MCP server's protected-resource metadata lists no authorization servers", + "mcp.oauth.incomplete_metadata": "authorization server metadata at %s is missing the authorization or token endpoint", + "mcp.oauth.no_metadata_endpoint": "could not locate the authorization server metadata endpoint", + "mcp.oauth.no_registration_endpoint": "the authorization server does not support dynamic client registration and no client_id is configured", + "mcp.oauth.registration_failed": "dynamic client registration failed: %s", + "mcp.oauth.listener_failed": "could not start the local OAuth callback listener", + "mcp.oauth.token_exchange_failed": "OAuth token exchange failed", + "mcp.oauth.empty_access_token": "OAuth token exchange returned an empty access token", + "mcp.oauth.refresh_nil_cred": "cannot refresh a nil MCP credential", + "mcp.oauth.refresh_missing": "no refresh token available for this MCP server; run /mcp login again", + "mcp.oauth.refresh_failed": "MCP OAuth token refresh failed", + "mcp.oauth.bad_server_url": "invalid MCP server URL %q", + "mcp.oauth.http_get_failed": "GET %s returned HTTP %d", + "mcp.oauth.not_remote": "MCP server %q is not a remote (http/sse) server; OAuth login does not apply", + "mcp.oauth.no_url": "MCP server %q has no URL configured", + "mcp.oauth.state_failed": "failed to generate OAuth state", + "mcp.oauth.callback_csrf_html": "

Authorization failed

State mismatch (possible CSRF). You can close this tab.

", + "mcp.oauth.callback_csrf": "OAuth state mismatch (possible CSRF)", + "mcp.oauth.callback_error_html": "

Authorization failed

The authorization server returned an error. You can close this tab.

", + "mcp.oauth.callback_server_error": "authorization server returned error %q: %s", + "mcp.oauth.callback_nocode_html": "

Authorization failed

No authorization code received. You can close this tab.

", + "mcp.oauth.callback_nocode": "no authorization code in callback", + "mcp.oauth.callback_success_html": "

✅ Authorized

ChatCLI has been authorized. You can close this tab and return to the terminal.

", + "mcp.oauth.opening_browser": "Opening your browser to authorize the MCP server…", + "mcp.oauth.browser_failed": "Could not open a browser automatically. Open this URL manually:", + "mcp.oauth.browser_hint": "If your browser did not open, visit this URL:", + "mcp.oauth.waiting": "Waiting for authorization to complete…", + "mcp.oauth.callback_failed": "OAuth callback failed", + "mcp.oauth.callback_timeout": "timed out waiting for OAuth authorization after %s", + "mcp.oauth.not_enabled": "MCP is not enabled in this session", + "mcp.oauth.login_success": "Authorized MCP server %q and reconnected — %d tool(s) now available.", + "mcp.oauth.logout_success": "Forgot stored OAuth credential for MCP server %q.", + "mcp.oauth.status_no_servers": "No MCP servers are configured.", + "mcp.oauth.status_header": "MCP server authorization:", + "mcp.oauth.status_connected": "connected", + "mcp.oauth.status_auth_required": "authorization required (run @mcp-login)", + "mcp.oauth.status_logged_in_disconnected": "logged in, disconnected", + "mcp.oauth.status_disconnected": "disconnected", + "plugins.mcplogin.describe_generic": "MCP auth", + "plugins.mcplogin.describe_login": "MCP auth: login %s", + "plugins.mcplogin.describe_logout": "MCP auth: logout %s", + "plugins.mcplogin.describe_status": "MCP auth: status", + "mcp.cmd.login_usage": "Usage: /mcp login ", + "mcp.cmd.login_starting": "Authorizing MCP server %q…", + "mcp.cmd.login_failed": "Login failed: %v", + "mcp.cmd.login_ok": "Authorized %q — %d tool(s) available.", + "mcp.cmd.logout_usage": "Usage: /mcp logout ", + "mcp.cmd.logout_failed": "Logout failed: %v", + "mcp.cmd.logout_ok": "Forgot credential for %q.", + "mcp.cmd.sug_login": "Authorize an OAuth-protected server", + "mcp.cmd.sug_logout": "Forget a server's stored credential", + "mcp.cmd.status_auth_required": "authorization required", + "mcp.cmd.auth_required_hint": "run /mcp login %s (or @mcp-login) to authorize", + "agent.mcp.note_auth_required": "NOTE: these MCP servers require OAuth authorization before their tools are usable: %s. To authorize one, call the @mcp-login tool with {\"cmd\":\"login\",\"args\":{\"server\":\"\"}} — it opens a browser for the user to approve. After it succeeds, the server's mcp_* tools become available.", "cfg.section.output.title": "Output", "cfg.output.verbosity": "verbosity", "cfg.output.effort_routing": "effort routing", @@ -2286,7 +2343,7 @@ "mcp.cmd.box_title": "MCP SERVERS", "mcp.cmd.not_enabled": "MCP is not enabled.", "mcp.cmd.enable_hint": "To enable, create ~/.chatcli/mcp_servers.json:", - "mcp.cmd.usage": "Usage: /mcp [status|tools|restart|start|stop|reload|logs] [server]", + "mcp.cmd.usage": "Usage: /mcp [status|tools|restart|start|stop|reload|logs|login|logout] [server]", "mcp.cmd.no_servers": "No servers configured.", "mcp.cmd.status_connected": "connected", "mcp.cmd.status_disconnected": "disconnected", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index da4c3537..478f0cc7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1,4 +1,61 @@ { + "mcp.oauth.required_error": "MCP server %q requires OAuth authorization. Call the @mcp-login tool with {\"server\":\"%s\"} to authorize (this opens a browser), then retry the original tool.", + "mcp.oauth.no_home": "cannot resolve home directory for MCP OAuth storage", + "mcp.oauth.discovery_failed": "MCP OAuth discovery failed", + "mcp.oauth.no_auth_servers": "the MCP server's protected-resource metadata lists no authorization servers", + "mcp.oauth.incomplete_metadata": "authorization server metadata at %s is missing the authorization or token endpoint", + "mcp.oauth.no_metadata_endpoint": "could not locate the authorization server metadata endpoint", + "mcp.oauth.no_registration_endpoint": "the authorization server does not support dynamic client registration and no client_id is configured", + "mcp.oauth.registration_failed": "dynamic client registration failed: %s", + "mcp.oauth.listener_failed": "could not start the local OAuth callback listener", + "mcp.oauth.token_exchange_failed": "OAuth token exchange failed", + "mcp.oauth.empty_access_token": "OAuth token exchange returned an empty access token", + "mcp.oauth.refresh_nil_cred": "cannot refresh a nil MCP credential", + "mcp.oauth.refresh_missing": "no refresh token available for this MCP server; run /mcp login again", + "mcp.oauth.refresh_failed": "MCP OAuth token refresh failed", + "mcp.oauth.bad_server_url": "invalid MCP server URL %q", + "mcp.oauth.http_get_failed": "GET %s returned HTTP %d", + "mcp.oauth.not_remote": "MCP server %q is not a remote (http/sse) server; OAuth login does not apply", + "mcp.oauth.no_url": "MCP server %q has no URL configured", + "mcp.oauth.state_failed": "failed to generate OAuth state", + "mcp.oauth.callback_csrf_html": "

Authorization failed

State mismatch (possible CSRF). You can close this tab.

", + "mcp.oauth.callback_csrf": "OAuth state mismatch (possible CSRF)", + "mcp.oauth.callback_error_html": "

Authorization failed

The authorization server returned an error. You can close this tab.

", + "mcp.oauth.callback_server_error": "authorization server returned error %q: %s", + "mcp.oauth.callback_nocode_html": "

Authorization failed

No authorization code received. You can close this tab.

", + "mcp.oauth.callback_nocode": "no authorization code in callback", + "mcp.oauth.callback_success_html": "

✅ Authorized

ChatCLI has been authorized. You can close this tab and return to the terminal.

", + "mcp.oauth.opening_browser": "Opening your browser to authorize the MCP server…", + "mcp.oauth.browser_failed": "Could not open a browser automatically. Open this URL manually:", + "mcp.oauth.browser_hint": "If your browser did not open, visit this URL:", + "mcp.oauth.waiting": "Waiting for authorization to complete…", + "mcp.oauth.callback_failed": "OAuth callback failed", + "mcp.oauth.callback_timeout": "timed out waiting for OAuth authorization after %s", + "mcp.oauth.not_enabled": "MCP is not enabled in this session", + "mcp.oauth.login_success": "Authorized MCP server %q and reconnected — %d tool(s) now available.", + "mcp.oauth.logout_success": "Forgot stored OAuth credential for MCP server %q.", + "mcp.oauth.status_no_servers": "No MCP servers are configured.", + "mcp.oauth.status_header": "MCP server authorization:", + "mcp.oauth.status_connected": "connected", + "mcp.oauth.status_auth_required": "authorization required (run @mcp-login)", + "mcp.oauth.status_logged_in_disconnected": "logged in, disconnected", + "mcp.oauth.status_disconnected": "disconnected", + "plugins.mcplogin.describe_generic": "MCP auth", + "plugins.mcplogin.describe_login": "MCP auth: login %s", + "plugins.mcplogin.describe_logout": "MCP auth: logout %s", + "plugins.mcplogin.describe_status": "MCP auth: status", + "mcp.cmd.login_usage": "Usage: /mcp login ", + "mcp.cmd.login_starting": "Authorizing MCP server %q…", + "mcp.cmd.login_failed": "Login failed: %v", + "mcp.cmd.login_ok": "Authorized %q — %d tool(s) available.", + "mcp.cmd.logout_usage": "Usage: /mcp logout ", + "mcp.cmd.logout_failed": "Logout failed: %v", + "mcp.cmd.logout_ok": "Forgot credential for %q.", + "mcp.cmd.sug_login": "Authorize an OAuth-protected server", + "mcp.cmd.sug_logout": "Forget a server's stored credential", + "mcp.cmd.status_auth_required": "authorization required", + "mcp.cmd.auth_required_hint": "run /mcp login %s (or @mcp-login) to authorize", + "agent.mcp.note_auth_required": "NOTE: these MCP servers require OAuth authorization before their tools are usable: %s. To authorize one, call the @mcp-login tool with {\"cmd\":\"login\",\"args\":{\"server\":\"\"}} — it opens a browser for the user to approve. After it succeeds, the server's mcp_* tools become available.", "cfg.section.output.title": "Output", "cfg.output.verbosity": "verbosity", "cfg.output.effort_routing": "effort routing", @@ -2286,7 +2343,7 @@ "mcp.cmd.box_title": "MCP SERVERS", "mcp.cmd.not_enabled": "MCP is not enabled.", "mcp.cmd.enable_hint": "To enable, create ~/.chatcli/mcp_servers.json:", - "mcp.cmd.usage": "Usage: /mcp [status|tools|restart|start|stop|reload|logs] [server]", + "mcp.cmd.usage": "Usage: /mcp [status|tools|restart|start|stop|reload|logs|login|logout] [server]", "mcp.cmd.no_servers": "No servers configured.", "mcp.cmd.status_connected": "connected", "mcp.cmd.status_disconnected": "disconnected", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 88cd4bfc..718bf184 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -1,4 +1,61 @@ { + "mcp.oauth.required_error": "O servidor MCP %q exige autorização OAuth. Chame a tool @mcp-login com {\"server\":\"%s\"} para autorizar (abre o navegador) e tente a ferramenta original novamente.", + "mcp.oauth.no_home": "não foi possível resolver o diretório home para o armazenamento OAuth do MCP", + "mcp.oauth.discovery_failed": "falha na descoberta OAuth do MCP", + "mcp.oauth.no_auth_servers": "os metadados de recurso protegido do servidor MCP não listam nenhum authorization server", + "mcp.oauth.incomplete_metadata": "os metadados do authorization server em %s não têm o endpoint de authorization ou token", + "mcp.oauth.no_metadata_endpoint": "não foi possível localizar o endpoint de metadados do authorization server", + "mcp.oauth.no_registration_endpoint": "o authorization server não suporta registro dinâmico de cliente e nenhum client_id foi configurado", + "mcp.oauth.registration_failed": "falha no registro dinâmico de cliente: %s", + "mcp.oauth.listener_failed": "não foi possível iniciar o listener local de callback OAuth", + "mcp.oauth.token_exchange_failed": "falha na troca de token OAuth", + "mcp.oauth.empty_access_token": "a troca de token OAuth retornou um access token vazio", + "mcp.oauth.refresh_nil_cred": "não é possível renovar uma credencial MCP nula", + "mcp.oauth.refresh_missing": "nenhum refresh token disponível para este servidor MCP; rode /mcp login novamente", + "mcp.oauth.refresh_failed": "falha ao renovar o token OAuth do MCP", + "mcp.oauth.bad_server_url": "URL do servidor MCP inválida %q", + "mcp.oauth.http_get_failed": "GET %s retornou HTTP %d", + "mcp.oauth.not_remote": "o servidor MCP %q não é remoto (http/sse); login OAuth não se aplica", + "mcp.oauth.no_url": "o servidor MCP %q não tem URL configurada", + "mcp.oauth.state_failed": "falha ao gerar o state OAuth", + "mcp.oauth.callback_csrf_html": "

Falha na autorização

State divergente (possível CSRF). Você pode fechar esta aba.

", + "mcp.oauth.callback_csrf": "state OAuth divergente (possível CSRF)", + "mcp.oauth.callback_error_html": "

Falha na autorização

O authorization server retornou um erro. Você pode fechar esta aba.

", + "mcp.oauth.callback_server_error": "authorization server retornou erro %q: %s", + "mcp.oauth.callback_nocode_html": "

Falha na autorização

Nenhum código de autorização recebido. Você pode fechar esta aba.

", + "mcp.oauth.callback_nocode": "nenhum código de autorização no callback", + "mcp.oauth.callback_success_html": "

✅ Autorizado

O ChatCLI foi autorizado. Você pode fechar esta aba e voltar ao terminal.

", + "mcp.oauth.opening_browser": "Abrindo o navegador para autorizar o servidor MCP…", + "mcp.oauth.browser_failed": "Não foi possível abrir o navegador automaticamente. Abra esta URL manualmente:", + "mcp.oauth.browser_hint": "Se o navegador não abriu, acesse esta URL:", + "mcp.oauth.waiting": "Aguardando a conclusão da autorização…", + "mcp.oauth.callback_failed": "falha no callback OAuth", + "mcp.oauth.callback_timeout": "tempo esgotado aguardando a autorização OAuth após %s", + "mcp.oauth.not_enabled": "o MCP não está habilitado nesta sessão", + "mcp.oauth.login_success": "Servidor MCP %q autorizado e reconectado — %d ferramenta(s) disponível(is) agora.", + "mcp.oauth.logout_success": "Credencial OAuth do servidor MCP %q removida.", + "mcp.oauth.status_no_servers": "Nenhum servidor MCP configurado.", + "mcp.oauth.status_header": "Autorização dos servidores MCP:", + "mcp.oauth.status_connected": "conectado", + "mcp.oauth.status_auth_required": "autorização necessária (rode @mcp-login)", + "mcp.oauth.status_logged_in_disconnected": "autenticado, desconectado", + "mcp.oauth.status_disconnected": "desconectado", + "plugins.mcplogin.describe_generic": "auth MCP", + "plugins.mcplogin.describe_login": "auth MCP: login %s", + "plugins.mcplogin.describe_logout": "auth MCP: logout %s", + "plugins.mcplogin.describe_status": "auth MCP: status", + "mcp.cmd.login_usage": "Uso: /mcp login ", + "mcp.cmd.login_starting": "Autorizando o servidor MCP %q…", + "mcp.cmd.login_failed": "Falha no login: %v", + "mcp.cmd.login_ok": "%q autorizado — %d ferramenta(s) disponível(is).", + "mcp.cmd.logout_usage": "Uso: /mcp logout ", + "mcp.cmd.logout_failed": "Falha no logout: %v", + "mcp.cmd.logout_ok": "Credencial de %q removida.", + "mcp.cmd.sug_login": "Autorizar um servidor protegido por OAuth", + "mcp.cmd.sug_logout": "Remover a credencial salva de um servidor", + "mcp.cmd.status_auth_required": "autorização necessária", + "mcp.cmd.auth_required_hint": "rode /mcp login %s (ou @mcp-login) para autorizar", + "agent.mcp.note_auth_required": "NOTA: estes servidores MCP exigem autorização OAuth antes que suas ferramentas fiquem disponíveis: %s. Para autorizar, chame a tool @mcp-login com {\"cmd\":\"login\",\"args\":{\"server\":\"\"}} — ela abre o navegador para o usuário aprovar. Após o sucesso, as ferramentas mcp_* do servidor ficam disponíveis.", "cfg.section.output.title": "Saída", "cfg.output.verbosity": "verbosidade", "cfg.output.effort_routing": "roteamento de esforço", @@ -2286,7 +2343,7 @@ "mcp.cmd.box_title": "SERVIDORES MCP", "mcp.cmd.not_enabled": "MCP não está habilitado.", "mcp.cmd.enable_hint": "Para habilitar, crie ~/.chatcli/mcp_servers.json:", - "mcp.cmd.usage": "Uso: /mcp [status|tools|restart|start|stop|reload|logs] [servidor]", + "mcp.cmd.usage": "Uso: /mcp [status|tools|restart|start|stop|reload|logs|login|logout] [servidor]", "mcp.cmd.no_servers": "Nenhum servidor configurado.", "mcp.cmd.status_connected": "conectado", "mcp.cmd.status_disconnected": "desconectado",