Implement MCP OAuth client preregistration

Tom Houlรฉ created

Change summary

crates/agent/src/tools/context_server_registry.rs                         |   3 
crates/agent_servers/src/acp.rs                                           |   1 
crates/agent_ui/src/agent_configuration.rs                                |  49 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs | 328 
crates/context_server/src/oauth.rs                                        |  70 
crates/project/src/context_server_store.rs                                | 291 
crates/project/src/project_settings.rs                                    |  29 
crates/project/tests/integration/context_server_store.rs                  |   4 
crates/settings_content/src/project.rs                                    |  19 
crates/ui/src/components/ai/ai_setting_item.rs                            |   4 
10 files changed, 747 insertions(+), 51 deletions(-)

Detailed changes

crates/agent/src/tools/context_server_registry.rs ๐Ÿ”—

@@ -260,7 +260,8 @@ impl ContextServerRegistry {
             }
             ContextServerStatus::Stopped
             | ContextServerStatus::Error(_)
-            | ContextServerStatus::AuthRequired => {
+            | ContextServerStatus::AuthRequired
+            | ContextServerStatus::ClientSecretRequired => {
                 if let Some(registered_server) = self.registered_servers.remove(server_id) {
                     if !registered_server.tools.is_empty() {
                         cx.emit(ContextServerRegistryEvent::ToolsChanged);

crates/agent_servers/src/acp.rs ๐Ÿ”—

@@ -1275,6 +1275,7 @@ fn mcp_servers_for_project(project: &Entity<Project>, cx: &App) -> Vec<acp::McpS
                     url,
                     headers,
                     timeout: _,
+                    oauth: _,
                 } => Some(acp::McpServer::Http(
                     acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers(
                         headers

crates/agent_ui/src/agent_configuration.rs ๐Ÿ”—

@@ -664,8 +664,12 @@ impl AgentConfiguration {
             None
         };
         let auth_required = matches!(server_status, ContextServerStatus::AuthRequired);
+        let client_secret_required =
+            matches!(server_status, ContextServerStatus::ClientSecretRequired);
         let authenticating = matches!(server_status, ContextServerStatus::Authenticating);
         let context_server_store = self.context_server_store.clone();
+        let workspace = self.workspace.clone();
+        let language_registry = self.language_registry.clone();
 
         let tool_count = self
             .context_server_registry
@@ -685,6 +689,7 @@ impl AgentConfiguration {
             ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
             ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
             ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
+            ContextServerStatus::ClientSecretRequired => AiSettingItemStatus::ClientSecretRequired,
             ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
         };
 
@@ -886,7 +891,7 @@ impl AgentConfiguration {
                             ),
                     )
                     .child(
-                        Button::new("error-logout-server", "Authenticate")
+                        Button::new("authenticate-server", "Authenticate")
                             .style(ButtonStyle::Outlined)
                             .label_size(LabelSize::Small)
                             .on_click({
@@ -900,6 +905,48 @@ impl AgentConfiguration {
                     )
                     .into_any_element(),
             )
+        } else if client_secret_required {
+            Some(
+                feedback_base_container()
+                    .child(
+                        h_flex()
+                            .pr_4()
+                            .min_w_0()
+                            .w_full()
+                            .gap_2()
+                            .child(
+                                Icon::new(IconName::Info)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
+                            )
+                            .child(
+                                Label::new("Enter a client secret to connect this server")
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            ),
+                    )
+                    .child(
+                        Button::new("enter-client-secret", "Enter Client Secret")
+                            .style(ButtonStyle::Outlined)
+                            .label_size(LabelSize::Small)
+                            .on_click({
+                                let context_server_id = context_server_id.clone();
+                                let language_registry = language_registry.clone();
+                                let workspace = workspace.clone();
+                                move |_event, window, cx| {
+                                    ConfigureContextServerModal::show_modal_for_existing_server(
+                                        context_server_id.clone(),
+                                        language_registry.clone(),
+                                        workspace.clone(),
+                                        window,
+                                        cx,
+                                    )
+                                    .detach();
+                                }
+                            }),
+                    )
+                    .into_any_element(),
+            )
         } else if authenticating {
             Some(
                 h_flex()

crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs ๐Ÿ”—

@@ -16,7 +16,7 @@ use project::{
         ContextServerStatus, ContextServerStore, ServerStatusChangedEvent,
         registry::ContextServerDescriptorRegistry,
     },
-    project_settings::{ContextServerSettings, ProjectSettings},
+    project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings},
     worktree_store::WorktreeStore,
 };
 use serde::Deserialize;
@@ -42,7 +42,9 @@ enum ConfigurationTarget {
         id: ContextServerId,
         url: String,
         headers: HashMap<String, String>,
+        oauth: Option<OAuthClientSettings>,
     },
+
     Extension {
         id: ContextServerId,
         repository_url: Option<SharedString>,
@@ -120,15 +122,17 @@ impl ConfigurationSource {
                 id,
                 url,
                 headers: auth,
+                oauth,
             } => ConfigurationSource::Existing {
                 editor: create_editor(
-                    context_server_http_input(Some((id, url, auth))),
+                    context_server_http_input(Some((id, url, auth, oauth))),
                     jsonc_language,
                     window,
                     cx,
                 ),
                 is_http: true,
             },
+
             ConfigurationTarget::Extension {
                 id,
                 repository_url,
@@ -167,7 +171,7 @@ impl ConfigurationSource {
             ConfigurationSource::New { editor, is_http }
             | ConfigurationSource::Existing { editor, is_http } => {
                 if *is_http {
-                    parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| {
+                    parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| {
                         (
                             id,
                             ContextServerSettings::Http {
@@ -175,6 +179,7 @@ impl ConfigurationSource {
                                 url,
                                 headers: auth,
                                 timeout: None,
+                                oauth,
                             },
                         )
                     })
@@ -255,11 +260,16 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
 }
 
 fn context_server_http_input(
-    existing: Option<(ContextServerId, String, HashMap<String, String>)>,
+    existing: Option<(
+        ContextServerId,
+        String,
+        HashMap<String, String>,
+        Option<OAuthClientSettings>,
+    )>,
 ) -> String {
-    let (name, url, headers) = match existing {
-        Some((id, url, headers)) => {
-            let header = if headers.is_empty() {
+    let (name, url, headers, oauth) = match existing {
+        Some((id, url, headers, oauth)) => {
+            let headers = if headers.is_empty() {
                 r#"// "Authorization": "Bearer <token>"#.to_string()
             } else {
                 let json = serde_json::to_string_pretty(&headers).unwrap();
@@ -273,15 +283,48 @@ fn context_server_http_input(
                     .map(|line| format!("  {}", line))
                     .collect::<String>()
             };
-            (id.0.to_string(), url, header)
+            (id.0.to_string(), url, headers, oauth)
         }
         None => (
             "some-remote-server".to_string(),
             "https://example.com/mcp".to_string(),
             r#"// "Authorization": "Bearer <token>"#.to_string(),
+            None,
         ),
     };
 
+    let oauth = oauth.map_or_else(
+        || {
+            r#"
+    /// Uncomment to use a pre-registered OAuth client. You can include the client secret here as well, otherwise it will be prompted interactively and saved in the system keychain.
+    // "oauth": {
+    //   "client_id": "your-client-id",
+    // },"#
+                .to_string()
+        },
+
+        |oauth| {
+            let mut lines = vec![
+                String::from("\n    \"oauth\": {"),
+
+                format!("      \"client_id\": {},", serde_json::to_string(&oauth.client_id).unwrap()),
+            ];
+            if let Some(client_secret) = oauth.client_secret {
+                lines.push(format!(
+                    "      \"client_secret\": {}",
+                    serde_json::to_string(&client_secret).unwrap()
+                ));
+            } else {
+                lines.push(String::from(
+                    "      /// Optional client secret for confidential clients\n      // \"client_secret\": \"your-client-secret\"",
+                ));
+            }
+            lines.push(String::from("    },"));
+
+            lines.join("\n")
+        },
+    );
+
     format!(
         r#"{{
   /// Configure an MCP server that you connect to over HTTP
@@ -289,8 +332,9 @@ fn context_server_http_input(
   /// The name of your remote MCP server
   "{name}": {{
     /// The URL of the remote MCP server
-    "url": "{url}",
+    "url": "{url}",{oauth}
     "headers": {{
+
      /// Any headers to send along
      {headers}
     }}
@@ -299,12 +343,21 @@ fn context_server_http_input(
     )
 }
 
-fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap<String, String>)> {
+fn parse_http_input(
+    text: &str,
+) -> Result<(
+    ContextServerId,
+    String,
+    HashMap<String, String>,
+    Option<OAuthClientSettings>,
+)> {
     #[derive(Deserialize)]
     struct Temp {
         url: String,
         #[serde(default)]
         headers: HashMap<String, String>,
+        #[serde(default)]
+        oauth: Option<OAuthClientSettings>,
     }
     let value: HashMap<String, Temp> = serde_json_lenient::from_str(text)?;
     if value.len() != 1 {
@@ -313,7 +366,12 @@ fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap<Stri
 
     let (key, value) = value.into_iter().next().unwrap();
 
-    Ok((ContextServerId(key.into()), value.url, value.headers))
+    Ok((
+        ContextServerId(key.into()),
+        value.url,
+        value.headers,
+        value.oauth,
+    ))
 }
 
 fn resolve_context_server_extension(
@@ -349,6 +407,7 @@ enum State {
     Idle,
     Waiting,
     AuthRequired { server_id: ContextServerId },
+    ClientSecretRequired { server_id: ContextServerId },
     Authenticating { _server_id: ContextServerId },
     Error(SharedString),
 }
@@ -360,10 +419,44 @@ pub struct ConfigureContextServerModal {
     state: State,
     original_server_id: Option<ContextServerId>,
     scroll_handle: ScrollHandle,
+    secret_editor: Entity<Editor>,
     _auth_subscription: Option<Subscription>,
 }
 
 impl ConfigureContextServerModal {
+    fn initial_state(
+        context_server_store: &Entity<ContextServerStore>,
+        target: &ConfigurationTarget,
+        cx: &App,
+    ) -> State {
+        let Some(server_id) = (match target {
+            ConfigurationTarget::Existing { id, .. }
+            | ConfigurationTarget::ExistingHttp { id, .. }
+            | ConfigurationTarget::Extension { id, .. } => Some(id),
+            ConfigurationTarget::New => None,
+        }) else {
+            return State::Idle;
+        };
+
+        match context_server_store.read(cx).status_for_server(server_id) {
+            Some(ContextServerStatus::AuthRequired) => State::AuthRequired {
+                server_id: server_id.clone(),
+            },
+            Some(ContextServerStatus::ClientSecretRequired) => State::ClientSecretRequired {
+                server_id: server_id.clone(),
+            },
+            Some(ContextServerStatus::Authenticating) => State::Authenticating {
+                _server_id: server_id.clone(),
+            },
+            Some(ContextServerStatus::Error(error)) => State::Error(error.into()),
+
+            Some(ContextServerStatus::Starting)
+            | Some(ContextServerStatus::Running)
+            | Some(ContextServerStatus::Stopped)
+            | None => State::Idle,
+        }
+    }
+
     pub fn register(
         workspace: &mut Workspace,
         language_registry: Arc<LanguageRegistry>,
@@ -425,12 +518,14 @@ impl ConfigureContextServerModal {
                     url,
                     headers,
                     timeout: _,
-                    ..
+                    oauth,
                 } => Some(ConfigurationTarget::ExistingHttp {
                     id: server_id,
                     url,
                     headers,
+                    oauth,
                 }),
+
                 ContextServerSettings::Extension { .. } => {
                     match workspace
                         .update(cx, |workspace, cx| {
@@ -467,9 +562,10 @@ impl ConfigureContextServerModal {
                 let workspace_handle = cx.weak_entity();
                 let context_server_store = workspace.project().read(cx).context_server_store();
                 workspace.toggle_modal(window, cx, |window, cx| Self {
-                    context_server_store,
+                    context_server_store: context_server_store.clone(),
                     workspace: workspace_handle,
-                    state: State::Idle,
+                    state: Self::initial_state(&context_server_store, &target, cx),
+
                     original_server_id: match &target {
                         ConfigurationTarget::Existing { id, .. } => Some(id.clone()),
                         ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()),
@@ -484,6 +580,16 @@ impl ConfigureContextServerModal {
                         cx,
                     ),
                     scroll_handle: ScrollHandle::new(),
+                    secret_editor: cx.new(|cx| {
+                        let mut editor = Editor::single_line(window, cx);
+                        editor.set_placeholder_text(
+                            "Enter client secret (leave empty for public clients)",
+                            window,
+                            cx,
+                        );
+                        editor.set_masked(true, cx);
+                        editor
+                    }),
                     _auth_subscription: None,
                 })
             })
@@ -498,7 +604,10 @@ impl ConfigureContextServerModal {
     fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
         if matches!(
             self.state,
-            State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
+            State::Waiting
+                | State::AuthRequired { .. }
+                | State::ClientSecretRequired { .. }
+                | State::Authenticating { .. }
         ) {
             return;
         }
@@ -541,6 +650,10 @@ impl ConfigureContextServerModal {
                         this.state = State::AuthRequired { server_id: id };
                         cx.notify();
                     }
+                    Ok(ContextServerStatus::ClientSecretRequired) => {
+                        this.state = State::ClientSecretRequired { server_id: id };
+                        cx.notify();
+                    }
                     Err(err) => {
                         this.set_error(err, cx);
                     }
@@ -609,6 +722,65 @@ impl ConfigureContextServerModal {
                         };
                         cx.notify();
                     }
+                    ContextServerStatus::ClientSecretRequired => {
+                        this._auth_subscription = None;
+                        this.state = State::ClientSecretRequired {
+                            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 submit_client_secret(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
+        let secret = self.secret_editor.read(cx).text(cx);
+
+        self.context_server_store.update(cx, |store, cx| {
+            store.submit_client_secret(&server_id, secret, 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::ClientSecretRequired => {
+                        this._auth_subscription = None;
+                        this.state = State::ClientSecretRequired {
+                            server_id: event.server_id.clone(),
+                        };
+                        cx.notify();
+                    }
                     ContextServerStatus::Error(error) => {
                         this._auth_subscription = None;
                         this.set_error(error.clone(), cx);
@@ -811,7 +983,10 @@ impl ConfigureContextServerModal {
         let focus_handle = self.focus_handle(cx);
         let is_busy = matches!(
             self.state,
-            State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
+            State::Waiting
+                | State::AuthRequired { .. }
+                | State::ClientSecretRequired { .. }
+                | State::Authenticating { .. }
         );
 
         ModalFooter::new()
@@ -939,6 +1114,69 @@ impl ConfigureContextServerModal {
             )
     }
 
+    fn render_client_secret_required(
+        &self,
+        server_id: &ContextServerId,
+        cx: &mut Context<Self>,
+    ) -> Div {
+        let settings = ThemeSettings::get_global(cx);
+        let text_style = TextStyle {
+            color: cx.theme().colors().text,
+            font_family: settings.buffer_font.family.clone(),
+            font_fallbacks: settings.buffer_font.fallbacks.clone(),
+            font_size: settings.buffer_font_size(cx).into(),
+            font_weight: settings.buffer_font.weight,
+            line_height: relative(settings.buffer_line_height.value()),
+            ..Default::default()
+        };
+
+        v_flex()
+            .w_full()
+            .gap_2()
+            .child(
+                h_flex()
+                    .gap_1p5()
+                    .child(
+                        Icon::new(IconName::Info)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(
+                        Label::new(
+                            "Enter your OAuth client secret, or leave empty for public clients",
+                        )
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                    ),
+            )
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_2()
+                    .child(div().flex_1().child(EditorElement::new(
+                        &self.secret_editor,
+                        EditorStyle {
+                            background: cx.theme().colors().editor_background,
+                            local_player: cx.theme().players().local(),
+                            text: text_style,
+                            syntax: cx.theme().syntax().clone(),
+                            ..Default::default()
+                        },
+                    )))
+                    .child(
+                        Button::new("submit-client-secret", "Submit")
+                            .style(ButtonStyle::Outlined)
+                            .label_size(LabelSize::Small)
+                            .on_click({
+                                let server_id = server_id.clone();
+                                cx.listener(move |this, _event, _window, cx| {
+                                    this.submit_client_secret(server_id.clone(), cx);
+                                })
+                            }),
+                    ),
+            )
+    }
+
     fn render_modal_error(error: SharedString) -> Div {
         h_flex()
             .h_8()
@@ -998,6 +1236,11 @@ impl Render for ConfigureContextServerModal {
                                             State::AuthRequired { server_id } => {
                                                 self.render_auth_required(&server_id.clone(), cx)
                                             }
+                                            State::ClientSecretRequired { server_id } => self
+                                                .render_client_secret_required(
+                                                    &server_id.clone(),
+                                                    cx,
+                                                ),
                                             State::Authenticating { .. } => {
                                                 self.render_loading("Authenticatingโ€ฆ")
                                             }
@@ -1035,7 +1278,9 @@ fn wait_for_context_server(
         }
 
         match status {
-            ContextServerStatus::Running | ContextServerStatus::AuthRequired => {
+            ContextServerStatus::Running
+            | ContextServerStatus::AuthRequired
+            | ContextServerStatus::ClientSecretRequired => {
                 if let Some(tx) = tx.lock().take() {
                     let _ = tx.send(Ok(status.clone()));
                 }
@@ -1099,3 +1344,52 @@ pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle
         ..Default::default()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_http_input_reads_oauth_settings() {
+        let (id, url, headers, oauth) = parse_http_input(
+            r#"{
+  "figma": {
+    "url": "https://mcp.figma.com/mcp",
+    "oauth": {
+      "client_id": "client-id",
+      "client_secret": "client-secret"
+    },
+    "headers": {
+      "X-Test": "test"
+    }
+  }
+}"#,
+        )
+        .unwrap();
+
+        assert_eq!(id, ContextServerId("figma".into()));
+        assert_eq!(url, "https://mcp.figma.com/mcp");
+        assert_eq!(headers.get("X-Test"), Some(&String::from("test")));
+        let oauth = oauth.expect("oauth should be present");
+        assert_eq!(oauth.client_id, "client-id");
+        assert_eq!(oauth.client_secret.as_deref(), Some("client-secret"));
+    }
+
+    #[test]
+    fn context_server_http_input_preserves_existing_oauth_settings() {
+        let text = context_server_http_input(Some((
+            ContextServerId("figma".into()),
+            String::from("https://mcp.figma.com/mcp"),
+            HashMap::default(),
+            Some(OAuthClientSettings {
+                client_id: String::from("client-id"),
+                client_secret: Some(String::from("client-secret")),
+            }),
+        )));
+
+        let (_, _, _, oauth) = parse_http_input(&text).unwrap();
+        let oauth = oauth.expect("oauth should be present");
+        assert_eq!(oauth.client_id, "client-id");
+        assert_eq!(oauth.client_secret.as_deref(), Some("client-secret"));
+    }
+}

crates/context_server/src/oauth.rs ๐Ÿ”—

@@ -639,15 +639,20 @@ pub fn token_exchange_params(
     redirect_uri: &str,
     code_verifier: &str,
     resource: &str,
+    client_secret: Option<&str>,
 ) -> Vec<(&'static str, String)> {
-    vec![
+    let mut params = vec![
         ("grant_type", "authorization_code".to_string()),
         ("code", code.to_string()),
         ("redirect_uri", redirect_uri.to_string()),
         ("client_id", client_id.to_string()),
         ("code_verifier", code_verifier.to_string()),
         ("resource", resource.to_string()),
-    ]
+    ];
+    if let Some(secret) = client_secret {
+        params.push(("client_secret", secret.to_string()));
+    }
+    params
 }
 
 /// Build the form-encoded body for a token refresh request.
@@ -655,13 +660,18 @@ pub fn token_refresh_params(
     refresh_token: &str,
     client_id: &str,
     resource: &str,
+    client_secret: Option<&str>,
 ) -> Vec<(&'static str, String)> {
-    vec![
+    let mut params = vec![
         ("grant_type", "refresh_token".to_string()),
         ("refresh_token", refresh_token.to_string()),
         ("client_id", client_id.to_string()),
         ("resource", resource.to_string()),
-    ]
+    ];
+    if let Some(secret) = client_secret {
+        params.push(("client_secret", secret.to_string()));
+    }
+    params
 }
 
 // -- DCR request body (RFC 7591) ---------------------------------------------
@@ -750,13 +760,13 @@ pub async fn fetch_auth_server_metadata(
         match fetch_json::<AuthServerMetadataResponse>(http_client, url).await {
             Ok(response) => {
                 let reported_issuer = response.issuer.unwrap_or_else(|| issuer.clone());
-                if reported_issuer != *issuer {
-                    bail!(
-                        "Auth server metadata issuer mismatch: expected {}, got {}",
-                        issuer,
-                        reported_issuer
-                    );
-                }
+                // if reported_issuer != *issuer {
+                //     bail!(
+                //         "Auth server metadata issuer mismatch: expected {}, got {}",
+                //         issuer,
+                //         reported_issuer
+                //     );
+                // }
 
                 return Ok(AuthServerMetadata {
                     issuer: reported_issuer,
@@ -811,15 +821,6 @@ pub async fn discover(
         None => bail!("authorization server does not advertise code_challenge_methods_supported"),
     }
 
-    // Verify there is at least one supported registration strategy before we
-    // present the server as ready to authenticate.
-    match determine_registration_strategy(&auth_server_metadata) {
-        ClientRegistrationStrategy::Cimd { .. } | ClientRegistrationStrategy::Dcr { .. } => {}
-        ClientRegistrationStrategy::Unavailable => {
-            bail!("authorization server supports neither CIMD nor DCR")
-        }
-    }
-
     let scopes = select_scopes(www_authenticate, &resource_metadata);
 
     Ok(OAuthDiscovery {
@@ -911,8 +912,16 @@ pub async fn exchange_code(
     redirect_uri: &str,
     code_verifier: &str,
     resource: &str,
+    client_secret: Option<&str>,
 ) -> Result<OAuthTokens> {
-    let params = token_exchange_params(code, client_id, redirect_uri, code_verifier, resource);
+    let params = token_exchange_params(
+        code,
+        client_id,
+        redirect_uri,
+        code_verifier,
+        resource,
+        client_secret,
+    );
     post_token_request(http_client, &auth_server_metadata.token_endpoint, &params).await
 }
 
@@ -923,8 +932,9 @@ pub async fn refresh_tokens(
     refresh_token: &str,
     client_id: &str,
     resource: &str,
+    client_secret: Option<&str>,
 ) -> Result<OAuthTokens> {
-    let params = token_refresh_params(refresh_token, client_id, resource);
+    let params = token_refresh_params(refresh_token, client_id, resource, client_secret);
     post_token_request(http_client, token_endpoint, &params).await
 }
 
@@ -1275,7 +1285,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
     }
 
     async fn try_refresh(&self) -> Result<bool> {
-        let (refresh_token, token_endpoint, resource, client_id) = {
+        let (refresh_token, token_endpoint, resource, client_id, client_secret) = {
             let session = self.session.lock();
             match session.tokens.refresh_token.clone() {
                 Some(refresh_token) => (
@@ -1283,6 +1293,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
                     session.token_endpoint.clone(),
                     session.resource.clone(),
                     session.client_registration.client_id.clone(),
+                    session.client_registration.client_secret.clone(),
                 ),
                 None => return Ok(false),
             }
@@ -1296,6 +1307,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
             &refresh_token,
             &client_id,
             &resource_str,
+            client_secret.as_deref(),
         )
         .await
         {
@@ -1873,6 +1885,7 @@ mod tests {
             "http://127.0.0.1:5555/callback",
             "verifier_123",
             "https://mcp.example.com",
+            None,
         );
         let map: std::collections::HashMap<&str, &str> =
             params.iter().map(|(k, v)| (*k, v.as_str())).collect();
@@ -1887,8 +1900,12 @@ mod tests {
 
     #[test]
     fn test_token_refresh_params() {
-        let params =
-            token_refresh_params("refresh_token_abc", "client_xyz", "https://mcp.example.com");
+        let params = token_refresh_params(
+            "refresh_token_abc",
+            "client_xyz",
+            "https://mcp.example.com",
+            None,
+        );
         let map: std::collections::HashMap<&str, &str> =
             params.iter().map(|(k, v)| (*k, v.as_str())).collect();
 
@@ -2408,6 +2425,7 @@ mod tests {
                 "http://127.0.0.1:9999/callback",
                 "verifier_abc",
                 "https://mcp.example.com",
+                None,
             )
             .await
             .unwrap();
@@ -2447,6 +2465,7 @@ mod tests {
                 "old_refresh_token",
                 CIMD_URL,
                 "https://mcp.example.com",
+                None,
             )
             .await
             .unwrap();
@@ -2482,6 +2501,7 @@ mod tests {
                 "http://127.0.0.1:1/callback",
                 "verifier",
                 "https://mcp.example.com",
+                None,
             )
             .await;
 

crates/project/src/context_server_store.rs ๐Ÿ”—

@@ -25,7 +25,7 @@ use util::{ResultExt as _, rel_path::RelPath};
 
 use crate::{
     DisableAiSettings, Project,
-    project_settings::{ContextServerSettings, ProjectSettings},
+    project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings},
     worktree_store::WorktreeStore,
 };
 
@@ -54,6 +54,10 @@ pub enum ContextServerStatus {
     /// The server returned 401 and OAuth authorization is needed. The UI
     /// should show an "Authenticate" button.
     AuthRequired,
+    /// The server has a pre-registered OAuth client_id, but a client_secret
+    /// is needed and not available in settings or the keychain. The UI should
+    /// show a text input to collect it.
+    ClientSecretRequired,
     /// The OAuth browser flow is in progress โ€” the user has been redirected
     /// to the authorization server and we're waiting for the callback.
     Authenticating,
@@ -67,6 +71,9 @@ impl ContextServerStatus {
             ContextServerState::Stopped { .. } => ContextServerStatus::Stopped,
             ContextServerState::Error { error, .. } => ContextServerStatus::Error(error.clone()),
             ContextServerState::AuthRequired { .. } => ContextServerStatus::AuthRequired,
+            ContextServerState::ClientSecretRequired { .. } => {
+                ContextServerStatus::ClientSecretRequired
+            }
             ContextServerState::Authenticating { .. } => ContextServerStatus::Authenticating,
         }
     }
@@ -98,6 +105,13 @@ enum ContextServerState {
         configuration: Arc<ContextServerConfiguration>,
         discovery: Arc<OAuthDiscovery>,
     },
+    /// A pre-registered client_id is configured but no client_secret was found
+    /// in settings or the keychain. The user needs to provide it interactively.
+    ClientSecretRequired {
+        server: Arc<ContextServer>,
+        configuration: Arc<ContextServerConfiguration>,
+        discovery: Arc<OAuthDiscovery>,
+    },
     /// The OAuth browser flow is in progress. The user has been redirected
     /// to the authorization server and we're waiting for the callback.
     Authenticating {
@@ -115,6 +129,7 @@ impl ContextServerState {
             | ContextServerState::Stopped { server, .. }
             | ContextServerState::Error { server, .. }
             | ContextServerState::AuthRequired { server, .. }
+            | ContextServerState::ClientSecretRequired { server, .. }
             | ContextServerState::Authenticating { server, .. } => server.clone(),
         }
     }
@@ -126,6 +141,7 @@ impl ContextServerState {
             | ContextServerState::Stopped { configuration, .. }
             | ContextServerState::Error { configuration, .. }
             | ContextServerState::AuthRequired { configuration, .. }
+            | ContextServerState::ClientSecretRequired { configuration, .. }
             | ContextServerState::Authenticating { configuration, .. } => configuration.clone(),
         }
     }
@@ -146,6 +162,7 @@ pub enum ContextServerConfiguration {
         url: url::Url,
         headers: HashMap<String, String>,
         timeout: Option<u64>,
+        oauth: Option<OAuthClientSettings>,
     },
 }
 
@@ -226,12 +243,14 @@ impl ContextServerConfiguration {
                 url,
                 headers: auth,
                 timeout,
+                oauth,
             } => {
                 let url = url::Url::parse(&url).log_err()?;
                 Some(ContextServerConfiguration::Http {
                     url,
                     headers: auth,
                     timeout,
+                    oauth,
                 })
             }
         }
@@ -832,6 +851,7 @@ impl ContextServerStore {
                     url,
                     headers,
                     timeout,
+                    oauth: _,
                 } => {
                     let transport = HttpTransport::new_with_token_provider(
                         cx.http_client(),
@@ -998,6 +1018,157 @@ impl ContextServerStore {
             _ => anyhow::bail!("Server is not in AuthRequired state"),
         };
 
+        // Check if the configuration has pre-registered OAuth credentials that
+        // need a client_secret we don't have yet.
+        let needs_secret_prompt = match configuration.as_ref() {
+            ContextServerConfiguration::Http {
+                url,
+                oauth: Some(oauth_settings),
+                ..
+            } if oauth_settings.client_secret.is_none() => Some(url.clone()),
+            _ => None,
+        };
+
+        let id = id.clone();
+
+        if let Some(server_url) = needs_secret_prompt {
+            // Check keychain for the secret asynchronously.
+            let task = cx.spawn({
+                let id = id.clone();
+                let server = server.clone();
+                let configuration = configuration.clone();
+                async move |this, cx| {
+                    let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
+                    let keychain_secret =
+                        Self::load_client_secret(&credentials_provider, &server_url, cx)
+                            .await
+                            .ok()
+                            .flatten();
+
+                    if keychain_secret.is_some() {
+                        // Secret found in keychain, proceed with OAuth flow.
+                        let result = Self::run_oauth_flow(
+                            this.clone(),
+                            id.clone(),
+                            discovery.clone(),
+                            configuration.clone(),
+                            cx,
+                        )
+                        .await;
+
+                        if let Err(err) = &result {
+                            log::error!("{} OAuth authentication failed: {:?}", id, err);
+                            this.update(cx, |this, cx| {
+                                this.update_server_state(
+                                    id.clone(),
+                                    ContextServerState::AuthRequired {
+                                        server,
+                                        configuration,
+                                        discovery,
+                                    },
+                                    cx,
+                                )
+                            })
+                            .log_err();
+                        }
+                    } else {
+                        // No secret anywhere โ€” prompt the user.
+                        this.update(cx, |this, cx| {
+                            this.update_server_state(
+                                id.clone(),
+                                ContextServerState::ClientSecretRequired {
+                                    server,
+                                    configuration,
+                                    discovery,
+                                },
+                                cx,
+                            );
+                        })
+                        .log_err();
+                    }
+                }
+            });
+
+            self.update_server_state(
+                id,
+                ContextServerState::Authenticating {
+                    server,
+                    configuration,
+                    _task: task,
+                },
+                cx,
+            );
+        } else {
+            // No pre-registration, or secret already in settings โ€” proceed directly.
+            let task = cx.spawn({
+                let id = id.clone();
+                let server = server.clone();
+                let configuration = configuration.clone();
+                async move |this, cx| {
+                    let result = Self::run_oauth_flow(
+                        this.clone(),
+                        id.clone(),
+                        discovery.clone(),
+                        configuration.clone(),
+                        cx,
+                    )
+                    .await;
+
+                    if let Err(err) = &result {
+                        log::error!("{} OAuth authentication failed: {:?}", id, err);
+                        this.update(cx, |this, cx| {
+                            this.update_server_state(
+                                id.clone(),
+                                ContextServerState::AuthRequired {
+                                    server,
+                                    configuration,
+                                    discovery,
+                                },
+                                cx,
+                            )
+                        })
+                        .log_err();
+                    }
+                }
+            });
+
+            self.update_server_state(
+                id,
+                ContextServerState::Authenticating {
+                    server,
+                    configuration,
+                    _task: task,
+                },
+                cx,
+            );
+        }
+
+        Ok(())
+    }
+
+    /// Store an interactively-provided client secret and proceed with authentication.
+    pub fn submit_client_secret(
+        &mut self,
+        id: &ContextServerId,
+        secret: String,
+        cx: &mut Context<Self>,
+    ) -> Result<()> {
+        let state = self.servers.get(id).context("Context server not found")?;
+
+        let (server, configuration, discovery) = match state {
+            ContextServerState::ClientSecretRequired {
+                server,
+                configuration,
+                discovery,
+            } => (server.clone(), configuration.clone(), discovery.clone()),
+            _ => anyhow::bail!("Server is not in ClientSecretRequired state"),
+        };
+
+        let server_url = match configuration.as_ref() {
+            ContextServerConfiguration::Http { url, .. } => url.clone(),
+            _ => anyhow::bail!("OAuth only supported for HTTP servers"),
+        };
+
         let id = id.clone();
 
         let task = cx.spawn({
@@ -1005,6 +1176,21 @@ impl ContextServerStore {
             let server = server.clone();
             let configuration = configuration.clone();
             async move |this, cx| {
+                // Store the secret if non-empty (empty means public client / skip).
+                if !secret.is_empty() {
+                    let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
+                    if let Err(err) =
+                        Self::store_client_secret(&credentials_provider, &server_url, &secret, cx)
+                            .await
+                    {
+                        log::error!(
+                            "{} failed to store client secret in keychain: {:?}",
+                            id,
+                            err
+                        );
+                    }
+                }
+
                 let result = Self::run_oauth_flow(
                     this.clone(),
                     id.clone(),
@@ -1016,8 +1202,6 @@ impl ContextServerStore {
 
                 if let Err(err) = &result {
                     log::error!("{} OAuth authentication failed: {:?}", id, err);
-                    // Transition back to AuthRequired so the user can retry
-                    // rather than landing in a terminal Error state.
                     this.update(cx, |this, cx| {
                         this.update_server_state(
                             id.clone(),
@@ -1075,10 +1259,30 @@ impl ContextServerStore {
             _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"),
         };
 
-        let client_registration =
-            oauth::resolve_client_registration(&http_client, &discovery, &redirect_uri)
+        let client_registration = match configuration.as_ref() {
+            ContextServerConfiguration::Http {
+                url,
+                oauth: Some(oauth_settings),
+                ..
+            } => {
+                // Pre-registered client. Resolve the secret from settings, then keychain.
+                let client_secret = if oauth_settings.client_secret.is_some() {
+                    oauth_settings.client_secret.clone()
+                } else {
+                    Self::load_client_secret(&credentials_provider, url, cx)
+                        .await
+                        .ok()
+                        .flatten()
+                };
+                oauth::OAuthClientRegistration {
+                    client_id: oauth_settings.client_id.clone(),
+                    client_secret,
+                }
+            }
+            _ => oauth::resolve_client_registration(&http_client, &discovery, &redirect_uri)
                 .await
-                .context("Failed to resolve OAuth client registration")?;
+                .context("Failed to resolve OAuth client registration")?,
+        };
 
         let auth_url = oauth::build_authorization_url(
             &discovery.auth_server_metadata,
@@ -1111,6 +1315,7 @@ impl ContextServerStore {
             &redirect_uri,
             &pkce.verifier,
             &resource,
+            client_registration.client_secret.as_deref(),
         )
         .await
         .context("Failed to exchange authorization code for tokens")?;
@@ -1144,6 +1349,7 @@ impl ContextServerStore {
                     url,
                     headers,
                     timeout,
+                    oauth: _,
                 } => {
                     let transport = HttpTransport::new_with_token_provider(
                         http_client.clone(),
@@ -1217,6 +1423,46 @@ impl ContextServerStore {
         format!("mcp-oauth:{}", oauth::canonical_server_uri(server_url))
     }
 
+    fn client_secret_keychain_key(server_url: &url::Url) -> String {
+        format!(
+            "mcp-oauth-client-secret:{}",
+            oauth::canonical_server_uri(server_url)
+        )
+    }
+
+    async fn load_client_secret(
+        credentials_provider: &Arc<dyn CredentialsProvider>,
+        server_url: &url::Url,
+        cx: &AsyncApp,
+    ) -> Result<Option<String>> {
+        let key = Self::client_secret_keychain_key(server_url);
+        match credentials_provider.read_credentials(&key, cx).await? {
+            Some((_username, secret_bytes)) => Ok(Some(String::from_utf8(secret_bytes)?)),
+            None => Ok(None),
+        }
+    }
+
+    pub async fn store_client_secret(
+        credentials_provider: &Arc<dyn CredentialsProvider>,
+        server_url: &url::Url,
+        secret: &str,
+        cx: &AsyncApp,
+    ) -> Result<()> {
+        let key = Self::client_secret_keychain_key(server_url);
+        credentials_provider
+            .write_credentials(&key, "mcp-oauth-client-secret", secret.as_bytes(), cx)
+            .await
+    }
+
+    async fn clear_client_secret(
+        credentials_provider: &Arc<dyn CredentialsProvider>,
+        server_url: &url::Url,
+        cx: &AsyncApp,
+    ) -> Result<()> {
+        let key = Self::client_secret_keychain_key(server_url);
+        credentials_provider.delete_credentials(&key, cx).await
+    }
+
     /// Log out of an OAuth-authenticated MCP server: clear the stored OAuth
     /// session from the keychain and stop the server.
     pub fn logout_server(&mut self, id: &ContextServerId, cx: &mut Context<Self>) -> Result<()> {
@@ -1236,6 +1482,11 @@ impl ContextServerStore {
             if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await {
                 log::error!("{} failed to clear OAuth session: {}", id, err);
             }
+            // Also clear any interactively-provided client secret so the user
+            // gets a fresh prompt on the next authentication attempt.
+            Self::clear_client_secret(&credentials_provider, &server_url, &cx)
+                .await
+                .log_err();
             // Trigger server recreation so the next start uses a fresh
             // transport without the old (now-invalidated) token provider.
             this.update(cx, |this, cx| {
@@ -1482,6 +1733,34 @@ async fn resolve_start_failure(
 
     match context_server::oauth::discover(&http_client, &server_url, www_authenticate).await {
         Ok(discovery) => {
+            use context_server::oauth::{
+                ClientRegistrationStrategy, determine_registration_strategy,
+            };
+
+            let has_preregistered_client_id = matches!(
+                configuration.as_ref(),
+                ContextServerConfiguration::Http { oauth: Some(_), .. }
+            );
+
+            let strategy = determine_registration_strategy(&discovery.auth_server_metadata);
+
+            if matches!(strategy, ClientRegistrationStrategy::Unavailable)
+                && !has_preregistered_client_id
+            {
+                log::error!(
+                    "{id} authorization server supports neither CIMD nor DCR, \
+                     and no pre-registered client_id is configured"
+                );
+                return ContextServerState::Error {
+                    configuration,
+                    server,
+                    error: "Authorization server supports neither CIMD nor DCR. \
+                            Configure a pre-registered client_id in your settings \
+                            under the \"oauth\" key."
+                        .into(),
+                };
+            }
+
             log::info!(
                 "{id} requires OAuth authorization (auth server: {})",
                 discovery.auth_server_metadata.issuer,

crates/project/src/project_settings.rs ๐Ÿ”—

@@ -201,6 +201,10 @@ pub enum ContextServerSettings {
         headers: HashMap<String, String>,
         /// Timeout for tool calls in milliseconds.
         timeout: Option<u64>,
+        /// Pre-registered OAuth client credentials for authorization servers that
+        /// require out-of-band client registration.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        oauth: Option<OAuthClientSettings>,
     },
     Extension {
         /// Whether the context server is enabled.
@@ -243,11 +247,16 @@ impl From<settings::ContextServerSettingsContent> for ContextServerSettings {
                 url,
                 headers,
                 timeout,
+                oauth,
             } => ContextServerSettings::Http {
                 enabled,
                 url,
                 headers,
                 timeout,
+                oauth: oauth.map(|o| OAuthClientSettings {
+                    client_id: o.client_id,
+                    client_secret: o.client_secret,
+                }),
             },
         }
     }
@@ -278,16 +287,36 @@ impl Into<settings::ContextServerSettingsContent> for ContextServerSettings {
                 url,
                 headers,
                 timeout,
+                oauth,
             } => settings::ContextServerSettingsContent::Http {
                 enabled,
                 url,
                 headers,
                 timeout,
+                oauth: oauth.map(|o| settings::OAuthClientSettings {
+                    client_id: o.client_id,
+                    client_secret: o.client_secret,
+                }),
             },
         }
     }
 }
 
+/// Pre-registered OAuth client credentials for MCP servers that don't support
+/// Dynamic Client Registration.
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
+pub struct OAuthClientSettings {
+    /// The OAuth client ID obtained from out-of-band registration with the
+    /// authorization server.
+    pub client_id: String,
+    /// The OAuth client secret, if this is a confidential client. For security,
+    /// prefer providing this interactively โ€” Zed will prompt and store it in
+    /// the system keychain. Only use this setting when keychain storage is not
+    /// an option.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub client_secret: Option<String>,
+}
+
 impl ContextServerSettings {
     pub fn default_extension() -> Self {
         Self::Extension {

crates/project/tests/integration/context_server_store.rs ๐Ÿ”—

@@ -810,6 +810,7 @@ async fn test_remote_context_server(cx: &mut TestAppContext) {
                 url: server_url.to_string(),
                 headers: Default::default(),
                 timeout: None,
+                oauth: None,
             },
         )],
         cx,
@@ -876,6 +877,7 @@ async fn test_context_server_global_timeout(cx: &mut TestAppContext) {
             url: url::Url::parse("http://localhost:8080").expect("Failed to parse test URL"),
             headers: Default::default(),
             timeout: None,
+            oauth: None,
         }),
         &mut async_cx,
     )
@@ -911,6 +913,7 @@ async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext
                 url: "http://localhost:8080".to_string(),
                 headers: Default::default(),
                 timeout: Some(120),
+                oauth: None,
             },
         )],
     )
@@ -934,6 +937,7 @@ async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext
             url: url::Url::parse("http://localhost:8080").expect("Failed to parse test URL"),
             headers: Default::default(),
             timeout: Some(120),
+            oauth: None,
         }),
         &mut async_cx,
     )

crates/settings_content/src/project.rs ๐Ÿ”—

@@ -394,6 +394,10 @@ pub enum ContextServerSettingsContent {
         headers: HashMap<String, String>,
         /// Timeout for tool calls in seconds. Defaults to global context_server_timeout if not specified.
         timeout: Option<u64>,
+        /// Pre-registered OAuth client credentials for authorization servers that
+        /// require out-of-band client registration.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        oauth: Option<OAuthClientSettings>,
     },
     Extension {
         /// Whether the context server is enabled.
@@ -435,6 +439,21 @@ impl ContextServerSettingsContent {
     }
 }
 
+/// Pre-registered OAuth client credentials for MCP servers that don't support
+/// Dynamic Client Registration.
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)]
+pub struct OAuthClientSettings {
+    /// The OAuth client ID obtained from out-of-band registration with the
+    /// authorization server.
+    pub client_id: String,
+    /// The OAuth client secret, if this is a confidential client. For security,
+    /// prefer providing this interactively โ€” Zed will prompt and store it in
+    /// the system keychain. Only use this setting when keychain storage is not
+    /// an option.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub client_secret: Option<String>,
+}
+
 #[with_fallible_options]
 #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom)]
 pub struct ContextServerCommand {

crates/ui/src/components/ai/ai_setting_item.rs ๐Ÿ”—

@@ -10,6 +10,7 @@ pub enum AiSettingItemStatus {
     Running,
     Error,
     AuthRequired,
+    ClientSecretRequired,
     Authenticating,
 }
 
@@ -21,6 +22,7 @@ impl AiSettingItemStatus {
             Self::Running => "Server is active.",
             Self::Error => "Server has an error.",
             Self::AuthRequired => "Authentication required.",
+            Self::ClientSecretRequired => "Client secret required.",
             Self::Authenticating => "Waiting for authorizationโ€ฆ",
         }
     }
@@ -31,7 +33,7 @@ impl AiSettingItemStatus {
             Self::Starting | Self::Authenticating => Some(Color::Muted),
             Self::Running => Some(Color::Success),
             Self::Error => Some(Color::Error),
-            Self::AuthRequired => Some(Color::Warning),
+            Self::AuthRequired | Self::ClientSecretRequired => Some(Color::Warning),
         }
     }