Skip to content

Commit 8752274

Browse files
authored
fix(mcp): Resolve nori-client MCP review issues (#502)
## Summary - keep replacement nori-client MCP servers pending until the ACP replacement session is accepted - preserve eager nori-client initialization state in initial and post-replacement capability snapshots - add mock-agent regression coverage for failed replacement sessions and eager MCP initialization ## Test Plan - cargo test -p nori-acp - cargo build --bin nori - cargo test -p tui-pty-e2e - cargo test -p mock-acp-agent - just fix -p nori-acp - just fix -p mock-acp-agent - just fmt - TUI tmux verification with elizacp: launch, assert prompt, send hello, assert prompt returns 🤖 Generated with [Nori](https://noriagentic.com/) Share Nori with your team: https://www.npmjs.com/package/nori-skillsets
1 parent 6375640 commit 8752274

8 files changed

Lines changed: 279 additions & 28 deletions

File tree

nori-rs/acp/docs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat
119119

120120
`nori_client_mcp.rs` is a bridge, not a second store. The goal tools are typed rmcp `#[tool]` handlers on `NoriClientService`; they lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; `NoriClientShared` stores the recorder behind a shared cell because the service is built before the session id is known.
121121

122-
The local `nori-client` server is only advertised when `register_for_session` in `@/nori-rs/acp/src/backend/nori_client_mcp.rs` sees HTTP MCP support from `@/nori-rs/acp/src/connection/mod.rs`. Nori advertises a real `http://127.0.0.1:<port>/mcp` endpoint rather than an ACP pseudo-URL, because Codex ACP and Claude ACP both forward ACP `mcpServers` to their underlying clients as ordinary HTTP MCP server config. Each eligible session registration gets a fresh loopback server with a generated bearer token advertised as an `Authorization` header; an `axum` middleware rejects unauthenticated requests before they reach rmcp's `StreamableHttpService` (stateless mode). The server is owned by the ACP backend (abort-on-drop) and talks directly to the same in-memory goal state as `/goal`. The server is named `nori-client` rather than `nori-goal` because it is Nori's general harness-side channel to the external ACP agent -- the single point of contact for harness-specific tooling the ACP protocol does not yet provide -- and goal tools are only its live-state surface. The backend emits a `SessionCapabilitiesChanged` projection after session setup so clients can derive built-in command availability from the same capability state. That projection separates raw agent capabilities (`agent.http_mcp`, `agent.load_session`), `nori_client.advertised`, `nori_client.initialized`, and derived `builtin_commands` availability.
122+
The local `nori-client` server is only advertised when `register_for_session` in `@/nori-rs/acp/src/backend/nori_client_mcp.rs` sees HTTP MCP support from `@/nori-rs/acp/src/connection/mod.rs`. Nori advertises a real `http://127.0.0.1:<port>/mcp` endpoint rather than an ACP pseudo-URL, because Codex ACP and Claude ACP both forward ACP `mcpServers` to their underlying clients as ordinary HTTP MCP server config. Each eligible session registration gets a fresh loopback server with a generated bearer token advertised as an `Authorization` header; an `axum` middleware rejects unauthenticated requests before they reach rmcp's `StreamableHttpService` (stateless mode). The server is owned by the ACP backend (abort-on-drop) and talks directly to the same in-memory goal state as `/goal`. The server is named `nori-client` rather than `nori-goal` because it is Nori's general harness-side channel to the external ACP agent -- the single point of contact for harness-specific tooling the ACP protocol does not yet provide -- and goal tools are only its live-state surface. The backend emits a `SessionCapabilitiesChanged` projection after session setup so clients can derive built-in command availability from the same capability state. That projection separates raw agent capabilities (`agent.http_mcp`, `agent.load_session`), `nori_client.advertised`, `nori_client.initialized`, and derived `builtin_commands` availability. Initial and post-replacement snapshots read the same connected flag flipped by MCP `initialize`, so agents that eagerly initialize the advertised server during session setup do not observe initialized state regress from true back to false.
123123

124124
`@/nori-rs/acp/src/backend/nori_client_context.rs` owns the fixed read-only catalog exposed through the same server. `nori_client_mcp.rs` advertises tools, resources, and prompts in `ServerInfo`, but delegates list/read/get operations to that sibling module so transport, initialization, connected-gate behavior, and capability registration stay separate from Nori's curated operating context. The catalog intentionally serves Nori-owned harness facts, minimal ACP debugging and custom-agent help, and a compact source map for answering Nori CLI questions; it is not an arbitrary filesystem, repo-read, or skill-workflow API. Unknown resource URIs and prompt names are rejected as MCP errors, keeping the context surface closed and predictable.
125125

@@ -697,7 +697,7 @@ This means `to_sacp_mcp_servers()` has side effects (reads from keyring/file sys
697697

698698
The server uses a normal `http://127.0.0.1:<port>/mcp` URL because current Codex and Claude ACP adapters forward ACP MCP server entries into their underlying clients as ordinary HTTP MCP config. Avoiding `acp:` keeps startup compatible with those adapters while still keeping the tool implementation in process.
699699

700-
ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_sacp_mcp_servers()`, then let backend-owned features append local MCP servers when their own eligibility checks pass. The `nori-client` server requires HTTP MCP support. This setup applies to resumed, fresh, fallback, and compaction-created sessions. Compaction-created replacement sessions re-emit `SessionCapabilitiesChanged` after `create_session` succeeds, deriving `nori_client.advertised` from the actual MCP server list passed to that replacement session. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers.
700+
ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_sacp_mcp_servers()`, then let backend-owned features append local MCP servers when their own eligibility checks pass. The `nori-client` server requires HTTP MCP support. This setup applies to resumed, fresh, fallback, and compaction-created sessions. `register_for_session` returns a pending server registration; callers commit that server to `AcpBackend` only after `session/new` or `session/load` succeeds, so a failed replacement session drops the new listener and preserves the still-active session's previously advertised endpoint. Compaction-created replacement sessions re-emit `SessionCapabilitiesChanged` after `create_session` succeeds, deriving `nori_client.advertised` from the actual MCP server list passed to that replacement session. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers.
701701

702702
The local `nori-client` MCP server is intentionally additive. User-configured MCP servers are still forwarded normally, and ineligible agents simply do not receive the loopback endpoint. `SessionCapabilitiesChanged` exposes both endpoint advertisement and initialization so clients do not need to infer either state from raw HTTP MCP support. Continuation chaining depends on the local MCP server actually being initialized for the current advertised endpoint, not just on HTTP MCP support or endpoint advertisement. Durable follow-up work for this surface lives in `@/docs/followups/nori-client-mcp.md`.
703703

nori-rs/acp/src/backend/nori_client_mcp.rs

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,29 @@ impl Drop for NoriClientServer {
395395
}
396396
}
397397

398+
pub(super) struct PendingNoriClientServer {
399+
server: Option<NoriClientServer>,
400+
connected: Arc<AtomicBool>,
401+
previous_connected: bool,
402+
committed: bool,
403+
}
404+
405+
impl PendingNoriClientServer {
406+
pub(super) async fn commit(mut self, http_server: &Arc<Mutex<Option<NoriClientServer>>>) {
407+
*http_server.lock().await = self.server.take();
408+
self.committed = true;
409+
}
410+
}
411+
412+
impl Drop for PendingNoriClientServer {
413+
fn drop(&mut self) {
414+
if !self.committed {
415+
self.connected
416+
.store(self.previous_connected, Ordering::Relaxed);
417+
}
418+
}
419+
}
420+
398421
async fn require_bearer_token(
399422
State(expected): State<Arc<String>>,
400423
request: Request<Body>,
@@ -413,11 +436,12 @@ async fn require_bearer_token(
413436

414437
pub(super) fn capabilities_update_for_session(
415438
connection: &SacpConnection,
439+
connected: &AtomicBool,
416440
) -> nori_protocol::SessionCapabilitiesView {
417441
capabilities_update_for_nori_client(
418442
connection,
419443
connection.capabilities().mcp_capabilities.http,
420-
false,
444+
connected.load(Ordering::Relaxed),
421445
)
422446
}
423447

@@ -464,15 +488,11 @@ pub(super) async fn register_for_session(
464488
backend_event_tx: mpsc::Sender<BackendEvent>,
465489
transcript_recorder: Arc<Mutex<Option<Arc<TranscriptRecorder>>>>,
466490
connected: Arc<AtomicBool>,
467-
http_server: Arc<Mutex<Option<NoriClientServer>>>,
468-
) -> Result<()> {
469-
connected.store(false, Ordering::Relaxed);
470-
491+
) -> Result<Option<PendingNoriClientServer>> {
471492
if !connection.capabilities().mcp_capabilities.http {
472-
return Ok(());
493+
return Ok(None);
473494
}
474495

475-
let mut server = http_server.lock().await;
476496
let next_server = NoriClientServer::spawn(NoriClientShared::new(
477497
thread_goal_state,
478498
backend_event_tx,
@@ -489,10 +509,15 @@ pub(super) async fn register_for_session(
489509
acp::McpServerHttp::new("nori-client", next_server.url().to_string())
490510
.headers(vec![next_server.authorization_header()]),
491511
);
492-
*server = Some(next_server);
512+
let previous_connected = connected.swap(false, Ordering::Relaxed);
493513
mcp_servers.push(advertised_server);
494514

495-
Ok(())
515+
Ok(Some(PendingNoriClientServer {
516+
server: Some(next_server),
517+
connected,
518+
previous_connected,
519+
committed: false,
520+
}))
496521
}
497522

498523
#[cfg(test)]

nori-rs/acp/src/backend/session.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,21 @@ impl AcpBackend {
138138
&config.mcp_servers,
139139
config.mcp_oauth_credentials_store_mode,
140140
);
141-
nori_client_mcp::register_for_session(
141+
let pending_nori_client_server = nori_client_mcp::register_for_session(
142142
&connection,
143143
&mut mcp_servers,
144144
Arc::clone(&thread_goal_state),
145145
backend_event_tx.clone(),
146146
Arc::clone(&transcript_recorder_cell),
147147
Arc::clone(&goal_mcp_connected),
148-
Arc::clone(&goal_mcp_http_server),
149148
)
150149
.await?;
151150

152151
match connection.load_session(sid, &cwd, mcp_servers).await {
153152
Ok(session_id) => {
153+
if let Some(server) = pending_nori_client_server {
154+
server.commit(&goal_mcp_http_server).await;
155+
}
154156
// Signal the collector that load is done, then collect results.
155157
let _ = load_done_tx.send(());
156158
let (session_driver, recovered_rx, buffered_client_events) =
@@ -187,14 +189,13 @@ impl AcpBackend {
187189
&config.mcp_servers,
188190
config.mcp_oauth_credentials_store_mode,
189191
);
190-
nori_client_mcp::register_for_session(
192+
let pending_nori_client_server = nori_client_mcp::register_for_session(
191193
&connection,
192194
&mut mcp_servers,
193195
Arc::clone(&thread_goal_state),
194196
backend_event_tx.clone(),
195197
Arc::clone(&transcript_recorder_cell),
196198
Arc::clone(&goal_mcp_connected),
197-
Arc::clone(&goal_mcp_http_server),
198199
)
199200
.await?;
200201
let session_id =
@@ -214,6 +215,9 @@ impl AcpBackend {
214215
&agent_config.install_hint,
215216
))
216217
})?;
218+
if let Some(server) = pending_nori_client_server {
219+
server.commit(&goal_mcp_http_server).await;
220+
}
217221

218222
let (replay_events, summary) = if let Some(t) = transcript {
219223
let client_events = transcript_to_replay_client_events(t);
@@ -246,14 +250,13 @@ impl AcpBackend {
246250
&config.mcp_servers,
247251
config.mcp_oauth_credentials_store_mode,
248252
);
249-
nori_client_mcp::register_for_session(
253+
let pending_nori_client_server = nori_client_mcp::register_for_session(
250254
&connection,
251255
&mut mcp_servers,
252256
Arc::clone(&thread_goal_state),
253257
backend_event_tx.clone(),
254258
Arc::clone(&transcript_recorder_cell),
255259
Arc::clone(&goal_mcp_connected),
256-
Arc::clone(&goal_mcp_http_server),
257260
)
258261
.await?;
259262
let session_id = connection
@@ -272,6 +275,9 @@ impl AcpBackend {
272275
&agent_config.install_hint,
273276
))
274277
})?;
278+
if let Some(server) = pending_nori_client_server {
279+
server.commit(&goal_mcp_http_server).await;
280+
}
275281

276282
let (replay_events, summary) = if let Some(t) = transcript {
277283
let client_events = transcript_to_replay_client_events(t);
@@ -305,7 +311,8 @@ impl AcpBackend {
305311
thread_goal::ThreadGoalState::from_replay_events(&replay_events_for_goal_state);
306312
}
307313

308-
let capabilities_update = nori_client_mcp::capabilities_update_for_session(&connection);
314+
let capabilities_update =
315+
nori_client_mcp::capabilities_update_for_session(&connection, &goal_mcp_connected);
309316
let connection = Arc::new(connection);
310317
let pending_approvals = Arc::new(Mutex::new(Vec::new()));
311318
let session_driver = Arc::new(Mutex::new(session_driver_state));

nori-rs/acp/src/backend/session_runtime_driver.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -491,19 +491,22 @@ impl AcpBackend {
491491
&self.mcp_servers,
492492
self.mcp_oauth_credentials_store_mode,
493493
);
494-
if let Err(err) = nori_client_mcp::register_for_session(
494+
let pending_nori_client_server = match nori_client_mcp::register_for_session(
495495
&self.connection,
496496
&mut mcp_servers,
497497
Arc::clone(&self.thread_goal_state),
498498
self.backend_event_tx.clone(),
499499
Arc::clone(&self.transcript_recorder_cell),
500500
Arc::clone(&self.goal_mcp_connected),
501-
Arc::clone(&self.goal_mcp_http_server),
502501
)
503502
.await
504503
{
505-
warn!("Failed to register goal MCP server after compact: {err}");
506-
}
504+
Ok(server) => server,
505+
Err(err) => {
506+
warn!("Failed to register goal MCP server after compact: {err}");
507+
None
508+
}
509+
};
507510
let nori_client_advertised = mcp_servers.iter().any(|server| {
508511
matches!(
509512
server,
@@ -512,14 +515,18 @@ impl AcpBackend {
512515
});
513516
match self.connection.create_session(&cwd, mcp_servers).await {
514517
Ok(new_session_id) => {
518+
if let Some(server) = pending_nori_client_server {
519+
server.commit(&self.goal_mcp_http_server).await;
520+
}
515521
debug!("Created new session after compact: {:?}", new_session_id);
516522
*self.session_id.write().await = new_session_id;
517523
self.forward_and_record_client_event(
518524
ClientEvent::SessionCapabilitiesChanged(
519525
nori_client_mcp::capabilities_update_for_nori_client(
520526
&self.connection,
521527
nori_client_advertised,
522-
false,
528+
self.goal_mcp_connected
529+
.load(std::sync::atomic::Ordering::Relaxed),
523530
),
524531
),
525532
)

nori-rs/acp/src/backend/spawn_and_relay.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,23 @@ impl AcpBackend {
6464
&config.mcp_servers,
6565
config.mcp_oauth_credentials_store_mode,
6666
);
67-
nori_client_mcp::register_for_session(
67+
let pending_nori_client_server = nori_client_mcp::register_for_session(
6868
&connection,
6969
&mut mcp_servers,
7070
Arc::clone(&thread_goal_state),
7171
backend_event_tx.clone(),
7272
Arc::clone(&transcript_recorder_cell),
7373
Arc::clone(&goal_mcp_connected),
74-
Arc::clone(&goal_mcp_http_server),
7574
)
7675
.await?;
7776
let session_result = connection.create_session(&cwd, mcp_servers).await;
7877
let session_id = match session_result {
79-
Ok(id) => id,
78+
Ok(id) => {
79+
if let Some(server) = pending_nori_client_server {
80+
server.commit(&goal_mcp_http_server).await;
81+
}
82+
id
83+
}
8084
Err(e) => {
8185
// Get the full error chain to check for nested auth errors
8286
let error_string = format!("{e:?}");
@@ -122,7 +126,8 @@ impl AcpBackend {
122126
}
123127
}
124128

125-
let capabilities_update = nori_client_mcp::capabilities_update_for_session(&connection);
129+
let capabilities_update =
130+
nori_client_mcp::capabilities_update_for_session(&connection, &goal_mcp_connected);
126131
let event_rx = connection.take_event_receiver();
127132

128133
let connection = Arc::new(connection);

0 commit comments

Comments
 (0)