mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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 #299 Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
341 lines
11 KiB
Rust
341 lines
11 KiB
Rust
//! Mock MCP server for E2E testing of the extension lifecycle.
|
|
//!
|
|
//! Provides a minimal HTTP server with:
|
|
//! - OAuth 2.1 discovery (`.well-known/oauth-protected-resource`, `.well-known/oauth-authorization-server`)
|
|
//! - Dynamic Client Registration (`/register`)
|
|
//! - Token exchange (`/token`)
|
|
//! - MCP JSON-RPC endpoint (`/mcp`) with `initialize`, `tools/list`, `tools/call`
|
|
//!
|
|
//! Tool call responses are pre-configured via `MockToolResponse`.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::oneshot;
|
|
|
|
/// A pre-configured response for a specific MCP tool call.
|
|
#[derive(Clone, Debug)]
|
|
pub struct MockToolResponse {
|
|
/// Tool name (e.g., "notion-search").
|
|
pub name: String,
|
|
/// JSON response content for `tools/call`.
|
|
pub content: serde_json::Value,
|
|
}
|
|
|
|
/// A running mock MCP server.
|
|
pub struct MockMcpServer {
|
|
/// Base URL including port (e.g., "http://127.0.0.1:12345").
|
|
pub base_url: String,
|
|
/// Shutdown signal sender.
|
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
|
/// Server task handle.
|
|
handle: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl MockMcpServer {
|
|
/// The MCP endpoint URL for use in registry entries.
|
|
pub fn mcp_url(&self) -> String {
|
|
format!("{}/mcp", self.base_url)
|
|
}
|
|
|
|
/// Shut down the server.
|
|
pub async fn shutdown(mut self) {
|
|
if let Some(tx) = self.shutdown_tx.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
if let Some(h) = self.handle.take() {
|
|
let _ = h.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for MockMcpServer {
|
|
fn drop(&mut self) {
|
|
if let Some(tx) = self.shutdown_tx.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
if let Some(h) = self.handle.take() {
|
|
h.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shared state for the mock server handlers.
|
|
struct MockState {
|
|
/// Base URL (filled after bind).
|
|
base_url: String,
|
|
/// Tool definitions served by tools/list.
|
|
tools: Vec<McpToolDef>,
|
|
/// Pre-configured tool call responses keyed by tool name.
|
|
/// Multiple calls to the same tool return responses in order.
|
|
tool_responses: HashMap<String, Vec<serde_json::Value>>,
|
|
/// Counter for tool_responses consumption (per tool name).
|
|
tool_response_idx: std::sync::Mutex<HashMap<String, usize>>,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
struct McpToolDef {
|
|
name: String,
|
|
description: String,
|
|
#[serde(rename = "inputSchema")]
|
|
input_schema: serde_json::Value,
|
|
}
|
|
|
|
/// Start a mock MCP server on a random port.
|
|
///
|
|
/// `tool_responses` configures what `tools/call` returns for each tool name.
|
|
/// Multiple responses for the same tool are returned in order.
|
|
pub async fn start_mock_mcp_server(tool_responses: Vec<MockToolResponse>) -> MockMcpServer {
|
|
// Build tool definitions and response map.
|
|
let mut tools = Vec::new();
|
|
let mut response_map: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
|
|
let mut seen_tools = std::collections::HashSet::new();
|
|
|
|
for tr in &tool_responses {
|
|
if seen_tools.insert(tr.name.clone()) {
|
|
tools.push(McpToolDef {
|
|
name: tr.name.clone(),
|
|
description: format!("Mock tool: {}", tr.name),
|
|
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
|
});
|
|
}
|
|
response_map
|
|
.entry(tr.name.clone())
|
|
.or_default()
|
|
.push(tr.content.clone());
|
|
}
|
|
|
|
// Bind to a random port.
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("failed to bind mock MCP server");
|
|
let addr: SocketAddr = listener.local_addr().expect("no local addr");
|
|
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
|
|
|
let state = Arc::new(MockState {
|
|
base_url: base_url.clone(),
|
|
tools,
|
|
tool_responses: response_map,
|
|
tool_response_idx: std::sync::Mutex::new(HashMap::new()),
|
|
});
|
|
|
|
let app = Router::new()
|
|
.route(
|
|
"/.well-known/oauth-protected-resource/mcp",
|
|
get(handle_protected_resource),
|
|
)
|
|
.route(
|
|
"/.well-known/oauth-authorization-server",
|
|
get(handle_auth_server_metadata),
|
|
)
|
|
.route("/register", post(handle_register))
|
|
.route("/authorize", get(handle_authorize))
|
|
.route("/token", post(handle_token))
|
|
.route("/mcp", post(handle_mcp))
|
|
.with_state(state);
|
|
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
|
let handle = tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = shutdown_rx.await;
|
|
})
|
|
.await
|
|
.expect("mock MCP server failed");
|
|
});
|
|
|
|
// Wait briefly for the server to start accepting.
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
MockMcpServer {
|
|
base_url,
|
|
shutdown_tx: Some(shutdown_tx),
|
|
handle: Some(handle),
|
|
}
|
|
}
|
|
|
|
// ── OAuth discovery endpoints ───────────────────────────────────────────
|
|
|
|
async fn handle_protected_resource(State(state): State<Arc<MockState>>) -> impl IntoResponse {
|
|
Json(serde_json::json!({
|
|
"resource": format!("{}/mcp", state.base_url),
|
|
"authorization_servers": [state.base_url],
|
|
"scopes_supported": ["read", "write"]
|
|
}))
|
|
}
|
|
|
|
async fn handle_auth_server_metadata(State(state): State<Arc<MockState>>) -> impl IntoResponse {
|
|
Json(serde_json::json!({
|
|
"issuer": state.base_url,
|
|
"authorization_endpoint": format!("{}/authorize", state.base_url),
|
|
"token_endpoint": format!("{}/token", state.base_url),
|
|
"registration_endpoint": format!("{}/register", state.base_url),
|
|
"response_types_supported": ["code"],
|
|
"grant_types_supported": ["authorization_code"],
|
|
"code_challenge_methods_supported": ["S256"],
|
|
"scopes_supported": ["read", "write"]
|
|
}))
|
|
}
|
|
|
|
// ── OAuth DCR ───────────────────────────────────────────────────────────
|
|
|
|
async fn handle_register() -> impl IntoResponse {
|
|
Json(serde_json::json!({
|
|
"client_id": "mock-client-id",
|
|
"client_name": "ironclaw-test",
|
|
"redirect_uris": [],
|
|
"grant_types": ["authorization_code"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none"
|
|
}))
|
|
}
|
|
|
|
// ── OAuth authorize (auto-approve) ──────────────────────────────────────
|
|
|
|
/// In a real flow, this would show a consent screen. For testing, we just
|
|
/// need the endpoint to exist. The test will bypass OAuth by injecting
|
|
/// tokens directly.
|
|
async fn handle_authorize() -> impl IntoResponse {
|
|
// Return a simple HTML page; in practice the test injects tokens directly.
|
|
axum::response::Html(
|
|
"<html><body>Mock OAuth: authorize endpoint. Tests bypass this.</body></html>",
|
|
)
|
|
}
|
|
|
|
// ── OAuth token exchange ────────────────────────────────────────────────
|
|
|
|
async fn handle_token() -> impl IntoResponse {
|
|
Json(serde_json::json!({
|
|
"access_token": "mock-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "mock-refresh-token"
|
|
}))
|
|
}
|
|
|
|
// ── MCP JSON-RPC endpoint ───────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct JsonRpcRequest {
|
|
jsonrpc: String,
|
|
id: Option<serde_json::Value>,
|
|
method: String,
|
|
#[serde(default)]
|
|
params: Option<serde_json::Value>,
|
|
}
|
|
|
|
async fn handle_mcp(
|
|
State(state): State<Arc<MockState>>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<JsonRpcRequest>,
|
|
) -> impl IntoResponse {
|
|
// Check for auth header.
|
|
let auth = headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
|
|
if !auth.starts_with("Bearer ") || &auth[7..] != "mock-access-token" {
|
|
// Return 401 with WWW-Authenticate header per MCP OAuth spec.
|
|
let www_auth = format!(
|
|
"Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource/mcp\"",
|
|
state.base_url
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
[("www-authenticate", www_auth.as_str())],
|
|
Json(serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": req.id,
|
|
"error": {"code": -32000, "message": "Unauthorized"}
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Handle notifications (no id) silently.
|
|
if req.id.is_none() {
|
|
return StatusCode::OK.into_response();
|
|
}
|
|
|
|
let response = match req.method.as_str() {
|
|
"initialize" => serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": req.id,
|
|
"result": {
|
|
"protocolVersion": "2024-11-05",
|
|
"serverInfo": {
|
|
"name": "mock-mcp-server",
|
|
"version": "1.0.0"
|
|
},
|
|
"capabilities": {
|
|
"tools": {}
|
|
}
|
|
}
|
|
}),
|
|
"tools/list" => {
|
|
let tools: Vec<serde_json::Value> = state
|
|
.tools
|
|
.iter()
|
|
.map(|t| serde_json::to_value(t).unwrap())
|
|
.collect();
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": req.id,
|
|
"result": {
|
|
"tools": tools
|
|
}
|
|
})
|
|
}
|
|
"tools/call" => {
|
|
let tool_name = req
|
|
.params
|
|
.as_ref()
|
|
.and_then(|p| p.get("name"))
|
|
.and_then(|n| n.as_str())
|
|
.unwrap_or("unknown");
|
|
|
|
let content = {
|
|
let mut idx_map = state.tool_response_idx.lock().unwrap();
|
|
let idx = idx_map.entry(tool_name.to_string()).or_insert(0);
|
|
let responses = state.tool_responses.get(tool_name);
|
|
let result = responses
|
|
.and_then(|r| r.get(*idx))
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!({"error": "no mock response configured"}));
|
|
*idx += 1;
|
|
result
|
|
};
|
|
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": req.id,
|
|
"result": {
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": serde_json::to_string(&content).unwrap_or_default()
|
|
}
|
|
]
|
|
}
|
|
})
|
|
}
|
|
_ => serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": req.id,
|
|
"error": {"code": -32601, "message": format!("Method not found: {}", req.method)}
|
|
}),
|
|
};
|
|
|
|
Json(response).into_response()
|
|
}
|