Skip to content

fix(mcp): open MCP OAuth in same browser as gateway - #951

Merged
zmanian merged 10 commits into
stagingfrom
fix/mcp-oauth-gateway-browser
Mar 12, 2026
Merged

zmanian merged 10 commits into
stagingfrom
fix/mcp-oauth-gateway-browser

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Summary

  • When MCP OAuth is triggered from the web gateway, the auth URL was opened via open::that() which launches the OS default browser — not the browser already running the gateway UI
  • Now in gateway mode, MCP OAuth follows the same pattern as WASM extensions: the auth URL is returned via SSE to the frontend, which opens it with window.open() in the same browser
  • Adds RFC 8707 resource parameter to the gateway token exchange path, scoping issued tokens to the correct MCP server
  • Adds resource field to PendingOAuthFlow so the gateway callback handler can include it in token exchange

Changes

  • src/extensions/manager.rs: auth_mcp() checks use_gateway_callback() first; auth_mcp_build_url() rewritten to store PendingOAuthFlow in gateway mode with proper CSRF state, PKCE, platform routing, and resource parameter
  • src/cli/oauth_defaults.rs: Added resource field to PendingOAuthFlow; added exchange_oauth_code_with_resource() for RFC 8707 support
  • src/channels/web/server.rs: Gateway callback handler passes flow.resource through to token exchange

Test plan

  • cargo clippy — zero warnings
  • cargo test — 2946 tests passing
  • New test: test_build_oauth_url_includes_resource_via_extra_params verifies resource param is URL-encoded in auth URL
  • Manual: trigger MCP OAuth from web gateway → verify auth opens in same browser
  • Manual: complete OAuth flow → verify token exchange includes resource param

Closes #299

🤖 Generated with Claude Code

…owser

When MCP OAuth is triggered from the web gateway, the auth URL was being
opened via `open::that()` which launches the OS default browser instead
of the browser already running the gateway UI. This changes the MCP OAuth
flow to use the same gateway callback pattern as WASM extensions: in
gateway mode, the auth URL is returned to the frontend via SSE and opened
with `window.open()`, keeping the user in the same browser.

Also adds RFC 8707 `resource` parameter support to the gateway token
exchange path, scoping issued tokens to the correct MCP server.

Closes #299

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 11, 2026 15:51
@github-actions github-actions Bot added scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: extensions Extension management size: L 200-499 changed lines labels Mar 11, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the MCP OAuth authentication process, particularly for users interacting via the web gateway. The primary goal is to provide a more seamless and secure authentication experience by ensuring that OAuth flows remain within the user's current browser session and that tokens are precisely scoped to their intended resources. This change improves both usability and security for MCP integrations.

Highlights

  • Improved MCP OAuth User Experience: MCP OAuth flows triggered from the web gateway now open the authentication URL in the same browser window, aligning with the behavior of WASM extensions.
  • RFC 8707 Resource Parameter Support: The gateway token exchange path now includes the RFC 8707 resource parameter, ensuring issued tokens are correctly scoped to the specific MCP server.
  • Enhanced PendingOAuthFlow: The PendingOAuthFlow struct has been updated to include a resource field, allowing the gateway callback handler to pass this parameter during token exchange.
Changelog
  • src/channels/web/server.rs
    • Updated the OAuth callback handler to utilize the new exchange_oauth_code_with_resource() function, passing the resource parameter.
    • Initialized the resource field to None in test instances of PendingOAuthFlow.
  • src/cli/oauth_defaults.rs
    • Introduced exchange_oauth_code_with_resource() to support an optional RFC 8707 resource parameter during token exchange.
    • Added a resource field to the PendingOAuthFlow struct.
    • Included a new test, test_build_oauth_url_includes_resource_via_extra_params, to verify the correct URL encoding and inclusion of the resource parameter in authorization URLs.
  • src/extensions/manager.rs
    • Modified auth_mcp() to prioritize gateway callback logic and adjusted error handling for OAuth support.
    • Rewrote auth_mcp_build_url() to differentiate between gateway and local modes, handling dynamic client registration, resource parameter inclusion, and storing PendingOAuthFlow for gateway mode.
    • Updated imports and documentation related to MCP OAuth authentication.
Activity
  • cargo clippy passed with zero warnings.
  • cargo test passed with 2946 tests.
  • A new test, test_build_oauth_url_includes_resource_via_extra_params, was added to verify the resource parameter in auth URLs.
  • Manual tests are planned to verify MCP OAuth from the web gateway opens in the same browser and that the token exchange includes the resource parameter.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@github-actions github-actions Bot added risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Mar 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates MCP OAuth when Ironclaw is running behind the web gateway so authorization opens in the same browser session as the gateway UI, and adds RFC 8707 resource scoping support to MCP token exchange.

Changes:

  • In gateway mode, MCP OAuth now returns an auth URL to the frontend (via SSE) instead of launching the OS default browser.
  • Adds resource to PendingOAuthFlow and introduces exchange_oauth_code_with_resource() to support RFC 8707 during token exchange.
  • Updates the web gateway OAuth callback handler to pass resource into the non-proxy token exchange path.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/extensions/manager.rs Adds gateway-mode MCP OAuth URL construction and stores a pending gateway OAuth flow (incl. resource).
src/cli/oauth_defaults.rs Adds resource to pending flow state and introduces a token exchange helper that can include RFC 8707 resource.
src/channels/web/server.rs Passes flow.resource into the direct token exchange function from the gateway callback handler.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/extensions/manager.rs Outdated
Comment on lines +1722 to +1731
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In gateway mode this treats any error from auth_mcp_build_url() as “server does not support OAuth” and falls back to manual token entry. That will mask real failures (e.g., discovery/network/DCR errors) and makes debugging much harder. Consider only falling back on a specific “not supported” condition and otherwise returning ExtensionError::AuthFailed (or include the underlying error in the instructions).

Suggested change
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
))
}
Err(e) => Err(ExtensionError::AuthFailed(e.to_string())),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Now only falls back to manual token entry on AuthNotSupported; real errors (discovery/network/DCR) are propagated as ExtensionError::AuthFailed.

Comment thread src/extensions/manager.rs Outdated
Comment on lines 1784 to 1787
@@ -1766,58 +1786,141 @@ impl ExtensionManager {
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discover_full_oauth_metadata(&server.url) is called unconditionally, which ignores any explicitly configured OAuth endpoints on server.oauth (authorization_url/token_url). For servers that provide endpoints but don’t support discovery (or return 404 for .well-known), this will fail even though config is sufficient. Consider using discover_oauth_endpoints(server) when server.oauth.is_some() (and only using full metadata discovery for the DCR/no-config path), and source authorization_endpoint/token_endpoint from those resolved endpoints.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. The current implementation already uses discover_oauth_endpoints(server) when server.oauth.is_some() in the CLI path. The gateway auth_mcp_build_url always uses full metadata discovery because it needs DCR registration_endpoint. Servers with pre-configured OAuth that don't support discovery will now get a proper error propagated (via the AuthNotSupported fix) rather than a misleading fallback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The gateway path uses full metadata discovery because it needs the DCR registration_endpoint. Servers with pre-configured OAuth that don't support discovery now get a proper error propagated (via the AuthNotSupported fix in b7376b7) rather than a misleading fallback.

Comment on lines 534 to +538
)
.await
.map_err(|e| e.to_string())?
} else {
oauth_defaults::exchange_oauth_code(
oauth_defaults::exchange_oauth_code_with_resource(

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When IRONCLAW_OAUTH_EXCHANGE_URL is set, the token exchange goes through exchange_via_proxy(...), but the flow’s RFC 8707 resource is not forwarded (only the direct exchange path passes it). In hosted/gateway deployments that use the proxy, MCP tokens won’t be scoped as intended. Consider extending exchange_via_proxy (and the proxy endpoint) to accept/send resource, or bypass the proxy for flows that require resource.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. When flow.resource.is_some(), we now bypass the exchange proxy and use direct token exchange with the resource parameter.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. When flow.resource.is_some(), we now bypass the exchange proxy and use direct token exchange with the resource parameter.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for the RFC 8707 resource parameter in OAuth flows for Multi-Cloud Platform (MCP) servers. Key changes include refactoring the exchange_oauth_code function to exchange_oauth_code_with_resource to accept an optional resource parameter, adding this parameter to the PendingOAuthFlow struct, and updating the ExtensionManager to handle OAuth authorization differently for "gateway mode" and "local/CLI mode", ensuring the resource is included in authorization URLs and stored for callback processing in gateway mode. Review comments suggest adding further explanations to improve code readability and maintainability, specifically regarding the usage of exchange_oauth_code_with_resource in an else block, the delegation pattern in exchange_oauth_code, and the purpose of the resource parameter in token parameters.

Note: Security Review did not run due to the size of the PR.

Comment on lines +538 to +546
oauth_defaults::exchange_oauth_code_with_resource(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
flow.resource.as_deref(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

medium: Consider adding a comment explaining why exchange_oauth_code_with_resource is being called instead of exchange_oauth_code in this else block. This will improve code readability and maintainability.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added clarifying comment in b7376b7.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added clarifying comment in b7376b7.

Comment thread src/cli/oauth_defaults.rs
Comment on lines +175 to +186
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
exchange_oauth_code_with_resource(
token_url,
client_id,
client_secret,
code,
redirect_uri,
code_verifier,
access_token_field,
None,
)
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

medium: This function is delegating to exchange_oauth_code_with_resource with None as the resource. It might be clearer to inline the exchange_oauth_code_with_resource call directly here, or add a comment explaining why this delegation is preferred.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added clarifying comment in b7376b7.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added clarifying comment in b7376b7.

Comment thread src/cli/oauth_defaults.rs
Comment on lines +214 to +216
if let Some(resource) = resource {
token_params.push(("resource", resource.to_string()));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

medium: Consider adding a comment explaining the purpose of adding the resource parameter to the token parameters. This will improve code clarity and maintainability.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added clarifying comment in b7376b7.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added RFC 8707 resource parameter comment in b7376b7.

@claude

claude Bot commented Mar 11, 2026

Copy link
Copy Markdown

Code review

Found 7 issues:

  1. [HIGH:92] Missing client_secret from DCR registration — code discards registration.client_secret and sets it to None, but DCR servers return this for token exchange validation

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/extensions/manager.rs#L1818-L1830

  1. [MEDIUM:75] String replacement overhead on auth URL state parameter — uses String::replace() O(n) scan with temporary formatted strings for platform state routing

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/extensions/manager.rs#L1844-L1851

  1. [MEDIUM:70] Unbounded pending_oauth_flows registry growth — HashMap has no capacity limit, relies on lazy cleanup that only runs on new auth attempts; abandoned flows accumulate

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/cli/oauth_defaults.rs#L441-L444

  1. [MEDIUM:65] Function API fragmentation — exchange_oauth_code() delegates to exchange_oauth_code_with_resource(); should be single parameterized function

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/cli/oauth_defaults.rs#L167-L216

  1. [MEDIUM:60] Stringly-typed OAuth mode — uses magic strings "gateway"/"local" instead of type-safe enum, violates project pattern for strong types

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/extensions/manager.rs#L1836-L1920

  1. [MEDIUM:55] Large function complexity — auth_mcp_build_url() grew to ~130 lines with nested conditionals; should decompose into helper functions

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/extensions/manager.rs#L1776-L1920

  1. [LOW:50] Missing test fixtures for resource parameter Some() case — tests only cover resource: None

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/channels/web/server.rs#L2854-L2867

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Mar 11, 2026

Copy link
Copy Markdown

Code review

Found 4 issues:

  1. [MEDIUM:75] Inconsistent module organization: OAuth helpers split across tools::mcp::auth and cli::oauth_defaults, violating CLAUDE.md's "module-owned initialization" principle. Generic OAuth logic should be in a neutral layer to prevent coupling between CLI and extension systems.

builtin_value.map(String::from)
}
/// Start the OAuth browser flow for a WASM tool.
///
/// Binds a callback listener, builds the authorization URL, spawns a background
/// task to wait for the callback and exchange the code, then returns the auth URL
/// immediately so the web UI can open it.
async fn start_wasm_oauth(
&self,
name: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> Result<AuthResult, String> {
use crate::cli::oauth_defaults;
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);

2275    if oauth_defaults::use_gateway_callback() {
2276        // Gateway mode
2277        oauth_defaults::sweep_expired_flows(&self.pending_oauth_flows).await;
2278        
2279        let platform_state = oauth_defaults::build_platform_state(&expected_state);
2280        let auth_url = oauth_result.url;  // Would be modified below
2281    }
  1. [MEDIUM:65] String manipulation instead of type-driven URL handling: Uses .replace() on the full authorization URL string instead of parsing with the available url::Url crate, violating CLAUDE.md's "prefer strong types over strings" guidance. While practical collision risk is low for cryptographic state values, this creates fragile code.

auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> Result<AuthResult, String> {
use crate::cli::oauth_defaults;

2286    let auth_url = if platform_state != expected_state {
2287        auth_url.replace(
2288            &format!("state={}", urlencoding::encode(&expected_state)),
2289            &format!("state={}", urlencoding::encode(&platform_state)),
2290        )
2291    } else {
2292        auth_url
  1. [MEDIUM:25] Redundant encoding and allocation in platform state substitution: Two format!() calls and two urlencoding::encode() calls are executed inside the .replace() operation, creating unnecessary temporary allocations. Pre-computing both encoded strings would eliminate the redundancy.

auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> Result<AuthResult, String> {
use crate::cli::oauth_defaults;

2285    let platform_state = oauth_defaults::build_platform_state(&expected_state);
2286    let auth_url = if platform_state != expected_state {
2287        auth_url.replace(  // Two format! + two encode! calls inline
2288            &format!("state={}", urlencoding::encode(&expected_state)),
2289            &format!("state={}", urlencoding::encode(&platform_state)),
2290        )
  1. [MEDIUM:50] Multiple sequential async lock acquisitions: Gateway flow acquires write locks on pending_oauth_flows (line 2280), read lock on sse_sender (line 2310), then write locks on pending_oauth_flows again (lines 2317-2320) and pending_auth (lines 2323-2331). While user-initiated flows rarely run concurrently, these could be batched for better concurrency semantics.

///
/// Binds a callback listener, builds the authorization URL, spawns a background
/// task to wait for the callback and exchange the code, then returns the auth URL
/// immediately so the web UI can open it.
async fn start_wasm_oauth(
&self,
name: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> Result<AuthResult, String> {
use crate::cli::oauth_defaults;
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
// Find setup secret names for client_id and client_secret from capabilities.
// These are the actual names used in the Setup tab (e.g., "google_oauth_client_id"),
// which may differ from "{secret_name}_client_id".
let (setup_client_id_entry, setup_client_secret_entry) =
self.find_setup_credential_names(name).await;
let setup_client_id_name = setup_client_id_entry.map(|(n, _)| n);
let setup_client_secret_name = setup_client_secret_entry.map(|(n, _)| n);
// Resolve client_id: setup secrets → inline → env var → builtin
let client_id = self
.resolve_oauth_credential(
&oauth.client_id,
&oauth.client_id_env,
builtin.as_ref().map(|c| c.client_id),
setup_client_id_name.as_deref(),
)
.await
.ok_or_else(|| {
let env_name = oauth
.client_id_env
.as_deref()
.unwrap_or("the client_id env var");
let mut msg = format!(
"OAuth client_id not configured for '{}'. \
Enter it in the Setup tab or set {} env var",
name, env_name
);
// Only mention the Google-specific build flag for Google providers
if auth.secret_name.to_lowercase().contains("google") {
msg.push_str(", or build with IRONCLAW_GOOGLE_CLIENT_ID");
}
msg.push('.');
msg
})?;
// Resolve client_secret (optional for PKCE-only flows)
let client_secret = self
.resolve_oauth_credential(

2280    oauth_defaults::sweep_expired_flows(&self.pending_oauth_flows).await;  // Lock 1
2310    let sse_sender = self.sse_sender.read().await.clone();  // Lock 2
2317    self.pending_oauth_flows.write().await.insert(...);  // Lock 1 again
2323    self.pending_auth.write().await.insert(...);  // Lock 3

@claude

claude Bot commented Mar 11, 2026

Copy link
Copy Markdown

Code review

Found 2 issues:

  1. [MEDIUM:75] Fragile string-based URL parameter substitution in oauth_result.url.replace() — Should use proper URL parsing with the url crate's query_pairs_mut() instead of String::replace() to modify the state parameter. String replacement is fragile and creates unnecessary intermediate allocations.

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/extensions/manager.rs#L1849-L1853

  1. [MEDIUM:78] Function signature proliferation — Creates new exchange_oauth_code_with_resource() function with the old exchange_oauth_code() as a thin wrapper. Should instead add optional resource: Option<&str> parameter directly to exchange_oauth_code() and remove the wrapper to avoid duplicating the API surface.

https://github.com/anthropics/ironclaw/blob/1a9b2d05bd0c4cbe33e1ac6040b4e13dca16f986/src/cli/oauth_defaults.rs#L172-L188

…efresh

The gateway callback handler stored access and refresh tokens but not
the DCR client_id. When the token expired, refresh failed with "No
client ID found" because get_client_id() could not find it in secrets.

Adds client_id_secret_name to PendingOAuthFlow so the gateway callback
handler persists the client_id alongside the tokens, matching the
behavior of the CLI flow in authorize_mcp_server().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 11, 2026 21:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/channels/web/server.rs Outdated
Comment on lines +581 to +582
.as_ref()
.map(|p| format!("mcp:{}", p))

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provider string used for persisting the MCP client_id secret is built as format!("mcp:{}", p) where p comes from flow.provider. If PendingOAuthFlow.provider is updated to already include the mcp: prefix (to match MCP token storage), this will double-prefix. Consider storing a fully-qualified provider string in the flow (e.g., mcp:{name}) and using it directly here (or otherwise ensure this is consistent with how MCP access/refresh tokens are stored).

Suggested change
.as_ref()
.map(|p| format!("mcp:{}", p))
.clone()

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. flow.provider now uses the mcp: prefix directly, and the client_id secret storage uses flow.provider as-is (no double-prefix).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. flow.provider now uses the mcp: prefix directly, and client_id secret storage uses flow.provider as-is (no double-prefix).

Comment thread src/cli/oauth_defaults.rs Outdated
Comment on lines +1056 to +1075
#[test]
fn test_pending_flow_mcp_carries_client_id_secret_name() {
// MCP flows must set client_id_secret_name so refresh can find the client_id
let mcp_secret_name = format!("mcp_{}_client_id", "notion");
assert_eq!(mcp_secret_name, "mcp_notion_client_id");

// Simulate what auth_mcp_build_url sets for gateway mode
let has_secret_name = Some(mcp_secret_name.clone());
assert!(
has_secret_name.is_some(),
"MCP PendingOAuthFlow must set client_id_secret_name for token refresh"
);

// WASM flows should not set it
let wasm_secret_name: Option<String> = None;
assert!(
wasm_secret_name.is_none(),
"WASM PendingOAuthFlow should not set client_id_secret_name"
);
}

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_pending_flow_mcp_carries_client_id_secret_name is effectively asserting that Some(String) is Some, without exercising any production logic (it doesn’t construct a PendingOAuthFlow or call auth_mcp_build_url). This test won’t catch regressions. Suggest either removing it or rewriting it to validate behavior via the relevant builder/flow-construction API (e.g., an integration-style test around the function that populates PendingOAuthFlow).

Suggested change
#[test]
fn test_pending_flow_mcp_carries_client_id_secret_name() {
// MCP flows must set client_id_secret_name so refresh can find the client_id
let mcp_secret_name = format!("mcp_{}_client_id", "notion");
assert_eq!(mcp_secret_name, "mcp_notion_client_id");
// Simulate what auth_mcp_build_url sets for gateway mode
let has_secret_name = Some(mcp_secret_name.clone());
assert!(
has_secret_name.is_some(),
"MCP PendingOAuthFlow must set client_id_secret_name for token refresh"
);
// WASM flows should not set it
let wasm_secret_name: Option<String> = None;
assert!(
wasm_secret_name.is_none(),
"WASM PendingOAuthFlow should not set client_id_secret_name"
);
}
// NOTE: Previous test here only asserted trivial properties of local values
// and did not exercise production logic. It was removed to avoid a misleading,
// non-meaningful test that could not catch regressions.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b7376b7.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b7376b7.

Comment thread src/extensions/manager.rs Outdated
code_verifier,
access_token_field: "access_token".to_string(),
secret_name: server.token_secret_name(),
provider: Some(name.to_string()),

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In gateway mode, PendingOAuthFlow.provider is set to Some(name.to_string()), but MCP secrets stored by the CLI flow use provider mcp:{server_name} (see store_tokens() in src/tools/mcp/auth.rs). This mismatch can break provider-based filtering/grouping in the secrets store. Consider setting provider here to Some(format!("mcp:{}", name)) (and then use the same provider string consistently when persisting the client_id secret in the callback handler).

Suggested change
provider: Some(name.to_string()),
provider: Some(format!("mcp:{}", name)),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Provider is now set to format!("mcp:{}", name) in the flow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Provider is now format\!("mcp:{}", name) in the flow.

Comment thread src/extensions/manager.rs Outdated
sse_sender: self.sse_sender.read().await.clone(),
gateway_token: self.gateway_token.clone(),
resource: Some(resource),
client_id_secret_name: Some(format!("mcp_{}_client_id", name)),

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client_id_secret_name is always set for gateway MCP flows, even when the server has pre-configured OAuth (server.oauth.is_some()). Token refresh only needs the persisted client_id for DCR flows (CLI path stores it only when server_config.oauth.is_none()). Consider setting client_id_secret_name only for DCR cases to avoid writing unnecessary secrets.

Suggested change
client_id_secret_name: Some(format!("mcp_{}_client_id", name)),
client_id_secret_name: if server.oauth.is_none() {
Some(format!("mcp_{}_client_id", name))
} else {
None
},

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Now uses server.client_id_secret_name() only when server.oauth.is_none() (DCR flows).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Now uses server.client_id_secret_name() only when server.oauth.is_none() (DCR flows).

Comment thread src/extensions/manager.rs Outdated
Comment on lines +1719 to +1732
if crate::cli::oauth_defaults::use_gateway_callback() {
return match self.auth_mcp_build_url(name, &server).await {
Ok(result) => Ok(result),
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),
};

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In gateway mode, any error from auth_mcp_build_url() is treated as “server does not support OAuth” and the user is prompted for a manual token. This can hide actionable failures (e.g., discovery network errors, unsafe endpoints, DCR failure) behind a misleading message. Consider propagating the underlying error (or including it in the awaiting_token message) and only falling back to manual token on a confirmed NotSupported-style condition.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Comment on lines 524 to +546
let result: Result<(), String> = async {
let token_response = if let Some(ref proxy_url) = exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(
proxy_url,
gateway_token,
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
.await
.map_err(|e| e.to_string())?
} else {
oauth_defaults::exchange_oauth_code(
oauth_defaults::exchange_oauth_code_with_resource(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
flow.resource.as_deref(),

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When IRONCLAW_OAUTH_EXCHANGE_URL is set, the callback always uses exchange_via_proxy(), which currently does not accept/forward the RFC 8707 resource parameter (and doesn’t use flow.token_url). For MCP flows this likely drops resource (and may route token exchange to a proxy that can’t handle MCP providers). Consider bypassing the proxy when flow.resource.is_some() (or extending the proxy request API to include resource / provider routing).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on 2919349142.

ilblackdragon and others added 2 commits March 11, 2026 18:27
activate_mcp() returned ActivationFailed for all errors including 401
auth responses, so the activate handler never triggered the OAuth flow.
Now 401/auth errors return AuthRequired, which the handler detects and
redirects to the OAuth flow — matching the WASM extension pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add explicit gateway_mode flag on ExtensionManager (set at startup by
  web gateway) so MCP OAuth returns auth URLs to the frontend instead of
  calling open::that() on the server machine.
- Auto-activate extensions after successful OAuth callback so the UI
  transitions from "Activate" to "Active" without a second click.
- Send ApprovalNeeded status (not generic "Awaiting approval") from
  thread_ops.rs for all three NeedApproval paths so the web UI shows
  approval cards for deferred tool calls.
- Remove duplicate ApprovalNeeded send from agent_loop.rs (thread_ops.rs
  is now the canonical sender).
- Skip approval for tool_auth in gateway mode since it only returns a URL.
- Revert fragile active-server detection heuristic from system prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 12, 2026 05:27
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: tool/builtin Built-in tools size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Mar 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/extensions/manager.rs
Comment on lines +1793 to +1805
return match self.auth_mcp_build_url(name, &server).await {
Ok(result) => Ok(result),
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),
};

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In gateway mode, any error from auth_mcp_build_url() is currently treated as “server does not support OAuth” and the user is prompted for a manual token. This masks real discovery/network/config errors (e.g., transient HTTP failures) and can send users down the wrong path. Consider only falling back to manual token entry for a specific “OAuth not supported” error, and otherwise surface the underlying error message to the caller/UI.

Suggested change
return match self.auth_mcp_build_url(name, &server).await {
Ok(result) => Ok(result),
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),
};
return self.auth_mcp_build_url(name, &server).await;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Comment thread src/extensions/manager.rs Outdated
sse_sender: self.sse_sender.read().await.clone(),
gateway_token: self.gateway_token.clone(),
resource: Some(resource),
client_id_secret_name: Some(format!("mcp_{}_client_id", name)),

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client_id_secret_name is set unconditionally for MCP OAuth gateway flows. The CLI path only persists a client_id secret for Dynamic Client Registration (when no client_id is preconfigured). Persisting it for preconfigured OAuth configs adds extra secrets unnecessarily and may confuse troubleshooting. Consider setting client_id_secret_name only when the flow was created via DCR, and prefer server.client_id_secret_name() over reformatting the secret name string here.

Suggested change
client_id_secret_name: Some(format!("mcp_{}_client_id", name)),
client_id_secret_name: server.client_id_secret_name(),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on 2921154104.

Comment on lines 570 to 571
.await
.map_err(|e| e.to_string())?;

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For MCP gateway OAuth, the access/refresh tokens are persisted via store_oauth_tokens(...) using flow.provider. The CLI MCP auth path stores secrets with provider tag mcp:{server_name}; if flow.provider is just the raw server name, gateway-mode tokens will be tagged differently than CLI tokens. Consider ensuring MCP flows pass a mcp:-prefixed provider string into store_oauth_tokens so provider tags remain consistent across auth paths.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — flow.provider is now mcp:-prefixed, and store_oauth_tokens uses it directly. All secrets (token, refresh, client_id) share the same provider tag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — flow.provider is now mcp:-prefixed, and store_oauth_tokens uses it directly. All secrets share the same provider tag.

Comment thread src/extensions/manager.rs Outdated
async fn gateway_callback_redirect_uri(&self) -> Option<String> {
use crate::cli::oauth_defaults;
if oauth_defaults::use_gateway_callback() {
return Some(format!("{}/callback", oauth_defaults::callback_url()));

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gateway_callback_redirect_uri() builds {callback_url()}/callback when IRONCLAW_OAUTH_CALLBACK_URL is set, but other gateway paths use /oauth/callback and the web server routes the handler at /oauth/callback. This can easily produce a redirect URI that 404s unless the env var is set to a non-obvious base path. Consider making the env-var branch generate the same /oauth/callback path (or clearly document/validate the required shape of IRONCLAW_OAUTH_CALLBACK_URL).

Suggested change
return Some(format!("{}/callback", oauth_defaults::callback_url()));
return Some(format!("{}/oauth/callback", oauth_defaults::callback_url()));

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Now generates /oauth/callback path to match the web server route.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7376b7. Now generates /oauth/callback path to match the web server route.

Comment thread src/extensions/manager.rs Outdated
code_verifier,
access_token_field: "access_token".to_string(),
secret_name: server.token_secret_name(),
provider: Some(name.to_string()),

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For MCP flows, provider is set to Some(name.to_string()), but the existing CLI MCP auth path stores secrets with provider tag mcp:{server_name}. Since the gateway callback uses flow.provider when persisting OAuth tokens, the provider tag will differ between CLI and gateway mode, which can break grouping/lookup by provider. Consider storing the fully-qualified provider string (e.g. mcp:{name}) in the flow (and avoid double-prefixing later), or otherwise ensure store_oauth_tokens receives the mcp:-prefixed provider.

Suggested change
provider: Some(name.to_string()),
provider: Some(format!("mcp:{}", name)),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on 2921154075.

Comment thread src/channels/web/server.rs Outdated
Comment on lines +578 to +584
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(
flow.provider
.as_ref()
.map(|p| format!("mcp:{}", p))
.unwrap_or_default(),
);

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new client_id_secret_name persistence uses with_provider(...unwrap_or_default()), which will set an empty provider string if flow.provider is missing. Even if MCP flows always set it today, it’s safer to only set a provider when it’s Some, and to avoid re-prefixing with mcp: if the flow already stores a fully-qualified provider tag.

Suggested change
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(
flow.provider
.as_ref()
.map(|p| format!("mcp:{}", p))
.unwrap_or_default(),
);
let mut params =
crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id);
if let Some(provider) = flow.provider.as_ref() {
let provider = if provider.starts_with("mcp:") {
provider.to_string()
} else {
format!("mcp:{}", provider)
};
params = params.with_provider(provider);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on 2921154036.

Comment thread src/extensions/mod.rs Outdated
Comment on lines +1074 to +1092

/// Verify that auth-related activation errors are detected as AuthRequired
/// so the activate handler triggers OAuth instead of showing a generic failure.
#[test]
fn activation_error_auth_detection() {
// 401 errors from MCP servers should be treated as AuthRequired
let auth_msg = "MCP server 'notion' requires authentication. Run: ironclaw mcp auth notion";
let is_auth = auth_msg.contains("requires authentication") || auth_msg.contains("401");
assert!(is_auth, "Should detect auth error in: {}", auth_msg);

let raw_401 = "External service error: 401 Unauthorized";
let is_auth = raw_401.contains("requires authentication") || raw_401.contains("401");
assert!(is_auth, "Should detect 401 in: {}", raw_401);

// Non-auth errors should not trigger auth flow
let other_msg = "Connection refused";
let is_auth = other_msg.contains("requires authentication") || other_msg.contains("401");
assert!(!is_auth, "Should not detect auth in: {}", other_msg);
}

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn’t validate the actual activation error classification logic; it only asserts that hard-coded strings contain substrings like "401". As written it can’t fail for the intended behavior and will provide false confidence. Consider rewriting it to exercise the real code path (e.g., whatever function maps activation errors to AuthRequired) or removing it.

Suggested change
/// Verify that auth-related activation errors are detected as AuthRequired
/// so the activate handler triggers OAuth instead of showing a generic failure.
#[test]
fn activation_error_auth_detection() {
// 401 errors from MCP servers should be treated as AuthRequired
let auth_msg = "MCP server 'notion' requires authentication. Run: ironclaw mcp auth notion";
let is_auth = auth_msg.contains("requires authentication") || auth_msg.contains("401");
assert!(is_auth, "Should detect auth error in: {}", auth_msg);
let raw_401 = "External service error: 401 Unauthorized";
let is_auth = raw_401.contains("requires authentication") || raw_401.contains("401");
assert!(is_auth, "Should detect 401 in: {}", raw_401);
// Non-auth errors should not trigger auth flow
let other_msg = "Connection refused";
let is_auth = other_msg.contains("requires authentication") || other_msg.contains("401");
assert!(!is_auth, "Should not detect auth in: {}", other_msg);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b7376b7. The real AuthRequired detection is now covered by the MCP extension lifecycle E2E test which hits a mock server returning 401.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b7376b7. AuthRequired detection is now covered by the MCP extension lifecycle E2E test (33bf9c6).

…way-browser

# Conflicts:
#	src/extensions/manager.rs
ilblackdragon and others added 2 commits March 12, 2026 00:54
- Use Release/Acquire ordering for gateway_mode AtomicBool instead of
  Relaxed to ensure visibility across threads.
- Report activation failure as error in OAuth callback SSE event instead
  of silently falling back to the success message.
- Fix EnvGuard::drop to remove env var when original was unset.
- Replace hardcoded /tmp/ path with std::env::temp_dir() in test helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…erver

Add a full MCP extension lifecycle E2E test that exercises:
- Turn 1: tool_search → tool_install → text (extension discovery and install)
- Token injection + activate (simulating OAuth completion)
- Turn 2: MCP tool calls (notion-search → notion-fetch → text)

Includes a mock MCP server (tests/support/mock_mcp_server.rs) with OAuth
discovery, DCR, token exchange, and JSON-RPC endpoints. The mock server
validates Bearer auth and serves pre-configured tool responses.

Also adds inject_registry_entry() to ExtensionManager for test use and
exposes extension_manager from TestRig.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 12, 2026 16:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +475 to +509
// 5. Turn 1: "setup mock-notion" → search → install → text.
rig.send_message("setup mock-notion").await;
let r1 = rig.wait_for_responses(1, TIMEOUT).await;
assert!(!r1.is_empty(), "Turn 1: no response");

// 6. Simulate OAuth completion: inject token + activate.
// This mirrors what the gateway's oauth_callback_handler does after
// the user completes the OAuth flow in their browser.
let secret_name = "mcp_mock-notion_access_token";
ext_mgr
.secrets()
.create(
"default",
ironclaw::secrets::CreateSecretParams::new(secret_name, "mock-access-token")
.with_provider("mcp:mock-notion".to_string()),
)
.await
.expect("failed to inject test token");

let activate_result = ext_mgr.activate("mock-notion").await;
assert!(
activate_result.is_ok(),
"activation failed: {:?}",
activate_result.err()
);

// 7. Turn 2: "check what's in my notion" → notion-search → notion-fetch → text.
rig.send_message("it's done, check what's in my notion")
.await;
let r2 = rig.wait_for_responses(2, TIMEOUT).await;
assert!(
r2.len() >= 2,
"Turn 2: expected at least 2 total responses, got {}",
r2.len()
);

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait_for_responses(n) returns all captured responses once the total count reaches n (it does not scope to the most recent turn). Here wait_for_responses(2, ...) for turn 2 can return immediately if turn 1 already produced ≥2 responses, so the test may pass without ever observing turn-2 behavior. Consider clearing the channel between turns (rig.clear().await), or waiting for r1.len() + expected_new responses after sending turn 2.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c8cc515. Now uses wait_for_responses(turn1_count + 1) to ensure at least one new turn-2 response is observed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c8cc515. Now uses wait_for_responses(turn1_count + 1) to ensure at least one new turn-2 response is observed.

Comment thread src/extensions/mod.rs Outdated
Comment on lines +1091 to +1109

/// Verify that auth-related activation errors are detected as AuthRequired
/// so the activate handler triggers OAuth instead of showing a generic failure.
#[test]
fn activation_error_auth_detection() {
// 401 errors from MCP servers should be treated as AuthRequired
let auth_msg = "MCP server 'notion' requires authentication. Run: ironclaw mcp auth notion";
let is_auth = auth_msg.contains("requires authentication") || auth_msg.contains("401");
assert!(is_auth, "Should detect auth error in: {}", auth_msg);

let raw_401 = "External service error: 401 Unauthorized";
let is_auth = raw_401.contains("requires authentication") || raw_401.contains("401");
assert!(is_auth, "Should detect 401 in: {}", raw_401);

// Non-auth errors should not trigger auth flow
let other_msg = "Connection refused";
let is_auth = other_msg.contains("requires authentication") || other_msg.contains("401");
assert!(!is_auth, "Should not detect auth in: {}", other_msg);
}

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't exercise any production code: it only asserts that hard-coded strings contain substrings. That provides false confidence for the new AuthRequired detection logic (which lives in ExtensionManager activation/error handling). Consider replacing with a unit/integration test that triggers the actual error path (e.g., mock an MCP server returning 401 from tools/list and assert activation yields ExtensionError::AuthRequired).

Suggested change
/// Verify that auth-related activation errors are detected as AuthRequired
/// so the activate handler triggers OAuth instead of showing a generic failure.
#[test]
fn activation_error_auth_detection() {
// 401 errors from MCP servers should be treated as AuthRequired
let auth_msg = "MCP server 'notion' requires authentication. Run: ironclaw mcp auth notion";
let is_auth = auth_msg.contains("requires authentication") || auth_msg.contains("401");
assert!(is_auth, "Should detect auth error in: {}", auth_msg);
let raw_401 = "External service error: 401 Unauthorized";
let is_auth = raw_401.contains("requires authentication") || raw_401.contains("401");
assert!(is_auth, "Should detect 401 in: {}", raw_401);
// Non-auth errors should not trigger auth flow
let other_msg = "Connection refused";
let is_auth = other_msg.contains("requires authentication") || other_msg.contains("401");
assert!(!is_auth, "Should not detect auth in: {}", other_msg);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed — see earlier reply.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b7376b7.

Comment thread src/cli/oauth_defaults.rs Outdated
Comment on lines 1054 to 1076
/// Verify that MCP flows set client_id_secret_name so the gateway callback
/// can persist the DCR client_id for token refresh.
#[test]
fn test_pending_flow_mcp_carries_client_id_secret_name() {
// MCP flows must set client_id_secret_name so refresh can find the client_id
let mcp_secret_name = format!("mcp_{}_client_id", "notion");
assert_eq!(mcp_secret_name, "mcp_notion_client_id");

// Simulate what auth_mcp_build_url sets for gateway mode
let has_secret_name = Some(mcp_secret_name.clone());
assert!(
has_secret_name.is_some(),
"MCP PendingOAuthFlow must set client_id_secret_name for token refresh"
);

// WASM flows should not set it
let wasm_secret_name: Option<String> = None;
assert!(
wasm_secret_name.is_none(),
"WASM PendingOAuthFlow should not set client_id_secret_name"
);
}
}

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is effectively a tautology (it constructs Some(...) and asserts it’s Some, then constructs None and asserts it’s None). It doesn’t verify that MCP gateway flows actually populate client_id_secret_name in PendingOAuthFlow. Consider removing it or rewriting it to call the real builder (auth_mcp_build_url / the code that creates the flow) and assert the produced flow includes client_id_secret_name for MCP and not for WASM.

Suggested change
/// Verify that MCP flows set client_id_secret_name so the gateway callback
/// can persist the DCR client_id for token refresh.
#[test]
fn test_pending_flow_mcp_carries_client_id_secret_name() {
// MCP flows must set client_id_secret_name so refresh can find the client_id
let mcp_secret_name = format!("mcp_{}_client_id", "notion");
assert_eq!(mcp_secret_name, "mcp_notion_client_id");
// Simulate what auth_mcp_build_url sets for gateway mode
let has_secret_name = Some(mcp_secret_name.clone());
assert!(
has_secret_name.is_some(),
"MCP PendingOAuthFlow must set client_id_secret_name for token refresh"
);
// WASM flows should not set it
let wasm_secret_name: Option<String> = None;
assert!(
wasm_secret_name.is_none(),
"WASM PendingOAuthFlow should not set client_id_secret_name"
);
}
}
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed — see earlier reply.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b7376b7.

Comment thread src/extensions/manager.rs
Comment on lines +1770 to +1782
return match self.auth_mcp_build_url(name, &server).await {
Ok(result) => Ok(result),
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),
};

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In gateway mode this swallows all errors from auth_mcp_build_url() and returns the fallback message "does not support OAuth". That will mislead users for real failures (discovery/DCR/network/invalid URLs) and makes debugging/auth recovery harder. Consider only falling back to manual-token flow for explicit "OAuth not supported" cases (e.g., when metadata lacks auth+registration endpoints), and otherwise propagate/return an AuthFailed message with the underlying error.

Suggested change
return match self.auth_mcp_build_url(name, &server).await {
Ok(result) => Ok(result),
Err(_) => Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
)),
};
return self.auth_mcp_build_url(name, &server).await;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Comment thread src/extensions/manager.rs Outdated
Comment on lines +1921 to +1931
access_token_field: "access_token".to_string(),
secret_name: server.token_secret_name(),
provider: Some(name.to_string()),
validation_endpoint: None,
scopes,
user_id: self.user_id.clone(),
secrets: Arc::clone(&self.secrets),
sse_sender: self.sse_sender.read().await.clone(),
gateway_token: self.gateway_token.clone(),
resource: Some(resource),
client_id_secret_name: Some(format!("mcp_{}_client_id", name)),

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PendingOAuthFlow.provider is set to Some(name.to_string()) (e.g. "mock-notion"), but MCP secrets elsewhere are tagged with provider mcp:{server_name} (see store_tokens() / store_client_id() in src/tools/mcp/auth.rs). With the current value, store_oauth_tokens() will persist the access/refresh tokens under provider "mock-notion", which is inconsistent and can break any provider-based lookup/management. Set the flow's provider to the fully qualified mcp:{name} (and then avoid re-prefixing it later in the gateway callback).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on the earlier duplicate comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on 2921154075.

Comment on lines +636 to +647
if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(
flow.provider
.as_ref()
.map(|p| format!("mcp:{}", p))
.unwrap_or_default(),
);
flow.secrets
.create(&flow.user_id, params)
.await
.map_err(|e| e.to_string())?;

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This provider tag construction is inconsistent with how OAuth tokens are stored in store_oauth_tokens(): the access/refresh tokens use flow.provider directly, but here the client_id secret is stored with mcp:{flow.provider}. As written, MCP flows currently store tokens under provider "mock-notion" while the client_id secret is stored under "mcp:mock-notion". Once flow.provider is fixed to include the mcp: prefix, this code would also double-prefix to mcp:mcp:.... Prefer storing all related secrets (token, refresh, client_id) with the same provider tag (likely flow.provider as-is).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see earlier reply. Provider tag is now consistent across all secrets.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see reply on 2921154036. Provider tag is now consistent across all secrets.

Comment thread src/channels/web/server.rs Outdated
Comment on lines +681 to +714
// After successful OAuth, auto-activate the extension so it moves
// from "Installed (Authenticate)" → "Active" without a second click.
let (final_success, final_message) = if success {
match ext_mgr.activate(&flow.extension_name).await {
Ok(result) => (true, result.message),
Err(e) => {
tracing::warn!(
extension = %flow.extension_name,
error = %e,
"Auto-activation after OAuth failed"
);
(
false,
format!(
"{} authenticated but activation failed: {}. Try activating manually.",
flow.display_name, e
),
)
}
}
} else {
(false, message)
};

// Broadcast SSE event to notify the web UI
if let Some(ref sender) = flow.sse_sender {
let _ = sender.send(SseEvent::AuthCompleted {
extension_name: flow.extension_name,
success,
message,
success: final_success,
message: final_message.clone(),
});
}

let html = oauth_defaults::landing_html(&flow.display_name, success);
let html = oauth_defaults::landing_html(&flow.display_name, final_success);

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When auto-activation fails, this sets final_success to false and uses that for both the SSE AuthCompleted.success and the landing page. That reports the OAuth step as "failed" even though tokens were successfully exchanged and stored, which is misleading and may cause the UI to regress to an unauthenticated state. Consider keeping success tied to OAuth/token persistence, and reporting activation failure separately (or include activation status in the message without flipping overall auth success).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c8cc515. OAuth success is now kept independent of activation — tokens are already stored, so auth succeeded. The SSE event and landing page reflect the actual OAuth result, with activation failure reported in the message text only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c8cc515. OAuth success is now independent of activation — tokens are already stored. Activation failure is reported in the message text only, without flipping the auth success status.

ilblackdragon and others added 2 commits March 12, 2026 10:12
- Only fall back to manual token entry on AuthNotSupported, propagate
  real errors from auth_mcp_build_url() instead of masking them
- Use mcp:-prefixed provider string in PendingOAuthFlow for consistency
  with CLI MCP auth token storage
- Only persist client_id_secret_name for DCR flows (not pre-configured OAuth)
- Fix gateway_callback_redirect_uri to use /oauth/callback path
- Bypass exchange proxy when flow has RFC 8707 resource parameter
- Remove client_id double-prefix in oauth callback handler
- Remove weak tests that didn't exercise production logic
- Add clarifying comments for exchange_oauth_code delegation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nses scoping

- OAuth success is now reported accurately even when auto-activation
  fails (tokens are already stored, so auth succeeded)
- E2E test waits for turn1_count + 1 responses to ensure turn-2
  behavior is actually observed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 12, 2026 17:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +443 to +446
// 2. Load trace fixture.
let trace =
LlmTrace::from_file(format!("{FIXTURES}/mcp_extension_lifecycle.json")).unwrap();

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test loads a trace fixture but never calls rig.verify_trace_expects(...) (or run_and_verify_trace) to assert the fixture’s declarative expectations (tool order, min responses, etc.). As written, failures like missing/extra tool calls could slip through as long as the ad-hoc assertions still pass. Consider verifying the trace expects against the accumulated responses after turn 2.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
async fn gateway_callback_redirect_uri(&self) -> Option<String> {
use crate::cli::oauth_defaults;
if oauth_defaults::use_gateway_callback() {
return Some(format!("{}/oauth/callback", oauth_defaults::callback_url()));

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gateway_callback_redirect_uri() appends /oauth/callback onto oauth_defaults::callback_url(), but callback_url() is an env-controlled string and can already include a path. If it already ends with /oauth/callback, this will generate a duplicated redirect URI (e.g. .../oauth/callback/oauth/callback) and break hosted auth. Consider parsing with url::Url and only appending the path segment when needed (or documenting/enforcing that IRONCLAW_OAUTH_CALLBACK_URL must be a base origin).

Suggested change
return Some(format!("{}/oauth/callback", oauth_defaults::callback_url()));
let callback = oauth_defaults::callback_url();
// Try to parse and only append `/oauth/callback` when needed.
if let Ok(mut url) = url::Url::parse(&callback) {
let path = url.path();
if path.ends_with("/oauth/callback") {
// Already points at the desired callback path; use as-is.
return Some(callback);
}
if path.is_empty() || path == "/" {
// No meaningful path yet; set it directly.
url.set_path("/oauth/callback");
} else {
// Append the callback segment to the existing path.
let new_path = format!("{}/oauth/callback", path.trim_end_matches('/'));
url.set_path(&new_path);
}
return Some(url.to_string());
}
// Fallback: preserve previous behavior with basic trailing slash handling.
let base = callback.trim_end_matches('/');
return Some(format!("{}/oauth/callback", base));

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines +1893 to +1899
let oauth_result = oauth_defaults::build_oauth_url(
&metadata.authorization_endpoint,
&client_id,
&redirect_uri,
&metadata.scopes_supported,
Some(&pkce),
&std::collections::HashMap::new(),
None,
&scopes,
true, // Always use PKCE for MCP
&extra_params,

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In auth_mcp_build_url, the discovered authorization_endpoint (and token endpoint via flow.token_url) is used to build an auth URL returned to the frontend without applying the same endpoint safety checks used in the CLI flow (authorize_mcp_server validates HTTPS/non-local via SSRF/phishing guard). This makes it easier for a malicious MCP server to supply an unsafe/phishing authorization URL. Consider adding a shared public validator in crate::tools::mcp::auth and rejecting/errored auth when the discovered endpoints fail validation.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines +1909 to +1913
let platform_state = oauth_defaults::build_platform_state(&expected_state);
let auth_url = if platform_state != expected_state {
oauth_result.url.replace(
&format!("state={}", urlencoding::encode(&expected_state)),
&format!("state={}", urlencoding::encode(&platform_state)),

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state rewrite for platform routing is done with a raw string replace() on the full URL. This is brittle (e.g., if the substring happens to appear elsewhere in the URL or if query param ordering/encoding changes) and makes the logic harder to reason about. Prefer parsing the URL and updating just the state query parameter via url::Url so it’s guaranteed to be correct.

Copilot uses AI. Check for mistakes.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review for fix(mcp): open MCP OAuth in same browser as gateway

Verdict: Approve

This is a well-structured fix for a real UX problem (MCP OAuth opening in the OS default browser instead of the browser running the gateway UI). The changes are clean, well-commented, and follow existing patterns.

What I reviewed:

  1. Code correctness and safety

    • No .unwrap() in production code. All error paths use proper map_err with context.
    • AtomicBool for gateway_mode uses Release/Acquire ordering -- correct for cross-thread visibility.
    • PendingOAuthFlow is properly populated with all fields including the new resource and client_id_secret_name.
    • The EnvGuard helper in tests correctly restores/removes env vars on drop.
  2. Error handling

    • New AuthNotSupported variant in ExtensionError cleanly distinguishes "server doesn't do OAuth" from "OAuth failed" -- this is the right fix for the root cause where ActivationFailed was returned for 401s.
    • exchange_oauth_code_with_resource() properly delegates from the existing exchange_oauth_code() so callers without RFC 8707 needs are unaffected.
    • OAuth callback handler correctly reports auth success independently of auto-activation failure (tokens are stored regardless).
  3. Code conventions

    • Follows existing patterns: thiserror for errors, Arc<RwLock<>> for shared state, map_err with context strings.
    • should_use_gateway_mode() has a clear priority chain (explicit flag > env var > tunnel URL) with good doc comments.
    • The approval status refactor (moving ApprovalNeeded from agent_loop.rs to thread_ops.rs) is correct -- the canonical sender is now in one place instead of two.
  4. Security

    • RFC 8707 resource parameter scopes tokens to specific MCP servers -- good security practice.
    • Gateway mode correctly bypasses the exchange proxy when resource param is present (proxy doesn't forward it).
    • client_id_secret_name is only persisted for DCR flows (not pre-configured OAuth), which is correct.
    • tool_auth skipping approval in gateway mode is safe since it only returns a URL, not launching a process.
  5. Testing

    • Good coverage: gateway mode detection tests (tunnel URL, loopback, explicit enable), redirect URI construction, RFC 8707 resource param in auth URL.
    • Full E2E lifecycle test with mock MCP server exercising search -> install -> OAuth -> activate -> tool calls.
    • Mock MCP server is well-implemented with proper JSON-RPC, OAuth discovery, and Bearer auth validation.

Minor observations (non-blocking):

  • The msg.contains("401") string matching for detecting auth errors is fragile (could match on unrelated "401" in error messages). Consider matching on a typed error variant from McpClient in a follow-up if the MCP client surfaces HTTP status codes.
  • The .githooks/pre-push addition is unrelated to the MCP OAuth fix but is fine to include.

All CI checks pass. Well done.

@zmanian
zmanian merged commit 8a26cfa into staging Mar 12, 2026
19 checks passed
@zmanian
zmanian deleted the fix/mcp-oauth-gateway-browser branch March 12, 2026 18:16
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
* fix(mcp): use gateway callback for MCP OAuth so auth opens in same browser

When MCP OAuth is triggered from the web gateway, the auth URL was being
opened via `open::that()` which launches the OS default browser instead
of the browser already running the gateway UI. This changes the MCP OAuth
flow to use the same gateway callback pattern as WASM extensions: in
gateway mode, the auth URL is returned to the frontend via SSE and opened
with `window.open()`, keeping the user in the same browser.

Also adds RFC 8707 `resource` parameter support to the gateway token
exchange path, scoping issued tokens to the correct MCP server.

Closes nearai#299

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): persist DCR client_id in gateway OAuth callback for token refresh

The gateway callback handler stored access and refresh tokens but not
the DCR client_id. When the token expired, refresh failed with "No
client ID found" because get_client_id() could not find it in secrets.

Adds client_id_secret_name to PendingOAuthFlow so the gateway callback
handler persists the client_id alongside the tokens, matching the
behavior of the CLI flow in authorize_mcp_server().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): return AuthRequired on 401 so activate triggers OAuth flow

activate_mcp() returned ActivationFailed for all errors including 401
auth responses, so the activate handler never triggered the OAuth flow.
Now 401/auth errors return AuthRequired, which the handler detects and
redirects to the OAuth flow — matching the WASM extension pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): fix gateway OAuth flow, approval cards, and auto-activation

- Add explicit gateway_mode flag on ExtensionManager (set at startup by
  web gateway) so MCP OAuth returns auth URLs to the frontend instead of
  calling open::that() on the server machine.
- Auto-activate extensions after successful OAuth callback so the UI
  transitions from "Activate" to "Active" without a second click.
- Send ApprovalNeeded status (not generic "Awaiting approval") from
  thread_ops.rs for all three NeedApproval paths so the web UI shows
  approval cards for deferred tool calls.
- Remove duplicate ApprovalNeeded send from agent_loop.rs (thread_ops.rs
  is now the canonical sender).
- Skip approval for tool_auth in gateway mode since it only returns a URL.
- Revert fragile active-server detection heuristic from system prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings

- Use Release/Acquire ordering for gateway_mode AtomicBool instead of
  Relaxed to ensure visibility across threads.
- Report activation failure as error in OAuth callback SSE event instead
  of silently falling back to the success message.
- Fix EnvGuard::drop to remove env var when original was unset.
- Replace hardcoded /tmp/ path with std::env::temp_dir() in test helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(mcp): add E2E trace test for MCP extension lifecycle with mock server

Add a full MCP extension lifecycle E2E test that exercises:
- Turn 1: tool_search → tool_install → text (extension discovery and install)
- Token injection + activate (simulating OAuth completion)
- Turn 2: MCP tool calls (notion-search → notion-fetch → text)

Includes a mock MCP server (tests/support/mock_mcp_server.rs) with OAuth
discovery, DCR, token exchange, and JSON-RPC endpoints. The mock server
validates Bearer auth and serves pre-configured tool responses.

Also adds inject_registry_entry() to ExtensionManager for test use and
exposes extension_manager from TestRig.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings (round 2)

- Only fall back to manual token entry on AuthNotSupported, propagate
  real errors from auth_mcp_build_url() instead of masking them
- Use mcp:-prefixed provider string in PendingOAuthFlow for consistency
  with CLI MCP auth token storage
- Only persist client_id_secret_name for DCR flows (not pre-configured OAuth)
- Fix gateway_callback_redirect_uri to use /oauth/callback path
- Bypass exchange proxy when flow has RFC 8707 resource parameter
- Remove client_id double-prefix in oauth callback handler
- Remove weak tests that didn't exercise production logic
- Add clarifying comments for exchange_oauth_code delegation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: keep OAuth success independent of activation, fix wait_for_responses scoping

- OAuth success is now reported accurately even when auto-activation
  fails (tokens are already stored, so auth succeeded)
- E2E test waits for turn1_count + 1 responses to ensure turn-2
  behavior is actually observed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
* fix(mcp): use gateway callback for MCP OAuth so auth opens in same browser

When MCP OAuth is triggered from the web gateway, the auth URL was being
opened via `open::that()` which launches the OS default browser instead
of the browser already running the gateway UI. This changes the MCP OAuth
flow to use the same gateway callback pattern as WASM extensions: in
gateway mode, the auth URL is returned to the frontend via SSE and opened
with `window.open()`, keeping the user in the same browser.

Also adds RFC 8707 `resource` parameter support to the gateway token
exchange path, scoping issued tokens to the correct MCP server.

Closes nearai#299

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): persist DCR client_id in gateway OAuth callback for token refresh

The gateway callback handler stored access and refresh tokens but not
the DCR client_id. When the token expired, refresh failed with "No
client ID found" because get_client_id() could not find it in secrets.

Adds client_id_secret_name to PendingOAuthFlow so the gateway callback
handler persists the client_id alongside the tokens, matching the
behavior of the CLI flow in authorize_mcp_server().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): return AuthRequired on 401 so activate triggers OAuth flow

activate_mcp() returned ActivationFailed for all errors including 401
auth responses, so the activate handler never triggered the OAuth flow.
Now 401/auth errors return AuthRequired, which the handler detects and
redirects to the OAuth flow — matching the WASM extension pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): fix gateway OAuth flow, approval cards, and auto-activation

- Add explicit gateway_mode flag on ExtensionManager (set at startup by
  web gateway) so MCP OAuth returns auth URLs to the frontend instead of
  calling open::that() on the server machine.
- Auto-activate extensions after successful OAuth callback so the UI
  transitions from "Activate" to "Active" without a second click.
- Send ApprovalNeeded status (not generic "Awaiting approval") from
  thread_ops.rs for all three NeedApproval paths so the web UI shows
  approval cards for deferred tool calls.
- Remove duplicate ApprovalNeeded send from agent_loop.rs (thread_ops.rs
  is now the canonical sender).
- Skip approval for tool_auth in gateway mode since it only returns a URL.
- Revert fragile active-server detection heuristic from system prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings

- Use Release/Acquire ordering for gateway_mode AtomicBool instead of
  Relaxed to ensure visibility across threads.
- Report activation failure as error in OAuth callback SSE event instead
  of silently falling back to the success message.
- Fix EnvGuard::drop to remove env var when original was unset.
- Replace hardcoded /tmp/ path with std::env::temp_dir() in test helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(mcp): add E2E trace test for MCP extension lifecycle with mock server

Add a full MCP extension lifecycle E2E test that exercises:
- Turn 1: tool_search → tool_install → text (extension discovery and install)
- Token injection + activate (simulating OAuth completion)
- Turn 2: MCP tool calls (notion-search → notion-fetch → text)

Includes a mock MCP server (tests/support/mock_mcp_server.rs) with OAuth
discovery, DCR, token exchange, and JSON-RPC endpoints. The mock server
validates Bearer auth and serves pre-configured tool responses.

Also adds inject_registry_entry() to ExtensionManager for test use and
exposes extension_manager from TestRig.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings (round 2)

- Only fall back to manual token entry on AuthNotSupported, propagate
  real errors from auth_mcp_build_url() instead of masking them
- Use mcp:-prefixed provider string in PendingOAuthFlow for consistency
  with CLI MCP auth token storage
- Only persist client_id_secret_name for DCR flows (not pre-configured OAuth)
- Fix gateway_callback_redirect_uri to use /oauth/callback path
- Bypass exchange proxy when flow has RFC 8707 resource parameter
- Remove client_id double-prefix in oauth callback handler
- Remove weak tests that didn't exercise production logic
- Add clarifying comments for exchange_oauth_code delegation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: keep OAuth success independent of activation, fix wait_for_responses scoping

- OAuth success is now reported accurately even when auto-activation
  fails (tokens are already stored, so auth succeeded)
- E2E test waits for turn1_count + 1 responses to ensure turn-2
  behavior is actually observed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: extensions Extension management scope: tool/builtin Built-in tools size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Help Needed: Unable to authenticate MCP servers

3 participants