NURLNURL registrynurl-lang.org →

← all packages

nurl-mcp

owner @Hindurable

repository https://github.com/nurl-lang/nurl/tree/main/packages/nurl-mcp

Install

[dependencies]
nurl-mcp = "^0.14.1"

Versions

Dependencies (latest)

nurl-mcp — a local MCP server for the NURL toolchain

nurl-mcp exposes the locally installed NURL toolchain to an LLM agent over the Model Context Protocol. It is the LLM-facing counterpart of nurl-lsp: where nurl-lsp serves editors over LSP, nurl-mcp serves an agent over MCP so it can build, run, type-check, and format NURL on the host — against the real filesystem and the exact compiler + stdlib you have installed.

This is deliberately not the broad playground MCP at <https://play.nurl-lang.org/mcp> (sandboxed cloud builds, cross-compile to every target). nurl-mcp is small, local, and offline: its value is that it drives your toolchain over your files — something a remote server cannot do.

It is written in NURL and itself built on the stdlib/ext/mcp.nu primitives (the same library the playground server uses), so it stays libc-only.

Install

nurlpkg install nurl-mcp        # → ~/.nurl/bin/nurl-mcp

It shells out to nurl, nurlc, and nurlfmt, which the toolchain installer (tools/install-toolchain.sh) puts on $PATH along with $NURL_STDLIB. Make sure those are present (nurlc --version-style smoke test: echo '@ main → i { ^ 0 }' | …).

Wire it into a client

The transport is newline-delimited JSON-RPC 2.0 over stdio — the transport every MCP client supports. The client spawns the binary and talks to it over the pipe; nothing listens on the network.

# Claude Code
claude mcp add nurl -- ~/.nurl/bin/nurl-mcp

nurlpkg install prints this exact line once the binary is in place. The ~ is expanded by your shell, so the absolute path works even from a client that doesn't inherit your $PATH; claude mcp add nurl -- nurl-mcp also works when ~/.nurl/bin is on $PATH.

For any other client, configure an MCP server whose command is ~/.nurl/bin/nurl-mcp (no arguments).

Tools

ToolArgumentsDoes
nurl_buildsource or pathCompile with the local toolchain; report success or compiler diagnostics. Does not run the program.
nurl_build_projectsource or files, deps, entry?Compile a program that USES registry packages: the given files plus a synthesized nurl.toml with deps ({"nn": "^0.1.1"}), resolved by nurlpkg install (signature- and checksum-verified), then nurl <entry>. A dependency is imported as `$ deps/<name>/src/<module>.nu . Nothing is executed; the binary and its .ll` are returned as paths.
nurl_build_wasmsource or path, optional outCompile to a wasm32-wasi module, fully locally (wasmbuilder package — no build service). out = output path; a path input defaults to <input>.wasm; inline source without out keeps the module + its .ll at a temp path and returns JSON with wasm_path / ll_path (no inline base64).
nurl_runsource or pathCompile and run; return the program's exit code, stdout, and stderr.
nurl_checksource or pathFront-end only — type-check + borrow-check, no binary. Fast.
nurl_fmtsource or pathFormat to canonical form (nurlfmt); return the formatted source.
nurl_list_stdlib—List the .nu modules in the installed stdlib as {name, path, bytes} JSON, paths relative to the stdlib root ($NURL_STDLIB/stdlib, or $NURL_STDLIB itself when it is the stdlib directory).
nurl_read_stdlibnameRead one stdlib module by path relative to the stdlib root (core/string.nu; the import spelling stdlib/core/string.nu works too).
nurl_apimodule | package (+ module, version) | querypackage= opens with the package's module index (each module's surface size and opening line); a package too large for one reply answers with the index alone — pass module='txn' or 'txn,db' beside it for those surfaces whole (an unknown module is a tool error listing the real ones). Otherwise: a module's API surface via nurldoc (signatures + doc comments + type definitions, no bodies — ext/csv.nu is 63 KB, its surface 11 KB), or an AND-term search over every module's declarations. A query no single declaration satisfies is re-run as a whole-word OR ranked by coverage — string builder append still lands on string_push_bytes, vec_push new string_new on string_new and vec_push — and only if that finds nothing does the reply widen to the package registry; an exact package-name term is footnoted regardless.
nurl_greppattern, where?, word?Case-insensitive search over the installed stdlib (path:line: hits, word-boundary matches ranked first) and the registry's package names + descriptions — "is there a package for X" from any MCP-only editor.
nurl_docsname?, section?, outline?, query?, offset?The shipped docs/ tree — the questions the API surface cannot answer: MEMORY.md (who frees what, and when), CRYPTO.md, ASYNC.md, PLATFORMS.md, spec.md, dev/COMPILER_INTERNALS.md, … No name lists everything with its size and title. query='string_free manually managed handles' searches every section of every document and returns the best few with their section= keys. name=X outline=true is that document's heading map; name=X section='7.4' (a number, or words from the heading) is one section — 3 KB instead of MEMORY.md's 44 KB, and the only way to read a slice of spec.md, which is past the 48 KB per-call cap. name=X alone still returns the whole document, paged with offset. formal/ — the mechanized memory-safety proof (formal/CLAIM.md, formal/RULES.md, the probe table formal/corpus/map.tsv, whose rows name=… query=h274 returns) — is part of the corpus; name= with query= searches one document. Served from $NURL_DOCS, else $NURL_STDLIB/docs — install-toolchain.sh puts it there, formal/ included.

source is inline NURL; path is a .nu file on the host. Inline source is handed to the toolchain on stdin under filename (default main.nu) — never written to disk — so its diagnostics read main.nu:6:12: …, the caller's own name, not a temp path. nurl_check runs only the front end and the borrow checker (nurlc --check), no IR. Build outputs go to temp paths that are deleted after the call (the server leaves nothing behind); inline nurl_build_wasm keeps its module and .ll and returns their paths. Requires NURL 0.76.0 (nurl --stdin).

Transports

stdio (default) — described above; the client spawns the binary and talks over the pipe. Nothing listens on the network.

HTTP (--http) — the MCP Streamable HTTP transport, served through the stdlib facade (ext/mcp_http.nu, the same plumbing swarm-mcp uses): POST /mcp single requests and JSON-RPC batches, GET /mcp SSE probe, Mcp-Session-Id echo, DELETE /mcp, CORS + preflight. Bearer-token auth wraps the whole endpoint. For clients that speak MCP over HTTP or when the agent runs elsewhere.

nurl-mcp --http [--host ADDR] [--port N] [--token TOK] [--read-only] [--allow-run]
FlagDefaultMeaning
--httpoff (stdio)Serve over HTTP instead of stdio.
--host ADDR127.0.0.1Bind address.
--port N8080Bind port.
--token TOKnoneRequire Authorization: Bearer TOK on every request (401 otherwise).
--read-onlyoffExpose only nurl_check, nurl_fmt, nurl_list_stdlib, nurl_read_stdlib, nurl_api, nurl_grep, nurl_docs — no build, no run.
--allow-runoffAllow nurl_run over HTTP (see Security).
# loopback dev server, run enabled, no auth (warns):
nurl-mcp --http --allow-run

# exposed on the LAN — token is mandatory:
nurl-mcp --http --host 0.0.0.0 --port 8080 --token "$(head -c32 /dev/urandom | base64)" --allow-run

Security

nurl_run is arbitrary code execution: NURL programs link libc and can make any syscall — there is no sandbox (unlike the playground's container).

server can reach it.

--allow-run. It is not even advertised in tools/list until then.

isn't 127.0.0.1 / ::1 / localhost) refuses to start without --token. A loopback HTTP server with no token starts but warns.

transport), leaving only analysis/read tools — the mode to expose when you only want the agent to inspect, not execute.

A disabled tool is not registered at all: it is absent from tools/list, and a call to it gets "unknown tool". Why it is absent goes into the server's instructions, which a client reads during the handshake — so a model learns that this endpoint is read-only, or that nurl_run needs --allow-run, before it picks a tool rather than after a rejected call.

The bearer token is compared in constant time, so the comparison's duration cannot leak how many leading bytes matched.

Status

v0.14.1 — stdio + token-authenticated HTTP transport, both served through stdlib/ext/mcp_server.nu, so the dual-era handshake (server/discover, the protocol version gate, _meta serverInfo) and per-handler panic isolation come from one implementation shared with the rest of the tree. Not yet bundled with the toolchain; install it explicitly. Possible follow-ups: a continuous SSE notification stream; per-session state.