Skip to content

feat(mcp): add HTTP streaming transport and cancellation support#17161

Merged
fdncred merged 21 commits intonushell:mainfrom
andrewgazelka:feat/mcp-http-transport
Feb 4, 2026
Merged

feat(mcp): add HTTP streaming transport and cancellation support#17161
fdncred merged 21 commits intonushell:mainfrom
andrewgazelka:feat/mcp-http-transport

Conversation

@andrewgazelka
Copy link
Contributor

@andrewgazelka andrewgazelka commented Dec 13, 2025

Summary

Add HTTP streaming transport for MCP server with cancellation support and multiple improvements for production use.

Features

  • HTTP transport with SSE streaming - Connect to MCP over the network (--mcp-transport http)
  • Configurable port - Use --mcp-port to set custom port (default: 8080)
  • Session management - Automatic session cleanup after 30 min idle timeout
  • External command output capture - Captures both stdout and stderr from external commands
  • Cancellation support - Long-running commands can be cancelled without corrupting state

Cancellation Architecture

When a client times out or cancels a request:

  1. Fork state before evaluation (cheap via Arc-based copy-on-write)
  2. Run evaluation in spawn_blocking with its own Signals instance
  3. Monitor CancellationToken and trigger nushell interrupt on cancel
  4. On success: commit forked state back to main state
  5. On cancel: discard forked state, original unchanged

This prevents commands like sleep 100000sec from blocking the MCP server indefinitely.

Fixes

  • Detach from controlling terminal - Calls setsid() to prevent ssh/sudo/psql from hanging on password prompts (man page)
  • Stdin null for external commands - Adds no_stdin flag so commands don't inherit stdin
  • Ctrl-C propagation - Properly cancels active HTTP sessions on shutdown
  • Error codes - Parse/compile errors now return proper invalid_params error code

Improvements

  • Structured NUON errors - Errors formatted as machine-readable NUON with line/column info
  • Replaced axum with hyper - Lighter HTTP server implementation
  • Updated rmcp to 0.14

Usage

nu --mcp                              # stdio (default)
nu --mcp --mcp-transport http         # HTTP on port 8080
nu --mcp --mcp-transport http --mcp-port 3000

Motivation

This is the first step toward #17160 — enabling remote AI coding sessions.

With HTTP transport, you can SSH to a remote machine, start nu --mcp --mcp-transport http, and connect an AI coding assistant to that endpoint over the network.

Test plan

  • nu --mcp works as before (stdio)
  • nu --mcp --mcp-transport http starts HTTP server
  • External commands don't hang on stdin prompts (e.g., cat, psql, ssh)
  • Ctrl-C cleanly shuts down HTTP server
  • Errors include structured line/column information
  • Cancelled evaluations discard state changes

🤖 Generated with Claude Code

@ofek
Copy link
Contributor

ofek commented Feb 1, 2026

We would very much appreciate this for our developer environments that are accessed via SSH. Are there any blockers for this feature?

@andrewgazelka
Copy link
Contributor Author

We would very much appreciate this for our developer environments that are accessed via SSH. Are there any blockers for this feature?

I'm really busy but it is pretty straight forward if you want to finish it up

related
#17174

I will likely work on this soon

Add configurable transport for MCP server with two options:
- stdio (default): Standard I/O transport for local usage
- http: Streamable HTTP with SSE for remote access

New CLI flags:
- `--mcp-transport <stdio|http>`: Select transport mode
- `--mcp-port <port>`: Port for HTTP transport (default: 8080)

Example usage:
  nu --mcp                              # stdio (default)
  nu --mcp --mcp-transport http         # HTTP on port 8080
  nu --mcp --mcp-transport http --mcp-port 3000

This enables remote MCP sessions over HTTP, which is a step toward
supporting AI coding assistants working over network connections.

Closes nushell#17160
@andrewgazelka andrewgazelka force-pushed the feat/mcp-http-transport branch from c773c82 to ffa5ea5 Compare February 1, 2026 23:12
@ofek
Copy link
Contributor

ofek commented Feb 2, 2026

This works!

 cargo install --git https://github.com/andrewgazelka/nushell --branch feat/mcp-http-transport --features mcp --root . nu
    Updating git repository `https://github.com/andrewgazelka/nushell`
  Installing nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#ad4d95ec)
...
   Installed package `nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#ad4d95ec)` (executable `nu.exe`)
warning: be sure to add `C:\Users\ofek\Desktop\bin` to your PATH to be able to run the installed binaries
 let id = (job spawn { ^.\bin\nu --mcp --mcp-transport http }); job spawn { sleep 5sec; job kill $id } | ignore
 mcp-inspector --cli --transport http http://localhost:8080/mcp/ --method tools/list
{
  "tools": [
    {
      "name": "command_help",
      "description": "Get help for a specific Nushell command. This will only work on commands that are native to nushell. To find out if a command is native to nushell you can use the find_command tool.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "description": "The name of the command",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "CommandNameRequest"
      }
    },
    {
      "name": "list_commands",
      "description": "List available Nushell native commands.\nBy default all available commands will be returned. To find a specific command by searching command names, descriptions and search terms, use the find parameter.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "find": {
            "description": "string to find in command names, descriptions, and search term",
            "type": "string",
            "nullable": true
          }
        },
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "ListCommandsRequest"
      }
    },
    {
      "name": "evaluate",
      "description": "Execute a command in the nushell.\nThis will return the output and error concatenated into a single string, as\nyou would see from running on the command line. There will also be an indication\nof if the command succeeded or failed.\n\nAvoid commands that produce a large amount of output, and consider piping those outputs to files.\nIf you need to run a long lived command, background it - e.g. `job spawn { uvicorn main:app }` so that\nthis tool does not run indefinitely.\n\nCommand Equivalents to Bash:\n\n| Bash Command | Nushell Command | Description |\n|--------------|-----------------|-------------|\n| `mkdir -p <path>` | `mkdir <path>` | Creates the given path, creating parents as necessary |\n| `> <path>`  | `o> <path>` | Save command output to a file |\n| `>> <path>` | `o>> <path>` | Append command output to a file |\n| `> /dev/null` | `ignore` | Discard command output |\n| `> /dev/null 2>&1` | `o+e>\\| ignore` | Discard command output, including stderr |\n| `command 2>&1` | `command o+e>\\| ...` | Redirect stderr to stdout (use `o+e>` or `out+err>`) |\n| `cmd1 \\| tee log.txt \\| cmd2` | `cmd1 \\| tee { save log.txt } \\| cmd2` | Tee command output to a log file |\n| `command \\| head -5` | `command \\| first 5` | Limit the output to the first 5 rows of an internal command (see also last and skip) |\n| `cat <path>` | `open --raw <path>` | Display the contents of the given file |\n| `cat <(<command1>) <(<command2>)` | `[(command1), (command2)] \\| str join` | Concatenate the outputs of command1 and command2 |\n| `cat <path> <(<command>)` | `[(open --raw <path>), (command)] \\| str join` | Concatenate the contents of the given file and output of command |\n| `for f in *.md; do echo $f; done` | `ls *.md \\| each { $in.name }` | Iterate over a list and return results |\n| `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 |\n| `cp <source> <dest>` | `cp <source> <dest>` | Copy file to new location |\n| `rm -rf <path>` | `rm -r <path>` | Recursively removes the given path |\n| `date -d <date>` | `\"<date>\" \\| into datetime -f <format>` | Parse a date (format documentation) |\n| `sed` | `str replace` | Find and replace a pattern in a string |\n| `grep <pattern>` | `where $it =~ <substring>` or `find <substring>` | Filter strings that contain the substring |\n| `command1 && command2` | `command1; command2` | Run a command, and if it's successful run a second |\n| `stat $(which git)` | `stat ...(which git).path` | Use command output as argument for other command |\n| `echo /tmp/$RANDOM` | `$\"/tmp/(random int)\"` | Use command output in a string |\n| `cargo b --jobs=$(nproc)` | `cargo b $\"--jobs=(sys cpu \\| length)\"` | Use command output in an option |\n| `echo $PATH` | `$env.PATH (Non-Windows) or $env.Path (Windows)` | See the current path |\n| `echo $?` | `$env.LAST_EXIT_CODE` | See the exit status of the last executed command |\n| `export` | `$env` | List the current environment variables |\n| `FOO=BAR ./bin` | `FOO=BAR ./bin` | Update environment for a command |\n| `echo $FOO` | `$env.FOO` | Use environment variables |\n| `echo ${FOO:-fallback}` | `$env.FOO? \\| default \"ABC\"` | Use a fallback in place of an unset variable |\n| `type FOO` | `which FOO` | Display information about a command (builtin, alias, or executable) |\n| `\\` | `( <command> )` | A command can span multiple lines when wrapped with ( and ) |\n\nIf the polars commands are available, prefer it for working with parquet, jsonl, ndjson, csv files, and avro files.\nIt is much more efficient than the other Nushell commands or other non-nushell commands.\nIt exposes much of the functionality of the polars dataframe library. Start the pipeline with `plugin use polars`\n\nAn example of converting a nushell table output to a polars dataframe:\n```nu\nps | polars into-df | polars collect\n```\n\nAn example of converting a polars dataframe back to a nushell table in order to run other nushell commands:\n```nu\npolars open file.parquet | polars into-nu\n```\n\nAn example of opening a parquet file, selecting columns, and saving to a new parquet file:\n```nu\npolars open file.parquet | polars select name status | polars save file2\n```\n\n**Important**: The `glob` command should be used exclusively when you need to locate a file or a code reference,\nother solutions may produce too large output because of hidden files! For example *do not* use `find` or `ls -r`.\nUse command_help tool to learn more about the `glob` command.\n\n**Important**: Variables and environment changes persist across tool calls (REPL-style).\nYou can set a variable in one call and access it in subsequent calls:\n- `let x = 42` in one call, then `$x` in the next call returns 42\n- `$env.MY_VAR = \"hello\"` persists for later calls\n\nHowever, external processes run in their own environment. Use absolute paths when possible.\n",
      "inputSchema": {
        "type": "object",
        "properties": {
          "input": {
            "description": "The Nushell source code to evaluate",
            "type": "string"
          }
        },
        "required": [
          "input"
        ],
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "NuSourceRequest"
      }
    }
  ]
}

@ofek
Copy link
Contributor

ofek commented Feb 2, 2026

It seems to me that most people will use the HTTP transport in which case the UX of requiring 2 --mcp flags feels off. What do you think about, since the MCP features are new and Nushell being 0ver, making --mcp an option that takes the name of a transport rather than it being a flag?

@andrewgazelka
Copy link
Contributor Author

most people will use the HTTP transport in which case the UX of requiring 2 --mcp flags feels of

I do not think this is the case. for local development stdio will be the main use case as they do not have to run the server in the background :/

there are a lot of other stdio issues though. if there is a good way to not have to have a server terminal window in the background I am all ears

Configure explicit session management to prevent memory growth from
abandoned sessions. Sessions now automatically clean up after 30
minutes of inactivity.

- Add SESSION_KEEP_ALIVE (30 min) for idle session cleanup
- Explicitly configure LocalSessionManager and StreamableHttpServerConfig
- Add tokio-util dependency for CancellationToken
@andrewgazelka andrewgazelka force-pushed the feat/mcp-http-transport branch from c0692c9 to d122cbd Compare February 3, 2026 14:37
External commands (e.g., `rg`, `grep`, `ls`) were outputting to the
server's stdout instead of being captured and returned via MCP.

This happened because `Stack::new()` defaults stdout to `OutDest::Inherit`,
which sends external command output directly to the process's terminal.
The output appeared in server debug logs but was never forwarded to clients.

Fix: Call `.collect_value()` on the stack to set `pipe_stdout` to
`OutDest::Value`, which captures external command output into the
pipeline for proper MCP response handling.
Document how to strip ANSI codes using `ansi strip` and clarify that
nushell doesn't support `\uXXXX` unicode escape syntax. Also mention
the `char` command for producing special characters.
The CancellationToken was created but never cancelled on shutdown.
When Ctrl-C was pressed, axum stopped accepting new connections but
existing SSE streams and session handlers kept running indefinitely.

Fix: Cancel the token in the graceful shutdown handler so all active
sessions and streams are properly terminated.
@fdncred
Copy link
Contributor

fdncred commented Feb 3, 2026

My only real concern here is with adding a new dependency on axum. Is there any way to avoid that?

Add `Stack::capture_all()` method that sets both `pipe_stdout` and
`pipe_stderr` to `OutDest::Value`, capturing all output instead of
letting it inherit to the process's terminal.

This fixes commands like `cargo clippy`, `cargo build`, `rustc` etc.
that output to stderr - their output is now returned via MCP instead
of going to the server's terminal.
@andrewgazelka
Copy link
Contributor Author

andrewgazelka commented Feb 3, 2026

My only real concern here is with adding a new dependency on axum. Is there any way to avoid that?

@fdncred I think streamable http using the MCP official rust client requires axum; let me double check if there is an alternative.

Is the concern mostly bundle size or? axum is pretty standard for web servers these days and maintained by tokio team.

@andrewgazelka
Copy link
Contributor Author

My only real concern here is with adding a new dependency on axum. Is there any way to avoid that?

looks like we can use hyper! let me use that

Replace direct axum dependency with hyper-util, using TowerToHyperService
to wrap the StreamableHttpService. This addresses the concern about adding
axum as a dependency.

The StreamableHttpService is a tower service that works with any HTTP
server. We now use hyper directly via hyper-util instead of axum's
convenience wrappers.

Note: axum is still pulled in transitively by rmcp's
transport-streamable-http-server feature, but we no longer depend on it
directly.
Update to latest rmcp version.
Parse and compile errors are user input errors, not server internal
errors. Use JSON-RPC error code -32602 (Invalid params) instead of
-32603 (Internal error) for these cases.

This provides clearer semantics: internal_error is reserved for
unexpected server-side failures, while invalid_params indicates
the user's input was malformed.
@andrewgazelka andrewgazelka force-pushed the feat/mcp-http-transport branch from 87e0b0c to e1ab4f6 Compare February 3, 2026 15:34
…rmat

Replace human-readable display error format with machine-readable NUON
for MCP error responses. The new format extracts structured data from
miette Diagnostic errors including:

- Error code (e.g., "nu::parser::parse_mismatch")
- Error message
- Severity level (error/warning/advice)
- Help/hint text
- Documentation URL (if available)
- Labels with line/column numbers and source context

This makes errors much more useful for LLMs which can parse the
structured data directly instead of extracting from formatted text.
Add `no_stdin` flag to EngineState that tells external commands to use
Stdio::null() instead of Stdio::inherit() for stdin. This prevents
commands like psql, sudo, or cat from hanging indefinitely when they
prompt for passwords or other input in non-interactive contexts.

MCP servers now set this flag on initialization since they run as
servers without access to interactive stdin.
SSH bypasses stdin and opens /dev/tty directly for password prompts.
Set SSH_ASKPASS="" and SSH_ASKPASS_REQUIRE=never to make SSH fail fast
with a clear error instead of hanging or showing GUI prompts.
Detach from controlling terminal on Unix systems to prevent child
processes from prompting for input. Programs like ssh, sudo, and psql
bypass stdin and open /dev/tty directly for password prompts. By
calling setsid(), we create a new session without a controlling
terminal, making /dev/tty unavailable and causing these programs to
fail fast with clear errors instead of hanging.

This replaces the SSH_ASKPASS env var workaround with a universal fix.

See: https://man7.org/linux/man-pages/man2/setsid.2.html
@ofek
Copy link
Contributor

ofek commented Feb 3, 2026

Heads up that the most recent change produces a much larger binary than the axum approach from 2 days ago.

❯ ls .\bin\nu.exe
╭───┬────────────┬──────┬─────────┬────────────╮
│ # │    name    │ type │  size   │  modified  │
├───┼────────────┼──────┼─────────┼────────────┤
│ 0 │ bin\nu.exe │ file │ 38.3 MB │ 2 days ago │
╰───┴────────────┴──────┴─────────┴────────────╯
❯ cargo install --git https://github.com/andrewgazelka/nushell --branch feat/mcp-http-transport --features mcp --root . nu
    Updating git repository `https://github.com/andrewgazelka/nushell`
  Installing nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#653f9f89)
...
    Finished `release` profile [optimized] target(s) in 4m 32s
   Replacing C:\Users\ofek\Desktop\bin\nu.exe
    Replaced package `nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#ad4d95ec)` with `nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#653f9f89)` (executable `nu.exe`)
warning: be sure to add `C:\Users\ofek\Desktop\bin` to your PATH to be able to run the installed binaries
❯ ls .\bin\nu.exe
╭───┬────────────┬──────┬─────────┬───────────────╮
│ # │    name    │ type │  size   │   modified    │
├───┼────────────┼──────┼─────────┼───────────────┤
│ 0 │ bin\nu.exe │ file │ 38.9 MB │ 2 minutes ago │
╰───┴────────────┴──────┴─────────┴───────────────╯

@andrewgazelka
Copy link
Contributor Author

Heads up that the most recent change produces a much larger binary than the axum approach from 2 days ago.

❯ ls .\bin\nu.exe
╭───┬────────────┬──────┬─────────┬────────────╮
│ # │    name    │ type │  size   │  modified  │
├───┼────────────┼──────┼─────────┼────────────┤
│ 0 │ bin\nu.exe │ file │ 38.3 MB │ 2 days ago │
╰───┴────────────┴──────┴─────────┴────────────╯
❯ cargo install --git https://github.com/andrewgazelka/nushell --branch feat/mcp-http-transport --features mcp --root . nu
    Updating git repository `https://github.com/andrewgazelka/nushell`
  Installing nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#653f9f89)
...
    Finished `release` profile [optimized] target(s) in 4m 32s
   Replacing C:\Users\ofek\Desktop\bin\nu.exe
    Replaced package `nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#ad4d95ec)` with `nu v0.110.1 (https://github.com/andrewgazelka/nushell?branch=feat%2Fmcp-http-transport#653f9f89)` (executable `nu.exe`)
warning: be sure to add `C:\Users\ofek\Desktop\bin` to your PATH to be able to run the installed binaries
❯ ls .\bin\nu.exe
╭───┬────────────┬──────┬─────────┬───────────────╮
│ # │    name    │ type │  size   │   modified    │
├───┼────────────┼──────┼─────────┼───────────────┤
│ 0 │ bin\nu.exe │ file │ 38.9 MB │ 2 minutes ago │
╰───┴────────────┴──────┴─────────┴───────────────╯

this is expected as axum is still included as a transitive dep (I have an open PR to fix this)

SourceSpan implements Copy, so dereferencing is preferred over cloning.
@fdncred
Copy link
Contributor

fdncred commented Feb 3, 2026

My only real concern here is with adding a new dependency on axum. Is there any way to avoid that?

@fdncred I think streamable http using the MCP official rust client requires axum; let me double check if there is an alternative.

Is the concern mostly bundle size or? axum is pretty standard for web servers these days and maintained by tokio team.

The concern is about adding another dependency in general and how much size it adds to the binary.

@andrewgazelka
Copy link
Contributor Author

My only real concern here is with adding a new dependency on axum. Is there any way to avoid that?

@fdncred I think streamable http using the MCP official rust client requires axum; let me double check if there is an alternative.
Is the concern mostly bundle size or? axum is pretty standard for web servers these days and maintained by tokio team.

The concern is about adding another dependency in general and how much size it adds to the binary.

I was able to properly remove it btw

Implement proper cancellation handling for MCP tool calls:

- Fork state before evaluation using Arc-based copy-on-write
- Run eval in spawn_blocking with its own Signals instance
- Monitor CancellationToken and trigger interrupt on cancel
- On success: commit forked state back to main state
- On cancel: discard forked state, original unchanged

This prevents long-running commands (e.g., `sleep 100000sec`) from
blocking the MCP server indefinitely. When a client times out,
the server can now interrupt and discard the operation cleanly.
@andrewgazelka andrewgazelka changed the title feat(mcp): add HTTP streaming transport support feat(mcp): add HTTP streaming transport and cancellation support Feb 3, 2026
Replace the generic no_stdin flag with is_mcp, following the same pattern
as is_lsp. This makes the flag specific to MCP context and exposes it as
$nu.is-mcp for runtime checks.

In the future, a more general option like no_stdin could be added after
more testing, but for now keeping it MCP-specific is safer.
@fdncred
Copy link
Contributor

fdncred commented Feb 3, 2026

I'd guess the CI error is because you added is_mcp, probably just need to increment the count.

Add is-mcp to the expected completions list and update the count to 21
to match the new field added in the eval_const.rs
@fdncred fdncred merged commit e5f4d6a into nushell:main Feb 4, 2026
17 checks passed
@fdncred
Copy link
Contributor

fdncred commented Feb 4, 2026

Let's move forward with this. Thanks!

@github-actions github-actions bot added this to the v0.111.0 milestone Feb 4, 2026
@ofek
Copy link
Contributor

ofek commented Feb 4, 2026

Thanks a lot! @fdncred I am curious what you think of my comment here.

As a user of the remote MCP functionality, requiring both --mcp --mcp-transport at all times doesn't match the polished UX one would expect from Nushell. I also think someone with experience in the MCP space would find it odd that the stdio transport type is considered the default and given preferential treatment with a single --mcp flag. Also, it's a bit confusing that the flag is treated as both an enabler of the feature and a choice of transport type.

I would suggest that we either go with the --mcp (stdio|http) approach or if there really is a preference for stdio then please make transport type options like --mcp-transport implicitly enable --mcp.

@fdncred
Copy link
Contributor

fdncred commented Feb 4, 2026

I am curious what you think of my comment

This PR was already getting long in the tooth so I wanted to get it to mvp and run with it. For what it's worth, let me know if I'm wrong, but it seems to me that having the parameters the way they are now isn't that big of a deal since you configure whatever you want to use the mcp once. I wouldn't think it would be typed dozens of times in the repl.

Having said that, I wouldn't be opposed to --mcp and the defaults to stdio and --mcp http uses http and --mcp stdio verbosely uses stdio. I could also maybe live with --mcp-transport to infer --mcp and --mcp-transport on 8080.

@0x4D5352
Copy link
Contributor

0x4D5352 commented Feb 4, 2026

I'd second a follow-up to this where we compress the redundant args. --mcp <stdio/http> feels more intuitive and --mcp-transport has that "vibe coded" smell to it of redundant semantics. stdio can be the default or explicitly stated as part of an mcp server config, with --mcp http being the more common full enumeration. To handle ports, 8080 as an implied default with either "auto-increment until an open port is found" or leverage the port nushell builtin that can be exposed to the user via STDOUT or an $env.MCP_PORT variable.

@andrewgazelka
Copy link
Contributor Author

I'd second a follow-up to this where we compress the redundant args. --mcp <stdio/http> feels more intuitive and --mcp-transport has that "vibe coded" smell to it of redundant semantics. stdio can be the default or explicitly stated as part of an mcp server config, with --mcp http being the more common full enumeration. To handle ports, 8080 as an implied default with either "auto-increment until an open port is found" or leverage the port nushell builtin that can be exposed to the user via STDOUT or an $env.MCP_PORT variable.

I do not have a preference on any of this i will not be offended if someone makes a change based on what is thought to be best

@fdncred
Copy link
Contributor

fdncred commented Feb 4, 2026

if someone is passionate about this parameter change, please step forward with a pr. thanks for the conversation and continuing to make nushell better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants