首页
/ Nushell MCP 的 evaluate 工具:执行语义、响应结构与 Bash 命令对照全解析

Nushell MCP 的 evaluate 工具:执行语义、响应结构与 Bash 命令对照全解析

2026-09-05 22:20:59作者:曹令琨Iris

本篇指南围绕 Nushell 仓库中 crates/nu-mcp/src/evaluate_tool.md 这份文档展开——它是 Nushell 内置 MCP(Model Context Protocol)服务器中 evaluate 工具的官方描述文本,会被直接内嵌进 MCP 协议的 tools 列表,供 LLM 客户端(Claude、Cursor 等)作为"如何使用这个工具"的第一手说明书。读完后你能掌握:evaluate 调用的成功/失败响应格式、长任务自动后台化的 NU_MCP_PROMOTE_AFTER 机制、Bash 与 Nushell 命令的完整对照表、polars 插件的接入姿势,以及跨调用状态持久化的正确用法,并能在 evaluation.rs 源码中找到每一处行为背后的实现证据。

evaluate 工具的定位:MCP 服务器的三个工具之一

Nushell 通过 mcp 特性编译出 MCP 服务器支持(见 nu-mcp 的 README):

cargo build --features mcp
nu --mcp                                   # stdio 传输(默认)
nu --mcp --mcp-transport http --mcp-port 3000   # Streamable HTTP,默认端口 8080

server.rs 中,服务器用 #[tool_router] 宏注册了三个工具:list_commands(按名称/描述/搜索词查找原生命令)、command_help(查看命令签名与参数)、以及核心的 evaluate(执行 Nushell 源码)。evaluate_tool.md 正是 evaluate 工具的描述来源:

// crates/nu-mcp/src/server.rs
#[doc = include_str!("evaluate_tool.md")]
#[tool]
async fn evaluate(
    &self,
    ctx: RequestContext<RoleServer>,
    Parameters(NuSourceRequest { input }): Parameters<NuSourceRequest>,
) -> CallToolResult {
    self.evaluator.eval_async(&input, ctx.ct).await
}

include_str! 意味着这份 Markdown 在编译期就被烙进二进制,MCP 客户端调用 tools/list 时看到的就是这份原文。evaluate 的输入 schema 只有一个 input 字段("The Nushell source code to evaluate"),这一点被测试 evaluate_tool_input_schema_exposes_only_input_property 明确锁定:每次调用无法传超时参数,长任务控制只能通过 NU_MCP_PROMOTE_AFTER 环境变量完成

成功响应:带元数据的 NUON 记录

evaluate_tool.md 第一句就定下契约:

Successful evaluations return a structured NUON record string with metadata fields including cwd, history_index, timestamp, and either output or note.

这个契约在 evaluation.rseval_on_state 中逐字段实现:

// crates/nu-mcp/src/evaluation.rs(简化)
let mut response_record = nu_protocol::record! {
    "cwd" => Value::string(cwd, block_span),
    "history_index" => Value::int(history_index as i64, block_span),
    "timestamp" => Value::date(timestamp_value, block_span),
};

if truncated {
    let note = format!("output truncated, full result in $history.{history_index}");
    response_record.push("note", Value::string(note, block_span));
} else {
    response_record.push("output", output_value);
}

各字段含义:

字段 说明
cwd 命令执行之后的工作目录(外部命令可能改变目录)
history_index 本次结果在 $history 环形缓冲区中的索引,是后续"回切"完整结果的唯一句柄
timestamp 执行时刻的日期时间值
output 命令输出(未超出内联大小时限的值,可为任意 NUON 结构化类型)
note 仅当输出被截断时出现,替代 output,提示完整结果在 $history.N

同一份响应会以两种形态回传给客户端:content 里是机器可读的 NUON 文本,structuredContent 里是等价 JSON(由 structured_json_from_value 生成)。test_evaluator_response_format 断言了四个字段齐全,evaluate_tool_computes_basic_expression 则验证了 evaluate("5 + 2")output7 且 structuredContent 中 output == 7

失败响应:结构化 NUON 错误记录

文档第二段的契约是"evaluation fails 时,MCP 工具响应被标记为 error,content 包含一个结构化的 NUON 错误记录"。实现上,format_mcp_error 会把 miette 诊断错误拆成以下字段:

  • code:错误码,如 nu::parser::unclosed_delimiternu::compile:: 前缀的编译错误、nu::shell::error 的运行时错误;
  • msg:短错误信息;
  • severityerror / warning / advice
  • helpurl:修复提示与文档链接(如有);
  • labels:定位列表,每条含 text(标签语义)、span(出错的源码片段)、line / column(从 1 开始的行列号)。

evaluation.rs#L21-L36 的函数注释完整描述了这一结构,并有三个测试分别钉住解析错误(test_evaluator_parse_error_nuon_format)、编译错误(test_evaluator_compile_error_nuon_format)与运行时错误(test_evaluator_runtime_error_nuon_format)的 NUON 格式,同时断言错误文本中不含 ANSI 转义码、不含 Rust Debug 格式的 Span { 残留。MCP 错误码层面,用户输入类错误(解析/编译)映射为 -32602 Invalid params,运行时错误映射为 -32603 Internal error(见 user_input_errorshell_error_to_mcp_error 的注释)。

大输出的正确姿势:不要限制输出,用 $history 回切

evaluate_tool.md 给出的第一条实操建议是:

Avoid commands that produce a large amount of output, and consider piping those outputs to files.

而 Nushell MCP 的设计哲学更进一步——配套的服务器级指令 instructions.md 明确要求 LLM 不要在"第一次运行"的管道里加 head/first N 之类的截断,因为:

  1. 每次评估的完整结果都会被存入 $history(环形缓冲区);
  2. 工具响应可能因内联大小限被截断(此时出现 note 字段),但数据不丢失;
  3. 之后可以用 history_index 精确回切,例如:
cargo build | complete        # complete 把 { stdout, stderr, exit_code } 存为独立列
# 响应: { history_index: 7, note: "output truncated, full result in $history.7", ... }
$history.7.stderr | lines | where $it =~ '^error'

三个相关环境变量控制这一行为(实现见下文"超时与限流"一节):

$env.NU_MCP_OUTPUT_LIMIT = 50kb     # 内联响应截断阈值(默认 10kb,0b 表示永不截断)
$env.NU_MCP_HISTORY_LIMIT = 200     # 历史条目上限(默认 100,环形语义,最旧条目被逐出)

注意 instructions.md 特别警告:取历史要用响应里给出的稳定索引 $history.7,而不是 $history | last——因为每次新评估都会先推入自己,last 会指到自己而非原始命令。

长任务:promote-after 超时与后台作业自动升级

evaluate_tool.md 中最关键的一段:

Calls that run longer than the promote-after timeout are auto-promoted to a background job; the tool then errors with a job id and you must job recv to collect the result. The timeout defaults to 120s and can be overridden by setting $env.NU_MCP_PROMOTE_AFTER (a duration, e.g. 10min) on the persistent stack. Bump it before a known long-running command so it stays synchronous; the setting persists across subsequent calls until you change it again.

源码中的对应事实链条:

  • 默认值是硬编码的 120 秒,且注释解释了为什么是 2 分钟——早期 10 秒的默认值导致普通命令频繁被扔进后台、拖累了模型的使用体验(evaluation.rs#L180-L188):
/// How long an evaluation can run before being auto-promoted to a background
/// job. Overridden via the `NU_MCP_PROMOTE_AFTER` env var on the persistent
/// stack.
const DEFAULT_PROMOTE_AFTER: Duration = Duration::from_secs(120);
  • 每次评估前读取当前环境值,解析失败(非 duration 类型)时回退到默认值而不是禁用升级,防止一个手误悄悄关掉保护(promote_timeout):
fn promote_timeout(engine_state: &EngineState, stack: &Stack) -> Duration {
    stack
        .get_env_var(engine_state, "NU_MCP_PROMOTE_AFTER")
        .and_then(|v| v.as_duration().ok())
        .and_then(|nanos| u64::try_from(nanos).ok())
        .map(Duration::from_nanos)
        .unwrap_or(DEFAULT_PROMOTE_AFTER)
}
  • 执行模型是一个 tokio::select! 三路竞争:客户端取消(ct.cancelled())、评估完成(result_rx)、超时(sleep(promote_after))。后两者触发 promote_to_background_job:注册一个 ThreadJob、向主线程邮箱(job 0)投递完整未截断的输出,然后工具立即以错误返回 Operation promoted to background job (id: N). Use job list to see it and job recv to get the result.promotion 实现):
Err(rmcp::ErrorData::internal_error(
    format!(
        "Operation promoted to background job (id: {}). \
         Use `job list` to see it and `job recv` to get the result.",
        job_id.get()
    ),
    None,
))

测试 test_external_command_promotion_respects_promote_after_env$env.NU_MCP_PROMOTE_AFTER = 100ms 把阈值压到 100 毫秒,再跑一个 sleep 0.2 的外部命令,断言响应 is_error == true 且文本含 "promoted to background job"。

由此形成的标准工作流:

# 1. 已知长命令前先放宽窗口,保持同步执行
$env.NU_MCP_PROMOTE_AFTER = 10min

# 2. 若仍被升级(或客户端取消),收集结果
job list                          # 查看是否还在运行
job recv                         # 阻塞直到结果(完整输出)
job recv --timeout 60sec         # 有界等待
job kill 1                       # 取消

一个容易踩坑的细节(instructions.md 的 Gotchas):job recv 只读当前作业邮箱、不带 id 参数;job send 必须带目标 id(主线程是 0);被升级的作业绕过 $history,其完整输出经 job recv 送达而不是存进历史。手动后台化则用 job spawn(文档建议的示例 job spawn { uvicorn main:app }),配套指令中还给出了投递到主线程的完整范式:

job spawn { ls | job send 0 }; job recv
job spawn { some-cmd | job send 0 }; job recv --timeout 5sec

Bash 命令对照表(完整继承)

evaluate_tool.md 的核心实用价值是一张 30 行的 Bash → Nushell 对照表,它是 LLM 客户端"翻译"Bash 习惯的主要依据。以下完整保留原文档内容:

Bash Command Nushell Command Description
mkdir -p <path> mkdir <path> Creates the given path, creating parents as necessary
> <path> o> <path> Save command output to a file
>> <path> o>> <path> Append command output to a file
> /dev/null ignore Discard command output
> /dev/null 2>&1 o+e>| ignore Discard command output, including stderr
command 2>&1 command o+e>| ... Redirect stderr to stdout (use o+e> or out+err>)
cmd1 | tee log.txt | cmd2 cmd1 | tee { save log.txt } | cmd2 Tee command output to a log file
command | head -5 command | first 5 Limit the output to the first 5 rows of an internal command (see also last and skip)
cat <path> open --raw <path> Display the contents of the given file
cat <(<command1>) <(<command2>) [(command1), (command2)] | str join Concatenate the outputs of command1 and command2
cat <path> <(<command>) [(open --raw <path>), (command)] | str join Concatenate the contents of the given file and output of command
for f in *.md; do echo $f; done ls *.md | each { $in.name } Iterate over a list and return results
for i in $(seq 1 10); do echo $i; done for i in 1..10 { print $i } Iterate over a list and run a command on results
cp <source> <dest> cp <source> <dest> Copy file to new location
rm -rf <path> rm -r <path> Recursively removes the given path
date -d <date> "<date>" | into datetime -f <format> Parse a date (format documentation)
sed str replace Find and replace a pattern in a string
grep <pattern> where $it =~ <substring> or find <substring> Filter strings that contain the substring
command1 && command2 command1; command2 Run a command, and if it's successful run a second
stat $(which git) stat ...(which git).path Use command output as argument for other command
echo /tmp/$RANDOM $"/tmp/(random int)" Use command output in a string
cargo b --jobs=$(nproc) cargo b $"--jobs=(sys cpu | length)" Use command output in an option
echo $PATH $env.PATH (Non-Windows) or $env.Path (Windows) See the current path
echo $? $env.LAST_EXIT_CODE See the exit status of the last executed command
export $env List the current environment variables
FOO=BAR ./bin FOO=BAR ./bin Update environment for a command
echo $FOO $env.FOO Use environment variables
echo ${FOO:-fallback} $env.FOO? | default "ABC" Use a fallback in place of an unset variable
type FOO which FOO Display information about a command (builtin, alias, or executable)
\ ( <command> ) A command can span multiple lines when wrapped with ( and )

几个值得注意的语义差异(原文档隐含、值得展开):

  • &&;:Nushell 管道式 cmd1; cmd2 中,第二条命令只在第一条成功时执行(非零退出码会短路),这正是它能替代 && 的原因。
  • stderr 合并:Nushell 用 o+e>|(或等价的 out+err>)显式把标准错误并入标准输出,而不是 Bash 的 2>&1 文件描述符技巧。
  • stat 例子展示了"展开"...(which git).path... 把命令输出按位置参数展开——$(cmd) 在 Nushell 里没有对应写法,子表达式一律用 (cmd),要"散开"成多个参数就加 ...
  • 环境变量是区分大小写的$env.PATH(Unix)与 $env.Path(Windows);回退值用 $env.FOO? | default "ABC",对应 Bash 的 ${FOO:-fallback}

数据面:polars 插件与文件解析

原文档的下一段是一个强推荐:

If the polars commands are available, prefer it for working with parquet, jsonl, ndjson, csv files, and avro files. It is much more efficient than the other Nushell commands or other non-nushell commands. It exposes much of the functionality of the polars dataframe library. Start the pipeline with plugin use polars

三个官方示例(原文档原样给出):

# Nushell 表格 -> polars DataFrame
ps | polars into-df | polars collect

# polars DataFrame -> Nushell 表格(以便继续用 Nushell 命令处理)
polars open file.parquet | polars into-nu

# 打开 parquet、选列、另存为新 parquet
polars open file.parquet | polars select name status | polars save file2

nu-mcp 的 Cargo.toml 可以看到 nu-mcp 本身依赖的是 nu-enginenu-parsernu-protocolnuon 等核心 crate,polars 是独立插件 nu_plugin_polars(见 crates/nu_plugin_polars),所以文档才用"if available"措辞——MCP 服务器评估时若该插件已注册,polars 命令即可用。

另外 instructions.md 补了原文档没写的 HTTP 细节:http get/post/put 会按 Content-Type 自动解析 JSON 响应,不要再 | from json-t json 不合法,必须传完整 MIME 类型 application/json

http get https://api.example.com/users | get 0.name
http post -t application/json $url {key: "value"}

文件查找只用 glob

原文档的第二条 Important 值得单独强调:

The glob command should be used exclusively when you need to locate a file or a code reference, other solutions may produce too large output because of hidden files! For example do not use find or ls -r.

这与 MCP 场景强相关:ls **/* 也会遍历隐藏目录(如 .gitnode_modules),输出会撑爆上下文窗口,而 glob 是 Nushell 原生的受限通配查找,可用 command_help glob 查看其参数。instructions.md 在 "Globs and file discovery" 一节重复了同一条建议。

跨调用状态持久化:REPL 语义

原文档最后一段是 evaluate 与一次性 bash -c 类工具的本质区别:

Important: Variables and environment changes persist across tool calls (REPL-style). You can set a variable in one call and access it in subsequent calls:

  • let x = 42 in one call, then $x in the next call returns 42
  • $env.MY_VAR = "hello" persists for later calls

However, external processes run in their own environment. Use absolute paths when possible.

这由 Evaluator 的实现保证:它持有一个持久 EngineState + Stack + Historyevaluation.rs 的架构注释明确写道 "The evaluator maintains a persistent EngineState and Stack that carry state across evaluations—just like an interactive REPL session")。每次调用:解析出 Blockworking_set.render() 得到 StateDeltamerge 进持久引擎状态(这样 def/闭包定义在后续调用中可见)→ 用持久状态求值。两组测试直接验证了文档承诺:

但"外部进程在独立环境运行"这句也属实:let 绑定的 Nushell 变量不会传进子进程,只有环境变量($env)会。这也是文档建议"尽量用绝对路径"的原因——你上一个调用里 cd 出来的目录虽会持久(cwd 字段会告诉你当前在哪),但外部程序自己看到的还是启动时的环境。

另一个微妙点:持久化是fork 后提交的。每次评估在 EvalState::fork() 出的隔离副本上运行(带独立 Signals,可被取消而不污染原状态);成功才把新状态写回,被升级/取消的那次,其状态变更不会提交——测试 test_cancellation_promotes_to_background_job 验证了取消 let x = 999$x 仍是 1。

超时、限流与历史缓冲的实现细节汇总

把散落各处的阈值与开关集中列表(均可在源码中逐一对应):

环境变量 默认值 类型 作用 实现位置
NU_MCP_PROMOTE_AFTER 120sec duration 超时后自动升级为后台作业 evaluation.rs#L188#L715-L722
NU_MCP_OUTPUT_LIMIT 10kb(10 × 1024 字节) filesize 内联响应截断阈值;0b 禁用截断;完整值始终在 $history evaluation.rs#L18-L19#L728-L736
NU_MCP_HISTORY_LIMIT 100 int $history 环形缓冲区容量,满则逐出最旧 history.rs#L7-L8#L38-L46

补充两点实现事实:

  1. 外部命令输出捕获Evaluator::newStack::new().capture_all() 使外部命令的 stdout 与 stderr 都被捕获而非继承终端;process_pipeline 把子进程输出合并为单个字符串(stderr 前补一个换行)。测试 test_failing_external_command_returns_captured_output 验证了退出码非 0 的外部命令仍返回捕获到的 stdout+stderr 文本。
  2. 环形缓冲语义History::push 在容量满时 pop_front() 逐出最旧条目,新条目索引等于写入前缓冲区长度——索引不会"左移",老条目只是变得不可访问。测试 test_history_ring_bufferNU_MCP_HISTORY_LIMIT = 3 精确推演了逐出过程。

服务器侧的配套行为

evaluate 工具并非孤立存在,整个 MCP 服务器为"无人值守执行"做了几处专门设计,理解它们能让 evaluate_tool.md 的契约读得更透:

  • 脱离控制终端lib.rs 在 Unix 下调用 setsid(),让 sshsudopsql 这类直接开 /dev/tty 要密码的程序快速失败而不是永久挂起——这也解释了为什么文档强调"用绝对路径、外部进程独立环境"。
  • 禁用 ANSI 着色NushellMcpServer::newEvaluator::new 都会把 use_ansi_coloring 设为 False 并清空 color_config——机器对机器协议里色码只会污染输出(错误格式的测试也断言了"无 ANSI 转义码")。
  • 外部命令不继承 stdinengine_state.is_mcp = true 标记 MCP 模式,避免程序在等待输入时卡死(lib.rs#L72-L74)。
  • HTTP 会话--mcp-transport http 下会话保活 30 分钟、消息缓冲容量 16(lib.rs#L108-L112),适合远程接入场景。

小结:把文档当契约、把源码当证据

evaluate_tool.md 不是一份"给人看的用户手册",而是 Nushell 以 MCP 服务器身份向 LLM 客户端输出的行为契约

  1. 契约层:NUON 元数据记录(cwd/history_index/timestamp/output|note)、失败时的结构化 NUON 错误记录、大输出先落 $history 再回切;
  2. 策略层:120 秒 promote-after 窗口 + NU_MCP_PROMOTE_AFTER 覆盖 + job recv 收集,保证工具调用永不无限阻塞;
  3. 翻译层:30 行 Bash 对照表 + polars 优先 + glob 限定 + REPL 式状态持久化,覆盖 LLM 写 Nushell 时最容易出错的几类场景。

由于 include_str! 把这份文档直接编进 tools/list 的响应,任何对它的修改都会改变所有 MCP 客户端对 evaluate 的理解——这也是为什么仓库用 server.rs 的测试 锁定工具集合与输入 schema,让这份"给机器读的文档"本身也处于测试覆盖之下。

适用前提说明:以上内容以当前仓库的 nu-mcp crate 为准,--mcp 相关能力需以 mcp 特性构建(cargo build --features mcp);未启用该特性时,--mcp 标志会提示需要重新编译。

登录后查看全文
热门项目推荐
相关项目推荐