Skip to content

Commit a3871a0

Browse files
committed
fix(mcp-oauth): resilient config resolution + reduce config_source spawn amplification + cache OAuth discovery (TASK-326)
1 parent f639105 commit a3871a0

11 files changed

Lines changed: 665 additions & 64 deletions

File tree

crates/animus-mcp-oauth/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ rmcp = { version = "1.7", features = [
3333

3434
[dev-dependencies]
3535
protocol = { workspace = true, features = ["test-utils"] }
36+
orchestrator-config = { workspace = true, features = ["test-utils"] }
3637
tempfile = "3"
3738
axum = "0.8"
3839
http = "1"

crates/animus-mcp-oauth/src/client.rs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,33 +59,55 @@ impl McpSession {
5959
timeout: Duration,
6060
) -> Result<Self> {
6161
crate::ensure_crypto_provider();
62+
// The ONE `config_source` resolution per `mcp call`. The resolved URL +
63+
// flow are threaded to the spawned proxy (`--url` + `--auth-code`) so the
64+
// proxy TRUSTS them and does NOT re-resolve — collapsing the historical
65+
// 3× config_source spawn amplification (CLI + proxy-bin + proxy-connect)
66+
// down to this single touch.
6267
let resolution = resolve_server_url(project_root, server, url_override)?;
6368
// OAuth servers (every flow) are served through the local proxy, which
6469
// resolves + injects the live bearer itself; plain-HTTP servers are
6570
// connected directly.
6671
let uses_oauth = resolution.is_authorization_code || resolution.broker_oauth.is_some();
6772
if uses_oauth {
68-
Self::connect_via_proxy(project_root, server, url_override, timeout).await
73+
Self::connect_via_proxy(project_root, server, &resolution, timeout).await
6974
} else {
7075
Self::connect_http(&resolution.url, timeout).await
7176
}
7277
}
7378

74-
/// Spawn `animus-mcp-proxy --server <server> [--url <url>] --project-root
75-
/// <root>` and connect an MCP client to its stdio. The proxy injects the
76-
/// cached bearer; no secret is passed on argv or read here. stderr is
77-
/// inherited so proxy diagnostics (e.g. "run `animus mcp auth`") reach the
78-
/// user; stdout carries only JSON-RPC frames.
79+
/// Spawn `animus-mcp-proxy --server <server> --url <resolved-url>
80+
/// [--auth-code] --project-root <root>` and connect an MCP client to its
81+
/// stdio. The proxy injects the cached bearer; no secret is passed on argv
82+
/// or read here. stderr is inherited so proxy diagnostics (e.g. "run `animus
83+
/// mcp auth`") reach the user; stdout carries only JSON-RPC frames.
84+
///
85+
/// Passing the already-resolved `--url` (and, for keychain servers,
86+
/// `--auth-code`) lets the proxy skip its own `config_source` lookup — so a
87+
/// bulk `mcp call` burst does one source resolution per call, here, instead
88+
/// of one per proxy spawn.
7989
async fn connect_via_proxy(
8090
project_root: &Path,
8191
server: &str,
82-
url_override: Option<&str>,
92+
resolution: &crate::config::ServerResolution,
8393
timeout: Duration,
8494
) -> Result<Self> {
8595
let mut cmd = Command::new(mcp_proxy_command());
8696
cmd.arg("--server").arg(server).arg("--project-root").arg(project_root);
87-
if let Some(url) = url_override.filter(|u| !u.trim().is_empty()) {
88-
cmd.arg("--url").arg(url);
97+
// Pass the resolved `--url` for BOTH flows so the proxy binds the exact
98+
// upstream the parent selected (honoring a `--url` override) instead of
99+
// re-resolving the server name to a possibly-different same-named entry.
100+
if !resolution.url.trim().is_empty() {
101+
cmd.arg("--url").arg(&resolution.url);
102+
}
103+
// `--auth-code` is added ONLY for keychain (`authorization_code`) servers:
104+
// it tells the proxy to trust `--url` and skip its own config_source
105+
// lookup entirely (the amplification cut). Broker flows omit it because
106+
// they still need their full oauth block from config; the proxy re-resolves
107+
// those — and if the source is down it errors cleanly (ConfigSourceUnavailable)
108+
// rather than misrouting the broker server to the keychain path.
109+
if resolution.is_authorization_code {
110+
cmd.arg("--auth-code");
89111
}
90112
// `TokioChildProcess::new` pipes stdin/stdout and inherits stderr; the
91113
// child is killed on drop of the returned transport (held by the

crates/animus-mcp-oauth/src/config.rs

Lines changed: 129 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::path::Path;
66
use std::sync::Arc;
77

88
use orchestrator_config::workflow_config::{
9-
load_workflow_config_or_default, load_workflow_config_with_metadata, OauthConfig, OauthFlow,
9+
try_load_workflow_config, OauthConfig, OauthFlow, WorkflowConfigAvailability,
1010
};
1111
use orchestrator_core::SecretStore;
1212
use protocol::repository_scope::{repository_scope_for_path, scoped_state_root};
@@ -21,6 +21,13 @@ pub enum ServerResolutionError {
2121
UnknownServer(String),
2222
#[error("MCP server `{0}` has no `url`; an HTTP-transport URL is required for OAuth")]
2323
MissingUrl(String),
24+
/// The `config_source` failed to load (spawn / RPC / DB overload / validation),
25+
/// so the server's config could NOT be determined. This is distinct from
26+
/// [`UnknownServer`], which fires only when the config loaded and the server
27+
/// is genuinely absent. Callers should retry / report a transient source
28+
/// error rather than "server not configured".
29+
#[error("MCP server `{0}` could not be resolved: the workflow config source failed to load ({1}). This is a config-source error, not a missing-server error — retry shortly or check the `config_source` plugin.")]
30+
ConfigSourceUnavailable(String, String),
2431
#[error("failed to load workflow config: {0}")]
2532
WorkflowConfig(String),
2633
#[error("failed to load project config: {0}")]
@@ -99,22 +106,30 @@ pub fn resolve_server_url(
99106
) -> Result<ServerResolution, ServerResolutionError> {
100107
// Workflow config mcp_servers first (authoritative for daemon runs).
101108
//
102-
// A malformed `.animus/workflows.yaml` must surface its YAML/validation
103-
// error rather than silently falling back to the builtin default (which
104-
// would mislead the user into "unknown server"). But an *absent* workflow
105-
// config is normal (project-config-only setups), and
106-
// `load_workflow_config_with_metadata` returns a "missing" error in that
107-
// case. So: only propagate the load error when a workflow YAML source
108-
// actually exists; otherwise fall back to the builtin default and continue
109-
// to project config.
110-
let loaded = match load_workflow_config_with_metadata(project_root, None) {
111-
Ok(loaded) => loaded,
112-
Err(err) if workflow_yaml_present(project_root) => {
113-
return Err(ServerResolutionError::WorkflowConfig(err.to_string()));
109+
// Use the NON-SWALLOWING loader so a transient `config_source` failure (DB
110+
// overload under bulk `mcp call`) is NOT degraded into an empty config that
111+
// then misreports the server as "not defined". `try_load_workflow_config`
112+
// distinguishes three cases:
113+
// * Loaded — use its `mcp_servers`.
114+
// * NoSource — no config_source configured; benign, fall through to
115+
// project config (project-config-only setups).
116+
// * SourceUnavailable — the source failed; surface a DISTINCT retryable
117+
// error. We do this EVEN when the caller supplied a
118+
// `--url`: a `--url` overrides only the upstream
119+
// endpoint, not the server's oauth FLOW/block, so
120+
// synthesizing an `authorization_code` resolution from
121+
// the URL alone would misroute a broker-flow server
122+
// (`manual_bearer` / `client_credentials` /
123+
// `refresh_token`) to the keychain path. Erroring lets
124+
// the caller retry once the source recovers.
125+
let loaded = match try_load_workflow_config(project_root, None) {
126+
WorkflowConfigAvailability::Loaded(loaded) => Some(loaded),
127+
WorkflowConfigAvailability::NoSource => None,
128+
WorkflowConfigAvailability::SourceUnavailable(err) => {
129+
return Err(ServerResolutionError::ConfigSourceUnavailable(server.to_string(), err.to_string()));
114130
}
115-
Err(_) => load_workflow_config_or_default(project_root),
116131
};
117-
if let Some(def) = loaded.config.mcp_servers.get(server) {
132+
if let Some(def) = loaded.as_ref().and_then(|loaded| loaded.config.mcp_servers.get(server)) {
118133
return finalize(server, url_override, def.url.clone(), def.oauth.clone());
119134
}
120135

@@ -139,21 +154,6 @@ pub fn resolve_server_url(
139154
}
140155
}
141156

142-
/// True when a workflow YAML source exists at `.animus/workflows.yaml` or
143-
/// any `.animus/workflows/*.yaml`. Used to decide whether a workflow-config
144-
/// load failure is a real (malformed-config) error worth propagating vs the
145-
/// benign "no workflow config" case.
146-
fn workflow_yaml_present(project_root: &Path) -> bool {
147-
let animus = project_root.join(".animus");
148-
if animus.join("workflows.yaml").exists() {
149-
return true;
150-
}
151-
let dir = animus.join("workflows");
152-
std::fs::read_dir(&dir)
153-
.map(|entries| entries.flatten().any(|e| e.path().extension().is_some_and(|ext| ext == "yaml" || ext == "yml")))
154-
.unwrap_or(false)
155-
}
156-
157157
fn finalize(
158158
server: &str,
159159
url_override: Option<&str>,
@@ -188,3 +188,102 @@ fn finalize(
188188
}),
189189
}
190190
}
191+
192+
#[cfg(test)]
193+
mod tests {
194+
use super::*;
195+
use orchestrator_config::workflow_config::builtin_workflow_config;
196+
use orchestrator_config::workflow_config::config_source_client::test_seam;
197+
use std::sync::{Mutex, MutexGuard, OnceLock};
198+
199+
// These tests mutate the process-global config_source test seam + HOME env,
200+
// so serialize them under one lock held for the whole test body.
201+
fn serial() -> &'static Mutex<()> {
202+
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
203+
LOCK.get_or_init(|| Mutex::new(()))
204+
}
205+
206+
/// RAII guard that points `HOME` at a temp dir for the test and RESTORES the
207+
/// previous value on drop, so the mutation never leaks to sibling tests.
208+
struct HomeGuard {
209+
prev: Option<std::ffi::OsString>,
210+
}
211+
212+
impl HomeGuard {
213+
fn set(path: &Path) -> Self {
214+
let prev = std::env::var_os("HOME");
215+
std::env::set_var("HOME", path);
216+
Self { prev }
217+
}
218+
}
219+
220+
impl Drop for HomeGuard {
221+
fn drop(&mut self) {
222+
match self.prev.take() {
223+
Some(v) => std::env::set_var("HOME", v),
224+
None => std::env::remove_var("HOME"),
225+
}
226+
}
227+
}
228+
229+
/// Common fixture: hold the serial lock, isolate `HOME` to a fresh tempdir
230+
/// (restored on drop). Bound in the caller in declaration order so the
231+
/// tempdir drops LAST — after `HomeGuard` has restored `HOME`.
232+
fn fixture() -> (MutexGuard<'static, ()>, tempfile::TempDir, HomeGuard) {
233+
let guard = serial().lock().unwrap_or_else(|p| p.into_inner());
234+
let temp = tempfile::tempdir().expect("tempdir");
235+
let home = HomeGuard::set(temp.path());
236+
(guard, temp, home)
237+
}
238+
239+
/// TASK-326 (fix d): a transient config_source failure must surface as the
240+
/// DISTINCT `ConfigSourceUnavailable`, NOT `UnknownServer` — otherwise the
241+
/// caller concludes "krisp isn't configured" when the source is merely down.
242+
#[test]
243+
fn source_failure_yields_config_source_unavailable_not_unknown_server() {
244+
let (_guard, temp, _home) = fixture();
245+
let root = temp.path();
246+
247+
let _fail = test_seam::install_failure(root, "config_source RPC timed out");
248+
let err = resolve_server_url(root, "krisp", None).expect_err("must fail when source is down");
249+
assert!(
250+
matches!(err, ServerResolutionError::ConfigSourceUnavailable(ref s, _) if s == "krisp"),
251+
"expected ConfigSourceUnavailable, got {err:?}"
252+
);
253+
}
254+
255+
/// Even with an explicit `--url`, a source outage yields
256+
/// `ConfigSourceUnavailable` (never a synthesized `authorization_code`
257+
/// resolution): a `--url` overrides only the upstream endpoint, not the
258+
/// server's oauth FLOW, so guessing the flow could misroute a broker server
259+
/// to the keychain path. The caller retries once the source recovers.
260+
#[test]
261+
fn explicit_url_still_errors_when_source_unavailable() {
262+
let (_guard, temp, _home) = fixture();
263+
let root = temp.path();
264+
265+
let _fail = test_seam::install_failure(root, "config_source RPC timed out");
266+
let err = resolve_server_url(root, "krisp", Some("https://example.test/mcp"))
267+
.expect_err("a source outage must error even with --url, to avoid misrouting a broker server");
268+
assert!(
269+
matches!(err, ServerResolutionError::ConfigSourceUnavailable(ref s, _) if s == "krisp"),
270+
"expected ConfigSourceUnavailable, got {err:?}"
271+
);
272+
}
273+
274+
/// When the config LOADS but the server is genuinely absent, the error is
275+
/// `UnknownServer` (the pre-existing behavior) — never `ConfigSourceUnavailable`.
276+
#[test]
277+
fn loaded_config_missing_server_yields_unknown_server() {
278+
let (_guard, temp, _home) = fixture();
279+
let root = temp.path();
280+
281+
// A base with no mcp_servers loads cleanly; the server is simply absent.
282+
let _base = test_seam::install(root, builtin_workflow_config());
283+
let err = resolve_server_url(root, "krisp", None).expect_err("absent server must error");
284+
assert!(
285+
matches!(err, ServerResolutionError::UnknownServer(ref s) if s == "krisp"),
286+
"expected UnknownServer, got {err:?}"
287+
);
288+
}
289+
}

crates/animus-mcp-oauth/src/flow.rs

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ enum Exchange {
4646
}
4747

4848
use crate::callback::CallbackListener;
49-
use crate::config::{build_secret_store, resolve_principal_id, resolve_server_url};
49+
use crate::config::{build_secret_store, resolve_principal_id, resolve_server_url, ServerResolutionError};
5050
use crate::keychain_store::KeychainCredentialStore;
5151
use crate::pending::{PendingAuth, PendingStore};
5252
use crate::state_store::PersistentStateStore;
@@ -745,17 +745,26 @@ pub async fn auth_status(project_root: &Path, server: Option<&str>, url_override
745745

746746
let servers: Vec<String> = match server {
747747
Some(s) => vec![s.to_string()],
748-
None => authorization_code_servers(project_root),
748+
// Non-swallowing enumeration: a transient config_source failure surfaces
749+
// as an Err here rather than an empty `servers:[]` that would read as
750+
// "no OAuth servers configured".
751+
None => authorization_code_servers(project_root)?,
749752
};
750753

751754
let mut out = Vec::with_capacity(servers.len());
752755
for name in servers {
753756
// Tokens are keyed by the upstream URL too, so resolve it. A server
754757
// that can't be resolved (e.g. dropped from config and no --url) is
755-
// reported as unauthenticated rather than failing the whole report.
756-
let Some(url) = resolve_server_url(project_root, &name, url_override).ok().map(|r| r.url) else {
757-
out.push(server_state_from_creds(&name, &principal, None));
758-
continue;
758+
// reported as unauthenticated rather than failing the whole report —
759+
// EXCEPT a transient config_source failure, which must surface (not be
760+
// silently reported as an unauthenticated server) so the user retries.
761+
let url = match resolve_server_url(project_root, &name, url_override) {
762+
Ok(resolution) => resolution.url,
763+
Err(err @ ServerResolutionError::ConfigSourceUnavailable(..)) => return Err(err.into()),
764+
Err(_) => {
765+
out.push(server_state_from_creds(&name, &principal, None));
766+
continue;
767+
}
759768
};
760769
let store = KeychainCredentialStore::new(secrets.clone(), &name, &principal, &url);
761770
let creds =
@@ -814,14 +823,27 @@ fn server_state_from_creds(server: &str, principal: &str, creds: Option<StoredCr
814823

815824
/// Servers in workflow/project config carrying an `authorization_code`
816825
/// oauth flow.
817-
fn authorization_code_servers(project_root: &Path) -> Vec<String> {
818-
use orchestrator_config::workflow_config::{load_workflow_config_or_default, OauthFlow};
826+
///
827+
/// Uses the NON-SWALLOWING loader: a transient `config_source` failure returns
828+
/// `Err` so `auth_status` reports a transient error rather than an empty
829+
/// `servers:[]` (which would read as "no OAuth servers configured"). A genuinely
830+
/// absent config source (`NoSource`) is benign — enumeration proceeds from
831+
/// project config only.
832+
fn authorization_code_servers(project_root: &Path) -> Result<Vec<String>> {
833+
use orchestrator_config::workflow_config::{try_load_workflow_config, OauthFlow, WorkflowConfigAvailability};
819834
let mut names = std::collections::BTreeSet::new();
820835

821-
let loaded = load_workflow_config_or_default(project_root);
822-
for (name, def) in &loaded.config.mcp_servers {
823-
if def.oauth.as_ref().is_some_and(|o| o.flow == OauthFlow::AuthorizationCode) {
824-
names.insert(name.clone());
836+
match try_load_workflow_config(project_root, None) {
837+
WorkflowConfigAvailability::Loaded(loaded) => {
838+
for (name, def) in &loaded.config.mcp_servers {
839+
if def.oauth.as_ref().is_some_and(|o| o.flow == OauthFlow::AuthorizationCode) {
840+
names.insert(name.clone());
841+
}
842+
}
843+
}
844+
WorkflowConfigAvailability::NoSource => {}
845+
WorkflowConfigAvailability::SourceUnavailable(err) => {
846+
return Err(anyhow!("workflow config source unavailable while listing OAuth servers: {err}"));
825847
}
826848
}
827849

@@ -838,7 +860,7 @@ fn authorization_code_servers(project_root: &Path) -> Vec<String> {
838860
}
839861
}
840862

841-
names.into_iter().collect()
863+
Ok(names.into_iter().collect())
842864
}
843865

844866
/// Extract the `state` query parameter from an authorization URL.

0 commit comments

Comments
 (0)