From 302aa859f7d239c5b8d50b3e00431a9edbbc4298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:54:08 +0100 Subject: [PATCH] MCP remote server OAuth authentication (#51768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #43162 Implements the OAuth 2.0 Authorization Code + PKCE authentication flow for remote MCP servers using Streamable HTTP transport, as specified by the [MCP auth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). Previously, connecting to a remote MCP server that required OAuth would silently fail with a timeout — the server's 401 response was never handled. Now, Zed detects the 401, performs OAuth discovery, and guides the user through browser-based authentication. Step-up authentication and pre-registered clients are not in scope for this PR, but will be done as follow-ups. ## Overview - **401 detection** — When the HTTP transport receives a 401 during server startup, it surfaces a typed `TransportError::AuthRequired` with parsed `WWW-Authenticate` header info. - **OAuth discovery** — Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414) are fetched to locate the authorization and token endpoints. - **Client registration** — Zed first tries CIMD (Client ID Metadata Document) hosted at `zed.dev`. If the server doesn't support CIMD, falls back to Dynamic Client Registration (DCR). - **Browser flow** — A loopback HTTP callback server starts on a preferred fixed port (27523, listed in the CIMD), the user's browser opens to the authorization URL, and Zed waits for the callback with the authorization code. - **Token exchange & persistence** — The code is exchanged for access/refresh tokens using PKCE. The session is persisted in the system keychain so subsequent startups restore it without another browser flow. - **Automatic refresh** — The HTTP transport transparently refreshes expired tokens using the refresh token, and persists the updated session to the keychain. ## UI changes - Servers requiring auth show a warning indicator with an **"Authenticate"** button - During auth, a spinner and **"Waiting for authorization..."** message are shown - A **"Log Out"** option is available in the server settings menu for OAuth-authenticated servers - The configure server modal handles the auth flow inline when configuring a new server that needs authentication. Release Notes: - Added OAuth authentication support for remote MCP servers. Servers requiring OAuth now show an "Authenticate" button when they need you to log in. You will be redirected in your browser to the authorization server of the MCP server to go through the authorization flow. --------- Co-authored-by: Danilo Leal --- Cargo.lock | 5 + .../src/tools/context_server_registry.rs | 6 +- crates/agent_ui/src/agent_configuration.rs | 163 +- .../configure_context_server_modal.rs | 268 +- .../src/text_thread_store.rs | 6 +- crates/context_server/Cargo.toml | 4 + crates/context_server/src/client.rs | 32 +- crates/context_server/src/context_server.rs | 1 + crates/context_server/src/oauth.rs | 2800 +++++++++++++++++ crates/context_server/src/transport/http.rs | 517 ++- crates/project/Cargo.toml | 1 + crates/project/src/context_server_store.rs | 597 +++- crates/ui/src/components/modal.rs | 2 +- 13 files changed, 4240 insertions(+), 162 deletions(-) create mode 100644 crates/context_server/src/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index d76e9f1f40cfb1be27799ee3433957639872b324..434e74a46219a94296af742d7298889f03d7627f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3572,6 +3572,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "collections", "futures 0.3.31", "gpui", @@ -3580,14 +3581,17 @@ dependencies = [ "net", "parking_lot", "postage", + "rand 0.9.2", "schemars", "serde", "serde_json", "settings", + "sha2", "slotmap", "smol", "tempfile", "terminal", + "tiny_http", "url", "util", ] @@ -13189,6 +13193,7 @@ dependencies = [ "clock", "collections", "context_server", + "credentials_provider", "dap", "encoding_rs", "extension", diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 1c7590d8097a5de50b879d5b253c5dbabd3dcbab..df4cc313036b55e8842a9c46567256afb92ed944 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -253,12 +253,14 @@ impl ContextServerRegistry { let project::context_server_store::ServerStatusChangedEvent { server_id, status } = event; match status { - ContextServerStatus::Starting => {} + ContextServerStatus::Starting | ContextServerStatus::Authenticating => {} ContextServerStatus::Running => { self.reload_tools_for_server(server_id.clone(), cx); self.reload_prompts_for_server(server_id.clone(), cx); } - ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { + ContextServerStatus::Stopped + | ContextServerStatus::Error(_) + | ContextServerStatus::AuthRequired => { if let Some(registered_server) = self.registered_servers.remove(server_id) { if !registered_server.tools.is_empty() { cx.emit(ContextServerRegistryEvent::ToolsChanged); diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 7c2f23fcbce43bed271c58b750145d75655d16ba..fc5a78dfc936617f3782eae154b6a13531e5c425 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -517,11 +517,7 @@ impl AgentConfiguration { } } - fn render_context_servers_section( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { + fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { let context_server_ids = self.context_server_store.read(cx).server_ids(); let add_server_popover = PopoverMenu::new("add-server-popover") @@ -601,7 +597,7 @@ impl AgentConfiguration { } else { parent.children(itertools::intersperse_with( context_server_ids.iter().cloned().map(|context_server_id| { - self.render_context_server(context_server_id, window, cx) + self.render_context_server(context_server_id, cx) .into_any_element() }), || { @@ -618,7 +614,6 @@ impl AgentConfiguration { fn render_context_server( &self, context_server_id: ContextServerId, - window: &mut Window, cx: &Context, ) -> impl use<> + IntoElement { let server_status = self @@ -646,6 +641,9 @@ impl AgentConfiguration { } else { None }; + let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); + let authenticating = matches!(server_status, ContextServerStatus::Authenticating); + let context_server_store = self.context_server_store.clone(); let tool_count = self .context_server_registry @@ -689,11 +687,33 @@ impl AgentConfiguration { Indicator::dot().color(Color::Muted).into_any_element(), "Server is stopped.", ), + ContextServerStatus::AuthRequired => ( + Indicator::dot().color(Color::Warning).into_any_element(), + "Authentication required.", + ), + ContextServerStatus::Authenticating => ( + Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Accent) + .with_keyed_rotate_animation( + SharedString::from(format!("{}-authenticating", context_server_id.0)), + 3, + ) + .into_any_element(), + "Waiting for authorization...", + ), }; + let is_remote = server_configuration .as_ref() .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) .unwrap_or(false); + + let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| { + matches!(config.as_ref(), ContextServerConfiguration::Http { .. }) + && !config.has_static_auth_header() + }); + let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") .trigger_with_tooltip( IconButton::new("context-server-config-menu", IconName::Settings) @@ -708,6 +728,7 @@ impl AgentConfiguration { let language_registry = self.language_registry.clone(); let workspace = self.workspace.clone(); let context_server_registry = self.context_server_registry.clone(); + let context_server_store = context_server_store.clone(); move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { @@ -754,6 +775,17 @@ impl AgentConfiguration { .ok(); } })) + .when(should_show_logout_button, |this| { + this.entry("Log Out", None, { + let context_server_store = context_server_store.clone(); + let context_server_id = context_server_id.clone(); + move |_window, cx| { + context_server_store.update(cx, |store, cx| { + store.logout_server(&context_server_id, cx).log_err(); + }); + } + }) + }) .separator() .entry("Uninstall", None, { let fs = fs.clone(); @@ -810,6 +842,9 @@ impl AgentConfiguration { } }); + let feedback_base_container = + || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); + v_flex() .min_w_0() .id(item_id.clone()) @@ -868,6 +903,7 @@ impl AgentConfiguration { .on_click({ let context_server_manager = self.context_server_store.clone(); let fs = self.fs.clone(); + let context_server_id = context_server_id.clone(); move |state, _window, cx| { let is_enabled = match state { @@ -915,30 +951,111 @@ impl AgentConfiguration { ) .map(|parent| { if let Some(error) = error { + return parent + .child( + feedback_base_container() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(IconName::XCircle) + .size(IconSize::XSmall) + .color(Color::Error), + ) + .child( + div().min_w_0().flex_1().child( + Label::new(error) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ) + .when(should_show_logout_button, |this| { + this.child( + Button::new("error-logout-server", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_store = + context_server_store.clone(); + let context_server_id = + context_server_id.clone(); + move |_event, _window, cx| { + context_server_store.update( + cx, + |store, cx| { + store + .logout_server( + &context_server_id, + cx, + ) + .log_err(); + }, + ); + } + }), + ) + }), + ); + } + if auth_required { return parent.child( - h_flex() - .gap_2() - .pr_4() - .items_start() + feedback_base_container() .child( h_flex() - .flex_none() - .h(window.line_height() / 1.6_f32) - .justify_center() + .pr_4() + .min_w_0() + .w_full() + .gap_2() .child( - Icon::new(IconName::XCircle) + Icon::new(IconName::Info) .size(IconSize::XSmall) - .color(Color::Error), + .color(Color::Muted), + ) + .child( + Label::new("Authenticate to connect this server") + .color(Color::Muted) + .size(LabelSize::Small), ), ) .child( - div().w_full().child( - Label::new(error) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::Small), - ), + Button::new("error-logout-server", "Authenticate") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_store = context_server_store.clone(); + let context_server_id = context_server_id.clone(); + move |_event, _window, cx| { + context_server_store.update(cx, |store, cx| { + store + .authenticate_server(&context_server_id, cx) + .log_err(); + }); + } + }), + ), + ); + } + if authenticating { + return parent.child( + h_flex() + .mt_1() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + div().size_3().flex_shrink_0(), // Alignment Div + ) + .child( + Label::new("Authenticating…") + .color(Color::Muted) + .size(LabelSize::Small), ), + ); } parent @@ -1234,7 +1351,7 @@ impl Render for AgentConfiguration { .min_w_0() .overflow_y_scroll() .child(self.render_agent_servers_section(cx)) - .child(self.render_context_servers_section(window, cx)) + .child(self.render_context_servers_section(cx)) .child(self.render_provider_configuration_section(cx)), ) .vertical_scrollbar_for(&self.scroll_handle, window, cx), diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 857a084b720e732b218f0060f1fbee312f712540..e550d59c0ccb4deab40f6fcbc39dae124e3c08db 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,25 +1,27 @@ -use std::sync::{Arc, Mutex}; - use anyhow::{Context as _, Result}; use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; + use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, - Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, + Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use notifications::status_toast::{StatusToast, ToastIcon}; +use parking_lot::Mutex; use project::{ context_server_store::{ - ContextServerStatus, ContextServerStore, registry::ContextServerDescriptorRegistry, + ContextServerStatus, ContextServerStore, ServerStatusChangedEvent, + registry::ContextServerDescriptorRegistry, }, project_settings::{ContextServerSettings, ProjectSettings}, worktree_store::WorktreeStore, }; use serde::Deserialize; use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; use theme::ThemeSettings; use ui::{ CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, @@ -237,6 +239,8 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) format!( r#"{{ + /// Configure an MCP server that runs locally via stdin/stdout + /// /// The name of your MCP server "{name}": {{ /// The command which runs the MCP server @@ -280,6 +284,8 @@ fn context_server_http_input( format!( r#"{{ + /// Configure an MCP server that you connect to over HTTP + /// /// The name of your remote MCP server "{name}": {{ /// The URL of the remote MCP server @@ -342,6 +348,8 @@ fn resolve_context_server_extension( enum State { Idle, Waiting, + AuthRequired { server_id: ContextServerId }, + Authenticating { _server_id: ContextServerId }, Error(SharedString), } @@ -352,6 +360,7 @@ pub struct ConfigureContextServerModal { state: State, original_server_id: Option, scroll_handle: ScrollHandle, + _auth_subscription: Option, } impl ConfigureContextServerModal { @@ -475,6 +484,7 @@ impl ConfigureContextServerModal { cx, ), scroll_handle: ScrollHandle::new(), + _auth_subscription: None, }) }) }) @@ -486,6 +496,13 @@ impl ConfigureContextServerModal { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { + if matches!( + self.state, + State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } + ) { + return; + } + self.state = State::Idle; let Some(workspace) = self.workspace.upgrade() else { return; @@ -515,14 +532,19 @@ impl ConfigureContextServerModal { async move |this, cx| { let result = wait_for_context_server_task.await; this.update(cx, |this, cx| match result { - Ok(_) => { + Ok(ContextServerStatus::Running) => { this.state = State::Idle; this.show_configured_context_server_toast(id, cx); cx.emit(DismissEvent); } + Ok(ContextServerStatus::AuthRequired) => { + this.state = State::AuthRequired { server_id: id }; + cx.notify(); + } Err(err) => { this.set_error(err, cx); } + Ok(_) => {} }) } }) @@ -558,6 +580,49 @@ impl ConfigureContextServerModal { cx.emit(DismissEvent); } + fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context) { + self.context_server_store.update(cx, |store, cx| { + store.authenticate_server(&server_id, cx).log_err(); + }); + + self.state = State::Authenticating { + _server_id: server_id.clone(), + }; + + self._auth_subscription = Some(cx.subscribe( + &self.context_server_store, + move |this, _, event: &ServerStatusChangedEvent, cx| { + if event.server_id != server_id { + return; + } + match &event.status { + ContextServerStatus::Running => { + this._auth_subscription = None; + this.state = State::Idle; + this.show_configured_context_server_toast(event.server_id.clone(), cx); + cx.emit(DismissEvent); + } + ContextServerStatus::AuthRequired => { + this._auth_subscription = None; + this.state = State::AuthRequired { + server_id: event.server_id.clone(), + }; + cx.notify(); + } + ContextServerStatus::Error(error) => { + this._auth_subscription = None; + this.set_error(error.clone(), cx); + } + ContextServerStatus::Authenticating + | ContextServerStatus::Starting + | ContextServerStatus::Stopped => {} + } + }, + )); + + cx.notify(); + } + fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) { self.workspace .update(cx, { @@ -615,7 +680,8 @@ impl ConfigureContextServerModal { } fn render_modal_description(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - const MODAL_DESCRIPTION: &str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables."; + const MODAL_DESCRIPTION: &str = + "Check the server docs for required arguments and environment variables."; if let ConfigurationSource::Extension { installation_instructions: Some(installation_instructions), @@ -637,6 +703,67 @@ impl ConfigureContextServerModal { } } + fn render_tab_bar(&self, cx: &mut Context) -> Option { + let is_http = match &self.source { + ConfigurationSource::New { is_http, .. } => *is_http, + _ => return None, + }; + + let tab = |label: &'static str, active: bool| { + div() + .id(label) + .cursor_pointer() + .p_1() + .text_sm() + .border_b_1() + .when(active, |this| { + this.border_color(cx.theme().colors().border_focused) + }) + .when(!active, |this| { + this.border_color(gpui::transparent_black()) + .text_color(cx.theme().colors().text_muted) + .hover(|s| s.text_color(cx.theme().colors().text)) + }) + .child(label) + }; + + Some( + h_flex() + .pt_1() + .mb_2p5() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .child( + tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| { + if let ConfigurationSource::New { editor, is_http } = &mut this.source { + if *is_http { + *is_http = false; + let new_text = context_server_input(None); + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }); + } + } + })), + ) + .child( + tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| { + if let ConfigurationSource::New { editor, is_http } = &mut this.source { + if !*is_http { + *is_http = true; + let new_text = context_server_http_input(None); + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }); + } + } + })), + ) + .into_any_element(), + ) + } + fn render_modal_content(&self, cx: &App) -> AnyElement { let editor = match &self.source { ConfigurationSource::New { editor, .. } => editor, @@ -682,7 +809,10 @@ impl ConfigureContextServerModal { fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); - let is_connecting = matches!(self.state, State::Waiting); + let is_busy = matches!( + self.state, + State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } + ); ModalFooter::new() .start_slot::