diff --git a/README.md b/README.md index 1e7e66626d..98237887a3 100644 --- a/README.md +++ b/README.md @@ -1082,6 +1082,12 @@ forge mcp remove # Reload servers and rebuild caches forge mcp reload + +# Authenticate with a remote OAuth server +forge mcp login server_name + +# Remove one server's stored OAuth credentials +forge mcp logout server_name ``` Or manually create a `.mcp.json` file with the following structure: @@ -1094,13 +1100,23 @@ Or manually create a `.mcp.json` file with the following structure: "args": ["arg1", "arg2"], "env": { "ENV_VAR": "value" } }, - "another_server": { - "url": "http://localhost:3000/events" + "xquik": { + "url": "https://xquik.com/mcp", + "oauth": { + "scopes": ["mcp:tools"] + } } } } ``` +Forge forwards configured scopes during `mcp login`. Without an `oauth` +object, Forge discovers scopes from the server metadata. + +The Xquik example adds authenticated X (Twitter) search and automation tools. +See the [Xquik MCP documentation](https://docs.xquik.com/mcp/overview) for its +published server contract. + MCP configurations are read from two locations (project-local takes precedence): 1. **Project-local:** `.mcp.json` in your project directory diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 5a2a5217fe..c0ab7f5d3b 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -257,8 +257,8 @@ pub trait API: Sync + Send { data_parameters: DataGenerationParameters, ) -> Result>>; - /// Authenticate with an MCP server via OAuth flow - async fn mcp_auth(&self, server_url: &str) -> Result<()>; + /// Authenticate with an MCP server via OAuth using configured scopes. + async fn mcp_auth(&self, server_url: &str, scopes: &[String]) -> Result<()>; /// Remove stored OAuth credentials for an MCP server (or all servers) async fn mcp_logout(&self, server_url: Option<&str>) -> Result<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index a056705761..3518c599fb 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -421,9 +421,9 @@ impl< self.services.get_provider(model_config.provider).await } - async fn mcp_auth(&self, server_url: &str) -> Result<()> { + async fn mcp_auth(&self, server_url: &str, scopes: &[String]) -> Result<()> { let env = self.services.get_environment().clone(); - forge_infra::mcp_auth(server_url, &env).await + forge_infra::mcp_auth(server_url, scopes, &env).await } async fn mcp_logout(&self, server_url: Option<&str>) -> Result<()> { diff --git a/crates/forge_domain/src/mcp.rs b/crates/forge_domain/src/mcp.rs index a065579f71..7a1b5fb840 100644 --- a/crates/forge_domain/src/mcp.rs +++ b/crates/forge_domain/src/mcp.rs @@ -140,6 +140,15 @@ impl McpHttpServer { _ => None, } } + + /// Returns explicitly configured OAuth scopes. + /// + /// An empty slice allows the OAuth client to discover scopes from server + /// metadata. + pub fn oauth_scopes(&self) -> &[String] { + self.oauth_config() + .map_or(&[], |config| config.scopes.as_slice()) + } } /// Represents the OAuth setting for an MCP server. @@ -607,6 +616,44 @@ mod tests { } } + #[test] + fn test_http_server_returns_configured_oauth_scopes() { + use pretty_assertions::assert_eq; + + let fixture: McpConfig = serde_json::from_str( + r#"{ + "mcpServers": { + "remote": { + "url": "https://mcp.example.com", + "oauth": { "scopes": ["mcp:tools"] } + } + } + }"#, + ) + .unwrap(); + let actual = match fixture.mcp_servers.get(&"remote".to_string().into()) { + Some(McpServerConfig::Http(server)) => server.oauth_scopes(), + _ => panic!("Expected Http variant"), + }; + let expected = vec!["mcp:tools".to_string()]; + + assert_eq!(actual, expected); + } + + #[test] + fn test_http_server_returns_no_oauth_scopes_for_auto_detection() { + use pretty_assertions::assert_eq; + + let fixture = McpServerConfig::new_http("https://mcp.example.com"); + let actual = match &fixture { + McpServerConfig::Http(server) => server.oauth_scopes(), + _ => panic!("Expected Http variant"), + }; + let expected: Vec = Vec::new(); + + assert_eq!(actual, expected); + } + #[test] fn test_server_type() { use fake::{Fake, Faker}; diff --git a/crates/forge_infra/src/mcp_client.rs b/crates/forge_infra/src/mcp_client.rs index 1c0b5db47a..a2e2d837c0 100644 --- a/crates/forge_infra/src/mcp_client.rs +++ b/crates/forge_infra/src/mcp_client.rs @@ -574,8 +574,13 @@ fn build_header_map( /// /// # Arguments /// * `server_url` - The URL of the MCP server to authenticate with +/// * `scopes` - Explicit OAuth scopes, or an empty slice for discovery /// * `env` - The environment for file system paths -pub async fn mcp_auth(server_url: &str, env: &Environment) -> anyhow::Result<()> { +pub async fn mcp_auth( + server_url: &str, + scopes: &[String], + env: &Environment, +) -> anyhow::Result<()> { use rmcp::transport::auth::{CredentialStore, OAuthState}; use crate::auth::McpTokenStorage; @@ -587,8 +592,9 @@ pub async fn mcp_auth(server_url: &str, env: &Environment) -> anyhow::Result<()> let redirect_uri = "http://127.0.0.1:8765/callback"; + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); oauth_state - .start_authorization(&[], redirect_uri, Some("Forge")) + .start_authorization(&scope_refs, redirect_uri, Some("Forge")) .await .map_err(|e| anyhow::anyhow!("OAuth authorization flow failed: {}", e))?; diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index a517907b24..53c69df090 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1057,7 +1057,7 @@ impl A + Send + Sync> UI let _ = self.api.mcp_logout(Some(&http.url)).await; // Run the OAuth flow (opens browser, waits for callback) - match self.api.mcp_auth(&http.url).await { + match self.api.mcp_auth(&http.url, http.oauth_scopes()).await { Ok(()) => { self.writeln_title(TitleFormat::info(format!( "Successfully authenticated with MCP server '{}'",