Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/forge_api/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@ pub trait API: Sync + Send {
data_parameters: DataGenerationParameters,
) -> Result<BoxStream<'static, Result<serde_json::Value, anyhow::Error>>>;

/// 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<()>;
Expand Down
4 changes: 2 additions & 2 deletions crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
47 changes: 47 additions & 0 deletions crates/forge_domain/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> = Vec::new();

assert_eq!(actual, expected);
}

#[test]
fn test_server_type() {
use fake::{Fake, Faker};
Expand Down
10 changes: 8 additions & 2 deletions crates/forge_infra/src/mcp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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))?;

Expand Down
2 changes: 1 addition & 1 deletion crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1057,7 +1057,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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 '{}'",
Expand Down
Loading