Skip to content

Commit 7e94906

Browse files
authored
fix(mcp): merge parsed blocks to prevent closure panic (nushell#16884)
When executing pipelines with closures, the evaluator would panic with "internal error: missing block" at engine_state.rs:903. After parsing, blocks (including closures) exist only in the StateWorkingSet. Without calling `working_set.render()` and `engine_state.merge_delta()`, these blocks are never registered in the engine state. When eval_block() tries to execute a closure, it calls `engine_state.get_block(id)` which panics because the block ID doesn't exist. Simply adding merge_delta would pollute the shared engine state across evaluations. To maintain isolation, we now: 1. Store a pristine EngineState (not Arc<EngineState>) in the Evaluator 2. Clone it for each evaluation (cheap due to internal Arcs) 3. Merge the parsed delta into the cloned state 4. Evaluate with the isolated, fully-populated state This ensures each evaluation is independent while making all parsed blocks available during execution.
1 parent f3e0dd3 commit 7e94906

File tree

2 files changed

+87
-34
lines changed

2 files changed

+87
-34
lines changed

crates/nu-mcp/src/evaluation.rs

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,34 @@ use serde::{Deserialize, Serialize};
1717

1818
const MAX_PAGE_SIZE: usize = 16 * 1024;
1919

20+
/// Evaluates Nushell code in isolated contexts for MCP.
21+
///
22+
/// # Architecture
23+
///
24+
/// The evaluator maintains a pristine `EngineState` template. Each evaluation:
25+
/// 1. Clones the engine state (cheap due to internal `Arc`s)
26+
/// 2. Parses code into a `Block` and gets a `StateDelta` via `working_set.render()`
27+
/// 3. **Merges the delta** via `engine_state.merge_delta()` to register blocks
28+
/// 4. Evaluates the block with the merged state
29+
///
30+
/// Step 3 is critical: parsed blocks (including closures) are only stored in the
31+
/// `StateWorkingSet` initially. Without merging, `eval_block()` will panic with
32+
/// "missing block" when it tries to execute closures or other block references.
33+
///
34+
/// # Isolation
35+
///
36+
/// Each evaluation gets its own cloned state, so variables/definitions from one
37+
/// evaluation don't persist to the next.
38+
///
39+
/// This architecture also enables future parallel evaluation of multiple pipelines.
2040
pub struct Evaluator {
21-
engine_state: Arc<EngineState>,
41+
engine_state: EngineState,
2242
cache: Cache<String, Arc<PipelineBuffer>>,
2343
}
2444

2545
impl Evaluator {
26-
pub fn new(engine_state: Arc<EngineState>) -> Self {
46+
pub fn new(mut engine_state: EngineState) -> Self {
2747
// Disable ANSI coloring for MCP - it's a computer-to-computer protocol
28-
let mut engine_state = Arc::unwrap_or_clone(engine_state);
2948
let mut config = Config::clone(engine_state.get_config());
3049
config.use_ansi_coloring = UseAnsiColoring::False;
3150
engine_state.set_config(config);
@@ -35,7 +54,7 @@ impl Evaluator {
3554
.time_to_live(std::time::Duration::from_secs(300))
3655
.build();
3756
Self {
38-
engine_state: Arc::new(engine_state),
57+
engine_state,
3958
cache,
4059
}
4160
}
@@ -45,28 +64,39 @@ impl Evaluator {
4564
{
4665
pipeline_buffer
4766
} else {
48-
let engine_state = Arc::clone(&self.engine_state);
49-
let mut working_set = StateWorkingSet::new(&engine_state);
50-
51-
// Parse the source code
52-
let block = parse(&mut working_set, None, nu_source.as_bytes(), false);
53-
54-
// Check for parse errors
55-
if let Some(err) = working_set.parse_errors.first() {
56-
return Err(McpError::internal_error(
57-
nu_protocol::format_cli_error(&working_set, err, None),
58-
None,
59-
));
60-
}
67+
// Clone the pristine engine state for this evaluation
68+
let mut engine_state = self.engine_state.clone();
6169

62-
// Check for compile errors (IR compilation errors)
63-
// These are caught during the parse/compile phase, before evaluation
64-
if let Some(err) = working_set.compile_errors.first() {
65-
return Err(McpError::internal_error(
66-
nu_protocol::format_cli_error(&working_set, err, None),
67-
None,
68-
));
69-
}
70+
let (block, delta) = {
71+
let mut working_set = StateWorkingSet::new(&engine_state);
72+
73+
// Parse the source code
74+
let block = parse(&mut working_set, None, nu_source.as_bytes(), false);
75+
76+
// Check for parse errors
77+
if let Some(err) = working_set.parse_errors.first() {
78+
return Err(McpError::internal_error(
79+
nu_protocol::format_cli_error(&working_set, err, None),
80+
None,
81+
));
82+
}
83+
84+
// Check for compile errors (IR compilation errors)
85+
// These are caught during the parse/compile phase, before evaluation
86+
if let Some(err) = working_set.compile_errors.first() {
87+
return Err(McpError::internal_error(
88+
nu_protocol::format_cli_error(&working_set, err, None),
89+
None,
90+
));
91+
}
92+
93+
(block, working_set.render())
94+
};
95+
96+
// Merge the parsed blocks into the engine state so they're available during eval
97+
engine_state
98+
.merge_delta(delta)
99+
.map_err(|e| shell_error_to_mcp_error(e, &engine_state))?;
70100

71101
// Eval the block with the input
72102
let mut stack = Stack::new().collect_value();
@@ -78,7 +108,7 @@ impl Evaluator {
78108
)
79109
.map_err(|e| shell_error_to_mcp_error(e, &engine_state))?;
80110

81-
let r = Arc::new(self.process_pipeline(output)?);
111+
let r = Arc::new(self.process_pipeline(output, &engine_state)?);
82112
self.cache.insert(nu_source.to_string(), Arc::clone(&r));
83113
r
84114
};
@@ -104,8 +134,8 @@ impl Evaluator {
104134
fn process_pipeline(
105135
&self,
106136
pipeline_execution_data: PipelineExecutionData,
137+
engine_state: &EngineState,
107138
) -> Result<PipelineBuffer, McpError> {
108-
let engine_state = Arc::clone(&self.engine_state);
109139
let span = pipeline_execution_data.span();
110140
// todo - this bystream use case won't work
111141
if let PipelineData::ByteStream(_stream, ..) = pipeline_execution_data.body {
@@ -128,7 +158,7 @@ impl Evaluator {
128158
let mut last_page_index = 0;
129159
for item in pipeline_execution_data.body {
130160
let out = if let Value::Error { error, .. } = item {
131-
return Err(shell_error_to_mcp_error(*error, &engine_state));
161+
return Err(shell_error_to_mcp_error(*error, engine_state));
132162
} else {
133163
item.to_expanded_string("\n", config) + "\n"
134164
};
@@ -147,7 +177,7 @@ impl Evaluator {
147177
last_index = buffer.len();
148178

149179
write_all_and_flush(out, &mut buffer, "mcp_output", span, engine_state.signals())
150-
.map_err(|e| shell_error_to_mcp_error(e, &engine_state))?;
180+
.map_err(|e| shell_error_to_mcp_error(e, engine_state))?;
151181
}
152182
if pages.is_empty() {
153183
pages.push(Page::new(0..buffer.len(), page_total));
@@ -238,7 +268,7 @@ mod tests {
238268
Some(Span::test_data()),
239269
false,
240270
)?;
241-
let evaluator = Evaluator::new(Arc::new(engine_state));
271+
let evaluator = Evaluator::new(engine_state);
242272
let result = evaluator.eval(&nuon_values, None)?;
243273
assert_eq!(result.summary.total, 3);
244274
assert!(result.summary.next_cursor.is_some());
@@ -248,7 +278,7 @@ mod tests {
248278
#[test]
249279
fn test_evaluator_parse_error_message() {
250280
let engine_state = create_default_context();
251-
let evaluator = Evaluator::new(Arc::new(engine_state));
281+
let evaluator = Evaluator::new(engine_state);
252282

253283
// Invalid syntax - missing closing bracket
254284
let result = evaluator.eval("let x = [1, 2, 3", None);
@@ -285,7 +315,7 @@ mod tests {
285315
#[test]
286316
fn test_evaluator_compile_error_message() {
287317
let engine_state = create_default_context();
288-
let evaluator = Evaluator::new(Arc::new(engine_state));
318+
let evaluator = Evaluator::new(engine_state);
289319

290320
// This will trigger a compile error (IR compilation)
291321
// because create_default_context doesn't fully compile blocks for pipelines
@@ -311,7 +341,7 @@ mod tests {
311341
#[test]
312342
fn test_evaluator_runtime_error_message() {
313343
let engine_state = create_default_context();
314-
let evaluator = Evaluator::new(Arc::new(engine_state));
344+
let evaluator = Evaluator::new(engine_state);
315345

316346
// Use error make to create a runtime error with custom message and labels
317347
let result = evaluator.eval(
@@ -332,4 +362,27 @@ mod tests {
332362
"Error message should contain rich formatting and custom error message, but got: {err_msg}"
333363
);
334364
}
365+
366+
#[test]
367+
fn test_closure_in_pipeline() {
368+
// Use add_default_context which includes basic language commands
369+
let engine_state = {
370+
let engine_state = nu_protocol::engine::EngineState::new();
371+
nu_cmd_lang::add_default_context(engine_state)
372+
};
373+
let evaluator = Evaluator::new(engine_state);
374+
375+
// Test with a simple closure using 'do' which is a lang command
376+
// The closure { ... } creates a block that must be available when eval_block runs
377+
// This tests that merge_delta properly registers blocks in the engine_state
378+
let result = evaluator.eval(r#"do { |x| $x + 1 } 41"#, None);
379+
380+
assert!(
381+
result.is_ok(),
382+
"Pipeline with closure should succeed: {:?}",
383+
result.err()
384+
);
385+
let output = result.unwrap();
386+
assert_eq!(output.summary.total, 1, "Should have 1 result");
387+
}
335388
}

crates/nu-mcp/src/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ pub struct NushellMcpServer {
2424
#[tool_router]
2525
impl NushellMcpServer {
2626
pub fn new(mut engine_state: EngineState) -> Self {
27+
// Configure the engine state for MCP
2728
if let Some(config) = Arc::get_mut(&mut engine_state.config) {
2829
config.use_ansi_coloring = UseAnsiColoring::False;
2930
config.color_config.clear();
3031
}
31-
let engine_state = Arc::new(engine_state);
3232
NushellMcpServer {
3333
tool_router: Self::tool_router(),
3434
evaluator: Evaluator::new(engine_state),

0 commit comments

Comments
 (0)