Skip to content

Commit bc35ff5

Browse files
theahuranori-agent
andauthored
fix(mcp): inject stored OAuth tokens into ACP agent MCP servers (#445)
## Summary 🤖 Generated with [Nori](https://www.npmjs.com/package/nori-ai) - **Fix OAuth tokens not reaching ACP agent:** Stored OAuth tokens (from browser login via keyring or `.credentials.json`) are now loaded and injected as `Authorization: Bearer` headers when converting MCP server configs to SACP protocol types. Previously only `bearer_token_env_var` was checked — tokens from OAuth login were stored but never sent to the agent. - **Fix disabled servers being sent:** MCP servers with `enabled == false` are now filtered out of the SACP conversion instead of being sent to the agent. - **Fix OAuth branding:** DCR client name changed from "Codex" to "Nori" so OAuth login pages show the correct app name. Keyring service name changed to "Nori TUI MCP Credentials". ## Test Plan - [x] Unit tests for OAuth token injection (`http_server_with_stored_oauth_tokens_gets_auth_header`) - [x] Unit test for bearer token env var precedence over stored OAuth (`bearer_token_env_var_takes_precedence_over_stored_oauth`) - [x] Unit test for disabled server filtering (`disabled_servers_are_excluded`) - [x] TUI verification via tmux: `/mcp` picker renders, HTTP server can be added, config is persisted to `config.toml` - [ ] Manual test: Add Linear MCP server, complete OAuth login, restart session, verify agent can use Linear tools Share Nori with your team: https://www.npmjs.com/package/nori-skillsets Co-authored-by: Nori <contact@tilework.tech>
1 parent da58e04 commit bc35ff5

8 files changed

Lines changed: 218 additions & 13 deletions

File tree

nori-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nori-rs/acp/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ codex-protocol = { workspace = true }
2323
codex-utils-string = { workspace = true }
2424
futures = { workspace = true }
2525
nori-protocol = { workspace = true }
26+
oauth2 = "5"
2627
serde = { workspace = true, features = ["derive"] }
2728
serde_json = { workspace = true }
2829
thiserror = { workspace = true }
@@ -44,6 +45,7 @@ libc = { workspace = true }
4445
[dev-dependencies]
4546
filetime = "0.2"
4647
pretty_assertions = { workspace = true }
48+
rmcp = { workspace = true }
4749
serial_test = { workspace = true }
4850
tempfile = { workspace = true }
4951
tokio-test = { workspace = true }

nori-rs/acp/docs.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,9 +568,16 @@ CLI-configured MCP servers (from `config.toml`) are converted to ACP schema type
568568
| Transport | SACP Type | Key Fields |
569569
|-----------|-----------|------------|
570570
| `Stdio` | `McpServer::Stdio` | command, args, env (explicit key-value pairs + env vars resolved from process environment) |
571-
| `StreamableHttp` | `McpServer::Http` | url, headers (static headers + env-resolved headers + bearer token from env var as `Authorization: Bearer` header) |
571+
| `StreamableHttp` | `McpServer::Http` | url, headers (static headers + env-resolved headers + bearer token + OAuth token as `Authorization: Bearer` header) |
572572

573-
Environment variable references (`bearer_token_env_var`, `env_http_headers`, `env_vars`) are resolved eagerly from the current process environment at conversion time. Missing variables are logged as warnings and skipped -- they do not cause errors. The `client_id` and `client_secret_env_var` fields on `StreamableHttp` are not forwarded to the agent -- they are only used by the TUI/rmcp-client layer for OAuth login flows (see `@/nori-rs/rmcp-client/docs.md`). All servers are included regardless of the `enabled` flag; the agent decides how to handle them. Results are sorted by server name for deterministic ordering.
573+
Disabled servers (`enabled == false`) are filtered out before conversion. Environment variable references (`bearer_token_env_var`, `env_http_headers`, `env_vars`) are resolved eagerly from the current process environment at conversion time. Missing variables are logged as warnings and skipped -- they do not cause errors. The `client_id` and `client_secret_env_var` fields on `StreamableHttp` are not forwarded to the agent -- they are only used by the TUI/rmcp-client layer for OAuth login flows (see `@/nori-rs/rmcp-client/docs.md`). Results are sorted by server name for deterministic ordering.
574+
575+
**OAuth token injection for HTTP servers:** For `StreamableHttp` servers, `to_sacp_mcp_servers()` resolves authentication headers using a priority chain:
576+
1. `bearer_token_env_var` -- if present and the env var resolves, used as `Authorization: Bearer` header
577+
2. Stored OAuth tokens -- if no bearer token env var was resolved, `load_oauth_tokens()` from `@/nori-rs/rmcp-client/src/oauth.rs` is called to load credentials from the system keyring or `CODEX_HOME/.credentials.json` fallback file
578+
3. No auth -- if neither source produces a token, the server is forwarded without an `Authorization` header
579+
580+
This means `to_sacp_mcp_servers()` has side effects (reads from keyring/file system) rather than being a pure config transformation. The `acp` crate depends on `codex-rmcp-client`'s `load_oauth_tokens` for this purpose.
574581

575582
`create_session()` accepts a `mcp_servers: Vec<McpServer>` parameter that is populated by calling `to_sacp_mcp_servers()` at each session creation site:
576583
- `spawn_and_relay.rs` -- initial session creation during backend spawn

nori-rs/acp/src/connection/mcp.rs

Lines changed: 192 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,28 @@ use std::collections::HashMap;
99

1010
use codex_core::config::types::McpServerConfig;
1111
use codex_core::config::types::McpServerTransportConfig;
12+
use codex_rmcp_client::OAuthCredentialsStoreMode;
13+
use codex_rmcp_client::load_oauth_tokens;
14+
use oauth2::TokenResponse;
1215
use sacp::schema as acp;
1316
use tracing::warn;
1417

1518
/// Convert CLI MCP server configs into SACP protocol `McpServer` values
1619
/// suitable for inclusion in a `NewSessionRequest`.
1720
///
18-
/// All servers are included regardless of their `enabled` flag — the agent
19-
/// decides how to handle them.
21+
/// Disabled servers (`enabled == false`) are excluded.
2022
///
2123
/// Environment variable references (`bearer_token_env_var`, `env_http_headers`,
2224
/// `env_vars`) are resolved eagerly from the current process environment.
2325
/// Missing variables are logged as warnings and skipped.
26+
///
27+
/// For HTTP servers without a `bearer_token_env_var`, stored OAuth tokens
28+
/// (from keyring or credential file) are loaded and injected as an
29+
/// `Authorization: Bearer` header.
2430
pub fn to_sacp_mcp_servers(servers: &HashMap<String, McpServerConfig>) -> Vec<acp::McpServer> {
2531
let mut result: Vec<acp::McpServer> = servers
2632
.iter()
33+
.filter(|(_, config)| config.enabled)
2734
.map(|(name, config)| convert_one(name, config))
2835
.collect();
2936
// Sort for deterministic ordering (HashMap iteration is random).
@@ -106,13 +113,15 @@ fn convert_one(name: &str, config: &McpServerConfig) -> acp::McpServer {
106113
}
107114

108115
// Bearer token from env var → Authorization header.
116+
let mut has_bearer = false;
109117
if let Some(token_env_var) = bearer_token_env_var {
110118
match std::env::var(token_env_var) {
111119
Ok(token) => {
112120
headers.push(acp::HttpHeader::new(
113121
"Authorization".to_string(),
114122
format!("Bearer {token}"),
115123
));
124+
has_bearer = true;
116125
}
117126
Err(_) => {
118127
warn!(
@@ -122,6 +131,35 @@ fn convert_one(name: &str, config: &McpServerConfig) -> acp::McpServer {
122131
}
123132
}
124133

134+
// Fall back to stored OAuth tokens if no bearer token env var was resolved.
135+
if !has_bearer {
136+
match load_oauth_tokens(name, url, OAuthCredentialsStoreMode::Auto) {
137+
Ok(Some(tokens)) => {
138+
if let Some(expires_at) = tokens.expires_at {
139+
let now_ms = std::time::SystemTime::now()
140+
.duration_since(std::time::UNIX_EPOCH)
141+
.unwrap_or_default()
142+
.as_millis() as u64;
143+
if expires_at <= now_ms {
144+
warn!(
145+
"MCP server '{name}': stored OAuth token has expired; \
146+
re-authenticate via /mcp"
147+
);
148+
}
149+
}
150+
let access_token = tokens.token_response.0.access_token().secret();
151+
headers.push(acp::HttpHeader::new(
152+
"Authorization".to_string(),
153+
format!("Bearer {access_token}"),
154+
));
155+
}
156+
Ok(None) => {}
157+
Err(e) => {
158+
warn!("MCP server '{name}': failed to load stored OAuth tokens: {e}");
159+
}
160+
}
161+
}
162+
125163
acp::McpServer::Http(acp::McpServerHttp::new(name, url.as_str()).headers(headers))
126164
}
127165
}
@@ -131,6 +169,7 @@ fn convert_one(name: &str, config: &McpServerConfig) -> acp::McpServer {
131169
mod tests {
132170
use super::*;
133171
use pretty_assertions::assert_eq;
172+
use serial_test::serial;
134173

135174
fn stdio_config(
136175
command: &str,
@@ -309,7 +348,7 @@ mod tests {
309348
}
310349

311350
#[test]
312-
fn disabled_servers_are_included() {
351+
fn disabled_servers_are_excluded() {
313352
let mut servers = HashMap::new();
314353
servers.insert(
315354
"disabled-server".to_string(),
@@ -327,9 +366,159 @@ mod tests {
327366
disabled_tools: None,
328367
},
329368
);
369+
servers.insert(
370+
"enabled-server".to_string(),
371+
stdio_config("echo", vec![], None, vec![]),
372+
);
373+
374+
let result = to_sacp_mcp_servers(&servers);
375+
assert_eq!(result.len(), 1);
376+
match &result[0] {
377+
acp::McpServer::Stdio(s) => {
378+
assert_eq!(s.name, "enabled-server");
379+
}
380+
other => panic!("Expected Stdio, got {other:?}"),
381+
}
382+
}
383+
384+
#[test]
385+
#[serial]
386+
fn http_server_with_stored_oauth_tokens_gets_auth_header() {
387+
use codex_rmcp_client::OAuthCredentialsStoreMode;
388+
use codex_rmcp_client::StoredOAuthTokens;
389+
use codex_rmcp_client::WrappedOAuthTokenResponse;
390+
use codex_rmcp_client::save_oauth_tokens;
391+
use oauth2::AccessToken;
392+
use oauth2::EmptyExtraTokenFields;
393+
use oauth2::basic::BasicTokenType;
394+
use rmcp::transport::auth::OAuthTokenResponse;
395+
396+
let tmp_dir = tempfile::tempdir().unwrap();
397+
let tmp_path = tmp_dir.path().to_str().unwrap().to_string();
398+
399+
// Point CODEX_HOME at temp dir so file-based credential storage works
400+
let old_codex_home = std::env::var("CODEX_HOME").ok();
401+
unsafe { std::env::set_var("CODEX_HOME", &tmp_path) };
402+
403+
let server_name = "linear";
404+
let server_url = "https://mcp.linear.app/mcp";
405+
406+
// Store OAuth tokens for this server
407+
let token_response = OAuthTokenResponse::new(
408+
AccessToken::new("test-oauth-access-token".to_string()),
409+
BasicTokenType::Bearer,
410+
EmptyExtraTokenFields {},
411+
);
412+
let stored = StoredOAuthTokens {
413+
server_name: server_name.to_string(),
414+
url: server_url.to_string(),
415+
client_id: "test-client-id".to_string(),
416+
token_response: WrappedOAuthTokenResponse(token_response),
417+
expires_at: None,
418+
};
419+
save_oauth_tokens(server_name, &stored, OAuthCredentialsStoreMode::File).unwrap();
420+
421+
// Create an HTTP server config without bearer_token_env_var
422+
let mut servers = HashMap::new();
423+
servers.insert(
424+
server_name.to_string(),
425+
http_config(server_url, None, None, None),
426+
);
427+
428+
let result = to_sacp_mcp_servers(&servers);
429+
assert_eq!(result.len(), 1);
430+
431+
match &result[0] {
432+
acp::McpServer::Http(s) => {
433+
assert_eq!(s.name, "linear");
434+
assert_eq!(s.url, server_url);
435+
assert_eq!(s.headers.len(), 1);
436+
assert_eq!(s.headers[0].name, "Authorization");
437+
assert_eq!(s.headers[0].value, "Bearer test-oauth-access-token");
438+
}
439+
other => panic!("Expected Http, got {other:?}"),
440+
}
441+
442+
// Cleanup
443+
match old_codex_home {
444+
Some(val) => unsafe { std::env::set_var("CODEX_HOME", val) },
445+
None => unsafe { std::env::remove_var("CODEX_HOME") },
446+
}
447+
}
448+
449+
#[test]
450+
#[serial]
451+
fn bearer_token_env_var_takes_precedence_over_stored_oauth() {
452+
use codex_rmcp_client::OAuthCredentialsStoreMode;
453+
use codex_rmcp_client::StoredOAuthTokens;
454+
use codex_rmcp_client::WrappedOAuthTokenResponse;
455+
use codex_rmcp_client::save_oauth_tokens;
456+
use oauth2::AccessToken;
457+
use oauth2::EmptyExtraTokenFields;
458+
use oauth2::basic::BasicTokenType;
459+
use rmcp::transport::auth::OAuthTokenResponse;
460+
461+
let tmp_dir = tempfile::tempdir().unwrap();
462+
let tmp_path = tmp_dir.path().to_str().unwrap().to_string();
463+
464+
let old_codex_home = std::env::var("CODEX_HOME").ok();
465+
unsafe { std::env::set_var("CODEX_HOME", &tmp_path) };
466+
467+
let server_name = "my-server";
468+
let server_url = "https://mcp.example.com";
469+
470+
// Store OAuth tokens
471+
let token_response = OAuthTokenResponse::new(
472+
AccessToken::new("oauth-token-should-be-ignored".to_string()),
473+
BasicTokenType::Bearer,
474+
EmptyExtraTokenFields {},
475+
);
476+
let stored = StoredOAuthTokens {
477+
server_name: server_name.to_string(),
478+
url: server_url.to_string(),
479+
client_id: "test-client-id".to_string(),
480+
token_response: WrappedOAuthTokenResponse(token_response),
481+
expires_at: None,
482+
};
483+
save_oauth_tokens(server_name, &stored, OAuthCredentialsStoreMode::File).unwrap();
484+
485+
// Also set a bearer token env var
486+
unsafe { std::env::set_var("TEST_PRECEDENCE_TOKEN", "env-var-token-wins") };
487+
488+
let mut servers = HashMap::new();
489+
servers.insert(
490+
server_name.to_string(),
491+
http_config(
492+
server_url,
493+
Some("TEST_PRECEDENCE_TOKEN".to_string()),
494+
None,
495+
None,
496+
),
497+
);
330498

331499
let result = to_sacp_mcp_servers(&servers);
332500
assert_eq!(result.len(), 1);
501+
502+
match &result[0] {
503+
acp::McpServer::Http(s) => {
504+
// Should have exactly one Authorization header from env var, not OAuth
505+
let auth_headers: Vec<_> = s
506+
.headers
507+
.iter()
508+
.filter(|h| h.name == "Authorization")
509+
.collect();
510+
assert_eq!(auth_headers.len(), 1);
511+
assert_eq!(auth_headers[0].value, "Bearer env-var-token-wins");
512+
}
513+
other => panic!("Expected Http, got {other:?}"),
514+
}
515+
516+
// Cleanup
517+
unsafe { std::env::remove_var("TEST_PRECEDENCE_TOKEN") };
518+
match old_codex_home {
519+
Some(val) => unsafe { std::env::set_var("CODEX_HOME", val) },
520+
None => unsafe { std::env::remove_var("CODEX_HOME") },
521+
}
333522
}
334523

335524
#[test]

nori-rs/rmcp-client/docs.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ The rmcp-client crate provides a high-level MCP client for connecting to remote
88

99
### How it fits into the larger codebase
1010

11-
Used by `@/nori-rs/core/` (`mcp_connection_manager.rs`) to establish connections to configured MCP servers that provide additional tools to the AI model.
11+
- Used by `@/nori-rs/core/` (`mcp_connection_manager.rs`) to establish connections to configured MCP servers that provide additional tools to the AI model
12+
- Used by `@/nori-rs/acp/src/connection/mcp.rs` to load stored OAuth tokens at session creation time, so the ACP agent receives credentials for MCP servers that were authenticated via the OAuth browser flow
1213

1314
### Core Implementation
1415

@@ -23,6 +24,7 @@ Used by `@/nori-rs/core/` (`mcp_connection_manager.rs`) to establish connections
2324
- Two OAuth login entry points, both backed by the same `wait_for_callback_or_cancel()` mechanism (biased `tokio::select!` between callback, cancel signal, and 5-minute timeout):
2425
- `perform_oauth_login()` - Blocking/interactive flow that uses `println!` for status and `stdin` Enter for cancellation. Used by CLI contexts where TUI suspension is acceptable.
2526
- `start_oauth_login()` - Non-blocking async flow that returns an `OAuthLoginHandle`. The handle exposes a `cancel_tx: Option<oneshot::Sender<()>>` for programmatic cancellation and a `task: JoinHandle<Result<()>>` for awaiting completion. Used by the TUI to run OAuth inline without suspending the terminal. Accepts optional `client_id` and `client_secret` parameters to select between two OAuth paths (see below).
27+
- Dynamic client registration (DCR) uses `"Nori"` as the client name presented to OAuth servers
2628
- Token refresh handling via `OAuthPersistor`, which is called after every MCP request
2729

2830
**Two OAuth Credential Paths** (`perform_oauth_login.rs`):
@@ -43,8 +45,11 @@ The pre-configured path uses `discover_oauth_metadata()` to fetch the server's `
4345
**Program Resolution** (`program_resolver.rs`): Resolves MCP server executables from configuration.
4446

4547
**Credential Storage** (`oauth.rs`):
46-
- `OAuthCredentialsStoreMode` - Keyring vs file storage
47-
- `save_oauth_tokens()` / `delete_oauth_tokens()` - Credential management
48+
- `OAuthCredentialsStoreMode` - Keyring vs file storage (`Auto`, `File`, `Keyring`)
49+
- `save_oauth_tokens()` / `load_oauth_tokens()` / `delete_oauth_tokens()` - Credential management
50+
- `load_oauth_tokens` is public and used by `@/codex-rs/acp/src/connection/mcp.rs` to inject stored OAuth tokens when forwarding MCP server configs to ACP agents
51+
- Keyring service name: `"Nori TUI MCP Credentials"`. Credentials stored under the previous service name are not migrated
52+
- Fallback file: `CODEX_HOME/.credentials.json` (used when keyring is unavailable or fails)
4853

4954
### Things to Know
5055

nori-rs/rmcp-client/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub use oauth::OAuthCredentialsStoreMode;
1414
pub use oauth::StoredOAuthTokens;
1515
pub use oauth::WrappedOAuthTokenResponse;
1616
pub use oauth::delete_oauth_tokens;
17-
pub(crate) use oauth::load_oauth_tokens;
17+
pub use oauth::load_oauth_tokens;
1818
pub use oauth::save_oauth_tokens;
1919
pub use perform_oauth_login::OAuthLoginHandle;
2020
pub use perform_oauth_login::perform_oauth_login;

nori-rs/rmcp-client/src/oauth.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use tokio::sync::Mutex;
4949

5050
use crate::find_codex_home::find_codex_home;
5151

52-
const KEYRING_SERVICE: &str = "Codex MCP Credentials";
52+
const KEYRING_SERVICE: &str = "Nori TUI MCP Credentials";
5353
const REFRESH_SKEW_MILLIS: u64 = 30_000;
5454

5555
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -90,7 +90,7 @@ impl PartialEq for WrappedOAuthTokenResponse {
9090
}
9191
}
9292

93-
pub(crate) fn load_oauth_tokens(
93+
pub fn load_oauth_tokens(
9494
server_name: &str,
9595
url: &str,
9696
store_mode: OAuthCredentialsStoreMode,

nori-rs/rmcp-client/src/perform_oauth_login.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub async fn start_oauth_login(
104104
// Dynamic registration path: use OAuthState from rmcp.
105105
let mut oauth_state = OAuthState::new(&server_url, Some(http_client)).await?;
106106
oauth_state
107-
.start_authorization(&scope_refs, &redirect_uri, Some("Codex"))
107+
.start_authorization(&scope_refs, &redirect_uri, Some("Nori"))
108108
.await?;
109109
let auth_url = oauth_state.get_authorization_url().await?;
110110

@@ -348,7 +348,7 @@ pub async fn perform_oauth_login(
348348
let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?;
349349
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
350350
oauth_state
351-
.start_authorization(&scope_refs, &redirect_uri, Some("Codex"))
351+
.start_authorization(&scope_refs, &redirect_uri, Some("Nori"))
352352
.await?;
353353
let auth_url = oauth_state.get_authorization_url().await?;
354354

0 commit comments

Comments
 (0)