diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 6e1174aae..b61371ace 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -2,14 +2,11 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; -use fabro_config::{ - CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, - RunSandboxLayer, parse_input_overrides, -}; +use fabro_config::{CliLayer, CliOutputLayer, RunGoalLayer, RunLayer, parse_input_overrides}; +use fabro_manifest::{RunOverrideInput, build_run_overrides}; use fabro_sandbox::SandboxProvider; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; -use fabro_types::settings::run::{ApprovalMode, RunMode}; use crate::args::{PreflightArgs, RunArgs}; @@ -32,47 +29,6 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap { .collect() } -fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option { - if model.is_none() && provider.is_none() { - return None; - } - Some(RunModelLayer { - provider: provider.map(InterpString::parse), - name: model.map(InterpString::parse), - fallbacks: Vec::new(), - }) -} - -fn sandbox_layer( - sandbox: Option, - preserve: Option, -) -> Option { - if sandbox.is_none() && preserve.is_none() { - return None; - } - Some(RunSandboxLayer { - provider: sandbox.map(|p| p.to_string()), - preserve, - ..RunSandboxLayer::default() - }) -} - -fn execution_layer(dry_run: Option, auto_approve: Option) -> Option { - if dry_run.is_none() && auto_approve.is_none() { - return None; - } - Some(RunExecutionLayer { - mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }), - approval: auto_approve.map(|a| { - if a { - ApprovalMode::Auto - } else { - ApprovalMode::Prompt - } - }), - }) -} - fn cli_layer_for_verbose(verbose: bool) -> Option { verbose.then(|| CliLayer { output: Some(CliOutputLayer { @@ -119,24 +75,21 @@ fn current_dir_or_dot() -> PathBuf { } pub(crate) fn run_args_overrides(args: &RunArgs) -> Result { - let model = model_from_args(args.model.as_deref(), args.provider.as_deref()); - let sandbox = sandbox_layer( - args.sandbox.map(Into::into), - sparse_flag(args.preserve_sandbox), - ); - let execution = execution_layer(sparse_flag(args.dry_run), sparse_flag(args.auto_approve)); - let cwd = current_dir_or_dot(); let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; - - let run = RunLayer { - goal, - metadata: ReplaceMap::from(parse_labels(&args.label)), - model, - sandbox, - execution, - ..RunLayer::default() - }; + let sandbox = args.sandbox.map(SandboxProvider::from); + let sandbox_provider = sandbox.as_ref().map(ToString::to_string); + let mut run = build_run_overrides(RunOverrideInput { + goal: None, + model: args.model.as_deref(), + provider: args.provider.as_deref(), + sandbox: sandbox_provider.as_deref(), + preserve_sandbox: sparse_flag(args.preserve_sandbox), + dry_run: sparse_flag(args.dry_run), + auto_approve: sparse_flag(args.auto_approve), + labels: parse_labels(&args.label), + }); + run.goal = goal; Ok(ManifestSettingsOverrides { run: Some(run), @@ -146,21 +99,22 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result Result { - let model = model_from_args(args.model.as_deref(), args.provider.as_deref()); - let sandbox = args.sandbox.map(|s| RunSandboxLayer { - provider: Some(SandboxProvider::from(s).to_string()), - ..RunSandboxLayer::default() - }); - let cwd = current_dir_or_dot(); let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; - - let run = RunLayer { - goal, - model, - sandbox, - ..RunLayer::default() - }; + let sandbox_provider = args + .sandbox + .map(|sandbox| SandboxProvider::from(sandbox).to_string()); + let mut run = build_run_overrides(RunOverrideInput { + goal: None, + model: args.model.as_deref(), + provider: args.provider.as_deref(), + sandbox: sandbox_provider.as_deref(), + preserve_sandbox: None, + dry_run: None, + auto_approve: None, + labels: HashMap::new(), + }); + run.goal = goal; Ok(ManifestSettingsOverrides { run: Some(run), diff --git a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs index 50776157d..1408d40fc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs @@ -12,6 +12,8 @@ use std::io::{BufRead as _, Write as _}; use std::path::{Path, PathBuf}; use std::process::Stdio; +use chrono::{Duration as ChronoDuration, Utc}; +use fabro_client::{AuthEntry, AuthStore, OAuthEntry, StoredSubject}; use fabro_mcp::client::McpClient; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; @@ -659,6 +661,100 @@ async fn mcp_search_includes_archived_runs_by_default() { .expect("MCP client should shut down"); } +#[tokio::test(flavor = "multi_thread")] +async fn mcp_search_refreshes_expired_oauth_token() { + let context = test_context!(); + let server = MockServer::start(); + let target_url = format!("{}/api/v1", server.base_url()); + let target: fabro_client::ServerTarget = target_url.parse().unwrap(); + seed_oauth_auth( + &context.home_dir, + &target, + "expired-access", + "refresh-octocat", + ); + let run_id = unique_run_id(); + let expired_access = server.mock(|when, then| { + when.method(GET) + .path("/api/v1/runs") + .header("authorization", "Bearer expired-access"); + then.status(401) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "errors": [{ + "detail": "access token expired", + "code": "access_token_expired" + }] + })); + }); + let refresh = server.mock(|when, then| { + when.method(POST) + .path("/auth/cli/refresh") + .header("authorization", "Bearer refresh-octocat"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "access_token": "fresh-access", + "access_token_expires_at": (Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(), + "refresh_token": "fresh-refresh", + "refresh_token_expires_at": (Utc::now() + ChronoDuration::days(30)).to_rfc3339(), + "subject": { + "idp_issuer": "https://github.com", + "idp_subject": "12345", + "login": "octocat", + "name": "The Octocat", + "email": "octocat@example.com" + } + })); + }); + let fresh_access = server.mock(|when, then| { + when.method(GET) + .path("/api/v1/runs") + .header("authorization", "Bearer fresh-access") + .query_param("include_archived", "true") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "data": [remote_run_summary_json( + &run_id, + "Simple", + "simple", + "OAuth refreshed", + &serde_json::json!({ "kind": "submitted" }), + "2026-04-05T12:00:00Z", + )], + "meta": { "has_more": false } + })); + }); + let client = spawn_mcp_client(&context, &["--server", &target_url]).await; + + let result = call_tool_json( + &client, + "fabro_run_search", + serde_json::json!({ "run_ids": [run_id], "first": 1 }), + ) + .await; + + assert_eq!(result["runs"][0]["run_id"], run_id); + expired_access.assert(); + refresh.assert(); + fresh_access.assert(); + let stored = AuthStore::new(context.home_dir.join(".fabro/auth.json")) + .get(&target) + .unwrap() + .unwrap(); + let AuthEntry::OAuth(stored) = stored else { + panic!("expected refreshed OAuth entry"); + }; + assert_eq!(stored.access_token, "fresh-access"); + client + .shutdown() + .await + .expect("MCP client should shut down"); +} + #[tokio::test(flavor = "multi_thread")] async fn mcp_search_orders_by_started_timestamp_before_created_timestamp() { let context = test_context!(); @@ -1496,6 +1592,7 @@ async fn mcp_events_desc_after_offset_and_limit_page_over_requested_order() { .await; assert_eq!(desc["events"][0]["event_id"], "evt-5"); + assert_eq!(desc["next_cursor"], 5); assert_eq!(paged["events"][0]["event_id"], "evt-3"); assert_eq!(paged["events"][1]["event_id"], "evt-4"); assert_eq!(paged["next_cursor"], 5); @@ -1509,6 +1606,80 @@ async fn mcp_events_desc_after_offset_and_limit_page_over_requested_order() { .expect("MCP client should shut down"); } +#[tokio::test(flavor = "multi_thread")] +async fn mcp_events_desc_cursor_continues_to_older_events() { + let context = test_context!(); + let server = MockServer::start(); + let target_url = format!("{}/api/v1", server.base_url()); + let target: fabro_client::ServerTarget = target_url.parse().unwrap(); + seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN); + let run_id = unique_run_id(); + let resolve = mock_resolved_run(&server, "nightly", &run_id); + let events = (1..=5) + .map(|sequence| { + serde_json::json!({ + "seq": sequence, + "id": format!("evt-{sequence}"), + "ts": format!("2026-04-05T12:00:0{sequence}Z"), + "run_id": run_id, + "event": "run.started", + "properties": { "name": format!("event {sequence}") }, + "actor": null + }) + }) + .collect::>(); + let full_events = server.mock(|when, then| { + when.method(GET) + .path(format!("/api/v1/runs/{run_id}/events")) + .query_param_missing("limit") + .query_param_missing("since_seq"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "data": events, + "meta": { "has_more": false } + })); + }); + let client = spawn_mcp_client(&context, &["--server", &target_url]).await; + + let first_page = call_tool_json( + &client, + "fabro_run_events", + serde_json::json!({ + "run_id": "nightly", + "action": "list", + "direction": "desc", + "first": 2 + }), + ) + .await; + let second_page = call_tool_json( + &client, + "fabro_run_events", + serde_json::json!({ + "run_id": "nightly", + "action": "list", + "direction": "desc", + "after": first_page["next_cursor"], + "first": 2 + }), + ) + .await; + + assert_eq!(first_page["events"][0]["event_id"], "evt-5"); + assert_eq!(first_page["events"][1]["event_id"], "evt-4"); + assert_eq!(first_page["next_cursor"], 4); + assert_eq!(second_page["events"][0]["event_id"], "evt-3"); + assert_eq!(second_page["events"][1]["event_id"], "evt-2"); + assert_eq!(second_page["next_cursor"], 2); + resolve.assert_calls(2); + full_events.assert_calls(2); + client + .shutdown() + .await + .expect("MCP client should shut down"); +} + #[tokio::test(flavor = "multi_thread")] async fn mcp_events_offset_beyond_fetch_cap_reaches_later_pages() { let context = test_context!(); @@ -1745,6 +1916,34 @@ async fn create_mcp_run(client: &McpClient, workflow: PathBuf, start: bool) -> S .to_string() } +fn seed_oauth_auth( + home_dir: &Path, + target: &fabro_client::ServerTarget, + access_token: &str, + refresh_token: &str, +) { + let now = Utc::now(); + AuthStore::new(home_dir.join(".fabro/auth.json")) + .put( + target, + AuthEntry::OAuth(OAuthEntry { + access_token: access_token.to_string(), + access_token_expires_at: now - ChronoDuration::minutes(1), + refresh_token: refresh_token.to_string(), + refresh_token_expires_at: now + ChronoDuration::days(30), + subject: StoredSubject { + idp_issuer: "https://github.com".to_string(), + idp_subject: "12345".to_string(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + }, + logged_in_at: now, + }), + ) + .unwrap_or_else(|err| panic!("failed to seed OAuth auth: {err}")); +} + fn normalize_run_search(mut value: serde_json::Value) -> serde_json::Value { if let Some(runs) = value["runs"].as_array_mut() { for run in runs { diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index 5757d4b5b..7cd5ebb17 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/fabro-manifest/src/lib.rs @@ -10,11 +10,15 @@ use anyhow::{Context, Result, anyhow}; use fabro_api::types; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace}; -use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder}; +use fabro_config::{ + CliLayer, DaytonaDockerfileLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, + RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder, +}; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_template::{TemplateContext, render as render_template}; -use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal}; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode}; use fabro_types::{DirtyStatus, GitContext, PreRunPushOutcome, RunId, WorkflowSettings}; use fabro_workflow::ManifestPath; use fabro_workflow::git::{ @@ -41,6 +45,73 @@ pub struct BuiltManifest { pub target_path: PathBuf, } +#[derive(Debug, Default)] +pub struct RunOverrideInput<'a> { + pub goal: Option<&'a str>, + pub model: Option<&'a str>, + pub provider: Option<&'a str>, + pub sandbox: Option<&'a str>, + pub preserve_sandbox: Option, + pub dry_run: Option, + pub auto_approve: Option, + pub labels: HashMap, +} + +#[must_use] +pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer { + let goal = input + .goal + .map(|goal| RunGoalLayer::Inline(InterpString::parse(goal))); + let model = (input.model.is_some() || input.provider.is_some()).then(|| RunModelLayer { + provider: input.provider.map(InterpString::parse), + name: input.model.map(InterpString::parse), + fallbacks: Vec::new(), + }); + let sandbox = + (input.sandbox.is_some() || input.preserve_sandbox.is_some()).then(|| RunSandboxLayer { + provider: input.sandbox.map(ToOwned::to_owned), + preserve: input.preserve_sandbox, + ..RunSandboxLayer::default() + }); + let execution = + (input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer { + mode: input.dry_run.map(|dry_run| { + if dry_run { + RunMode::DryRun + } else { + RunMode::Normal + } + }), + approval: input.auto_approve.map(|auto_approve| { + if auto_approve { + ApprovalMode::Auto + } else { + ApprovalMode::Prompt + } + }), + }); + + RunLayer { + goal, + metadata: ReplaceMap::from(input.labels), + model, + sandbox, + execution, + ..RunLayer::default() + } +} + +#[must_use] +pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option { + let run = build_run_overrides(input); + (run.goal.is_some() + || !run.metadata.is_empty() + || run.model.is_some() + || run.sandbox.is_some() + || run.execution.is_some()) + .then_some(run) +} + struct CollectContext<'a> { cwd: &'a Path, inputs: &'a HashMap, @@ -629,6 +700,64 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool { mod tests { use super::*; + #[test] + fn build_run_overrides_sets_common_cli_and_mcp_layers() { + let overrides = build_run_overrides(RunOverrideInput { + goal: Some("ship it"), + model: Some("gpt-5.4-mini"), + provider: Some("openai"), + sandbox: Some("local"), + preserve_sandbox: Some(true), + dry_run: Some(true), + auto_approve: Some(false), + labels: [("source".to_string(), "mcp".to_string())] + .into_iter() + .collect(), + }); + + let goal = overrides.goal.expect("goal override"); + assert!(matches!(goal, fabro_config::RunGoalLayer::Inline(_))); + assert_eq!( + overrides + .model + .as_ref() + .unwrap() + .name + .as_ref() + .unwrap() + .as_source(), + "gpt-5.4-mini" + ); + assert_eq!( + overrides + .model + .as_ref() + .unwrap() + .provider + .as_ref() + .unwrap() + .as_source(), + "openai" + ); + assert_eq!( + overrides.sandbox.as_ref().unwrap().provider.as_deref(), + Some("local") + ); + assert_eq!(overrides.sandbox.as_ref().unwrap().preserve, Some(true)); + assert_eq!( + overrides.execution.as_ref().unwrap().mode, + Some(RunMode::DryRun) + ); + assert_eq!( + overrides.execution.as_ref().unwrap().approval, + Some(ApprovalMode::Prompt) + ); + assert_eq!( + overrides.metadata.0.get("source").map(String::as_str), + Some("mcp") + ); + } + #[test] fn build_manifest_bundles_imports_prompts_and_children() { let temp = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-mcp-server/src/run_tools.rs b/lib/crates/fabro-mcp-server/src/run_tools.rs index 81248a107..88f03e820 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools.rs @@ -11,13 +11,12 @@ use std::time::{Duration, Instant}; use chrono::{DateTime, NaiveDate, Utc}; use fabro_api::types; use fabro_client::Client; -use fabro_config::{ - CliLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer, +use fabro_config::{CliLayer, RunLayer}; +use fabro_manifest::{ + ManifestBuildInput, RunOverrideInput, build_run_manifest as build_canonical_run_manifest, + build_sparse_run_overrides, }; -use fabro_manifest::{ManifestBuildInput, build_run_manifest as build_canonical_run_manifest}; use fabro_server::manifest_validation; -use fabro_types::settings::InterpString; -use fabro_types::settings::run::{ApprovalMode, RunMode}; use fabro_types::{EventEnvelope, Run, RunId, RunStatus}; use fabro_util::exit::{self, ExitClass}; use rmcp::model::{CallToolResult, Content}; @@ -596,12 +595,19 @@ pub(crate) async fn run_events( .await .map_err(|err| ToolError::from_anyhow(&err))? .id; + let descending = raw.direction.as_deref() == Some("desc"); + let fetch_after = if descending { None } else { raw.after }; let mut events = client - .list_run_events(&run_id, raw.after, event_fetch_limit(&raw)) + .list_run_events(&run_id, fetch_after, event_fetch_limit(&raw)) .await .map_err(|err| ToolError::from_anyhow(&err))?; + if descending { + if let Some(after) = raw.after { + events.retain(|event| event.seq < after); + } + } filter_events(&mut events, &raw)?; - if raw.direction.as_deref() == Some("desc") { + if descending { events.reverse(); } let offset = raw.offset.unwrap_or(0); @@ -616,7 +622,13 @@ pub(crate) async fn run_events( .iter() .map(|event| run_event_result(event, max_content_length)) .collect::>>()?; - let next_cursor = page.last().map(|event| event.seq.saturating_add(1)); + let next_cursor = page.last().map(|event| { + if descending { + event.seq + } else { + event.seq.saturating_add(1) + } + }); Ok(RunEventsResult { run_id: run_id.to_string(), @@ -902,52 +914,16 @@ fn mcp_manifest_args(spec: &CreateRunSpec) -> Option { } fn mcp_run_overrides(spec: &CreateRunSpec) -> Option { - let goal = spec - .goal - .as_ref() - .map(|goal| RunGoalLayer::Inline(InterpString::parse(goal))); - let model = (spec.model.is_some() || spec.provider.is_some()).then(|| RunModelLayer { - provider: spec.provider.as_deref().map(InterpString::parse), - name: spec.model.as_deref().map(InterpString::parse), - fallbacks: Vec::new(), - }); - let sandbox = - (spec.sandbox.is_some() || spec.preserve_sandbox.is_some()).then(|| RunSandboxLayer { - provider: spec.sandbox.clone(), - preserve: spec.preserve_sandbox, - ..RunSandboxLayer::default() - }); - let execution = - (spec.dry_run.is_some() || spec.auto_approve.is_some()).then(|| RunExecutionLayer { - mode: spec.dry_run.map(|dry_run| { - if dry_run { - RunMode::DryRun - } else { - RunMode::Normal - } - }), - approval: spec.auto_approve.map(|auto_approve| { - if auto_approve { - ApprovalMode::Auto - } else { - ApprovalMode::Prompt - } - }), - }); - let run = RunLayer { - goal, - metadata: ReplaceMap::from(spec.labels.clone()), - model, - sandbox, - execution, - ..RunLayer::default() - }; - (run.goal.is_some() - || !run.metadata.is_empty() - || run.model.is_some() - || run.sandbox.is_some() - || run.execution.is_some()) - .then_some(run) + build_sparse_run_overrides(RunOverrideInput { + goal: spec.goal.as_deref(), + model: spec.model.as_deref(), + provider: spec.provider.as_deref(), + sandbox: spec.sandbox.as_deref(), + preserve_sandbox: spec.preserve_sandbox, + dry_run: spec.dry_run, + auto_approve: spec.auto_approve, + labels: spec.labels.clone(), + }) } fn mcp_manifest_args_is_empty(args: &types::ManifestArgs) -> bool { diff --git a/lib/crates/fabro-mcp-server/src/server.rs b/lib/crates/fabro-mcp-server/src/server.rs index 1eead36a4..5d34b0917 100644 --- a/lib/crates/fabro-mcp-server/src/server.rs +++ b/lib/crates/fabro-mcp-server/src/server.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use fabro_client::{ - AuthEntry, AuthStore, Client, Credential, ServerTarget, TransportConnector, + AuthEntry, AuthStore, Client, Credential, OAuthSession, ServerTarget, TransportConnector, apply_bearer_token_auth, }; use fabro_config::bind::Bind; @@ -194,15 +194,15 @@ async fn client_from_settings(settings: &McpServerSettings) -> Result { async fn connect_target(server: &str, settings: &McpServerSettings) -> Result { let target: ServerTarget = server.parse()?; - let mut credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json")) - .get(&target)? - .map(credential_from_auth_entry); + let auth_store = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json")); + let mut credential = resolve_target_credential_with_store(&target, &auth_store)?; if credential.is_none() && target.is_unix_socket() { let runtime_token_path = Storage::new(&settings.storage_dir) .runtime_directory() .dev_token_path(); credential = dev_token::read_dev_token_file(&runtime_token_path).map(Credential::DevToken); } + let oauth_session = refreshable_oauth(&target, &auth_store, credential.as_ref()); let mut builder = Client::builder() .target(target.clone()) .transport_connector(target_transport_connector(target)) @@ -210,6 +210,9 @@ async fn connect_target(server: &str, settings: &McpServerSettings) -> Result Result { } Bind::Tcp(addr) => { let target = ServerTarget::http_url(format!("http://{addr}"))?; - let credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json")) - .get(&target)? - .map(credential_from_auth_entry); + let auth_store = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json")); + let credential = resolve_target_credential_with_store(&target, &auth_store)?; + let oauth_session = refreshable_oauth(&target, &auth_store, credential.as_ref()); let mut builder = Client::builder() .target(target.clone()) .transport_connector(target_transport_connector(target)) @@ -245,6 +248,9 @@ async fn connect_local_server(settings: &McpServerSettings) -> Result { if let Some(credential) = credential { builder = builder.credential(credential); } + if let Some(oauth_session) = oauth_session { + builder = builder.oauth_session(oauth_session); + } builder.connect().await } } @@ -299,13 +305,34 @@ async fn wait_for_runtime_dev_token(path: &Path) -> Result { )) } -fn credential_from_auth_entry(entry: AuthEntry) -> Credential { +fn resolve_target_credential_with_store( + target: &ServerTarget, + store: &AuthStore, +) -> Result> { + let Some(entry) = store.get(target)? else { + return Ok(None); + }; + let now = chrono::Utc::now(); match entry { - AuthEntry::OAuth(entry) => Credential::OAuth(entry), - AuthEntry::DevToken(entry) => Credential::DevToken(entry.token), + AuthEntry::DevToken(entry) => Ok(Some(Credential::DevToken(entry.token))), + AuthEntry::OAuth(entry) + if entry.access_token_expires_at > now || entry.refresh_token_expires_at > now => + { + Ok(Some(Credential::OAuth(entry))) + } + AuthEntry::OAuth(_) => Ok(None), } } +fn refreshable_oauth( + target: &ServerTarget, + auth_store: &AuthStore, + credential: Option<&Credential>, +) -> Option { + matches!(credential, Some(Credential::OAuth(_))) + .then(|| OAuthSession::new(target.clone(), auth_store.clone())) +} + fn target_transport_connector(target: ServerTarget) -> TransportConnector { TransportConnector::new(move |bearer_token| { let target = target.clone();