Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .codex/config.toml.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Project-local Codex MCP setup.
# Copy this file to .codex/config.toml from the repository root:
# cp .codex/config.toml.example .codex/config.toml
# The MCP commands and working directory are relative to this checkout.
# The MCP commands and working directory are relative to this checkout. bin/watchdog is built
# by the default target; the same flags run through `bin/daslang utils/watchdog/main.das --`
# where it is not.

[mcp_servers.daslang]
command = "python3"
args = ["utils/mcp/mcp_supervisor.py", "--repo-root", "."]
command = "bin/watchdog"
args = ["--stdio", "--name", "daslang-mcp", "--cwd", ".", "--program", "bin/daslang", "--", "-ignore-manifest", "utils/mcp/main.das"]
cwd = "."
enabled = true
required = true
Expand Down
7 changes: 3 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1979,7 +1979,6 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/utils/gen1-to-gen2/
# setup.das — bootstrap for a fresh tree; the installed README and the
# shipped skills/mcp_tools.md both document running it, so it
# must ship (it was missing through 0.6.4)
# mcp_supervisor.py — the stdio supervisor setup.das writes into .mcp.json
install(FILES
${PROJECT_SOURCE_DIR}/utils/mcp/main.das
${PROJECT_SOURCE_DIR}/utils/mcp/cpp_main.das
Expand All @@ -1989,7 +1988,6 @@ install(FILES
${PROJECT_SOURCE_DIR}/utils/mcp/registry_cpp.das
${PROJECT_SOURCE_DIR}/utils/mcp/cpp_search_config.das
${PROJECT_SOURCE_DIR}/utils/mcp/setup.das
${PROJECT_SOURCE_DIR}/utils/mcp/mcp_supervisor.py
${PROJECT_SOURCE_DIR}/utils/mcp/daslang-mcp-msvc.cmd
${PROJECT_SOURCE_DIR}/utils/mcp/README.md
DESTINATION utils/mcp
Expand All @@ -2002,8 +2000,8 @@ install(FILES
${PROJECT_SOURCE_DIR}/utils/mcp/test_tools.das
DESTINATION utils/mcp
)
# The installed test_tools.das drives two non-.das fixtures — _fixture_cpp_outline.h
# (cpp_outline) and _pretend_root/modules/pretend_mod/.das_module (project_root) — so a
# The installed test_tools.das drives non-.das fixtures — _fixture_cpp_outline.h
# (cpp_outline) and the _pretend_root/modules/*/.das_module descriptors (project_root) — so a
# bare "*.das" filter ships the suite in a state where those cases cannot run.
install(DIRECTORY ${PROJECT_SOURCE_DIR}/utils/mcp/tests/
DESTINATION utils/mcp/tests
Expand Down Expand Up @@ -2154,6 +2152,7 @@ install(FILES ${PROJECT_SOURCE_DIR}/utils/dasllama-convert/main.das
# beside daslang from utils/CMakeLists.txt, these are the library and the interpreter entry)
install(FILES
${PROJECT_SOURCE_DIR}/utils/watchdog/watchdog.das
${PROJECT_SOURCE_DIR}/utils/watchdog/stdio_front.das
${PROJECT_SOURCE_DIR}/utils/watchdog/main.das
${PROJECT_SOURCE_DIR}/utils/watchdog/README.md
DESTINATION utils/watchdog
Expand Down
6 changes: 3 additions & 3 deletions ci/smoke_test_bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,12 @@ fi
# tutorial/scaffold invoking a tool the bundle does not carry (found live: the
# AOT integration scaffolds). skills/ is excluded here: its own gate above owns
# skills content, with repo-only marker semantics this raw grep cannot honor.
# mcp_supervisor.py is excluded: it PROBES for the in-repo das-herd behind an
# exists-check, so the literal is functional and inert in a bundle.
# setup.das is excluded: it PROBES for the in-repo das-herd behind an exists-check, so
# the literal is functional and inert in a bundle.
# CHANGELIST.md is excluded: release history legitimately NAMES the utils/internal
# split; prose there is documentation, not a reference that can dangle.
printf ' %-30s ' "no utils/internal references"
INTERNAL_REFS="$(grep -rIl 'utils/internal' "$BUNDLE" --exclude-dir=skills --exclude=mcp_supervisor.py --exclude=CHANGELIST.md 2>/dev/null || true)"
INTERNAL_REFS="$(grep -rIl 'utils/internal' "$BUNDLE" --exclude-dir=skills --exclude=setup.das --exclude=CHANGELIST.md 2>/dev/null || true)"
if [[ -z "$INTERNAL_REFS" ]]; then
echo "OK"
PASS=$((PASS + 1))
Expand Down
4 changes: 2 additions & 2 deletions skills/mcp_tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ The daslang MCP server (`utils/mcp/main.das`) exposes compiler diagnostics, prog

**Live tools.** `live_*` interact with a running `daslang-live` instance via its REST API. `live_launch` starts one if not already running (sets working directory to the script's folder). All live tools accept an optional `port` parameter (default 9090). When a compilation error is active, `live_command` and `live_pause` return HTTP 503 with the error - use `live_reload` to fix. Hitting any unknown endpoint returns JSON help with all endpoints + curl examples.

**`shutdown` tool.** Shuts down the MCP server process. Claude Code auto-restarts it, picking up code changes to `.das` tool files. Tool registration changes (adding/removing tools) still require a manual MCP restart.
**`shutdown` tool.** Shuts down the MCP server process. Under the stdio front the next `tools/*` call respawns it; in the bare form Claude Code auto-restarts it. Either way the new process picks up code changes to `.das` tool files, while tool registration changes (adding/removing tools) still require a manual MCP restart.

**Configuration.** Configure `.mcp.json` with `"command"` pointing at the daslang binary (`bin/daslang` on Windows MSVC, `build/daslang` on Linux/macOS, `bin/daslang` for the installed SDK), `"args": ["-ignore-manifest", "utils/mcp/main.das"]` (the flag loads every C++ module on start; the server enumerates them). See `utils/mcp/README.md` for details and Claude Code permissions.
**Configuration.** `.mcp.json` names the watchdog's stdio front as the `daslang` server. `"command"` is the `watchdog` binary beside the daslang binary (`bin/watchdog`; `bin\watchdog.exe` on Windows); where it is not built, `"command"` is the daslang binary and `"args"` open with `utils/watchdog/main.das --`. The args are `--stdio --name daslang-mcp --cwd <root> --program <daslang> -- -ignore-manifest utils/mcp/main.das` (the flag loads every C++ module on start; the server enumerates them); on Windows `--program` is `%SystemRoot%\System32\cmd.exe` with `-- /c <root>/utils/mcp/daslang-mcp-msvc.cmd`, so the server runs under the vcvars launcher and `cpp_compile_check` finds `cl.exe`. The front answers `initialize` and `ping` itself and spawns the server on the first `tools/*` call, so the client connects before any child exists; when the child dies - a kill, a crash, the `shutdown` tool - the next call respawns it. `utils/mcp/setup.das` writes the entry; the bare form - `"command"` on the daslang binary, `"args": ["-ignore-manifest", "utils/mcp/main.das"]` - works, without the respawn. See `utils/mcp/README.md` for details and Claude Code permissions.

**Fresh checkouts / worktrees.** `.mcp.json`, `sgconfig.yml`, `bin/`, and the tree-sitter grammar lib are all gitignored, so a new `git worktree add` (or clone) has no daslang MCP at all. Bootstrap it with `daslang utils/mcp/setup.das -- --root <worktree>` - it configures `build/` on the cmake generator of the tree running the setup (platform default when that tree has no `build/CMakeCache.txt`), builds a worktree-local binary (+ grammar), copies the platform `sgconfig.yml`, and merges a `daslang` entry into `.mcp.json` (adds no new secrets; existing servers, including any secret env blocks, are preserved as-is). `--no-build` skips the build. Restart the session to pick it up.

Expand Down
16 changes: 16 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,14 @@ Coverage of per-iteration `finally` semantics across every loop form. Each cell
|---|---|---|
| test_pipes.das | lpipe macro - pipe into function calls, chain operators | |

## lsp/

| File | Description | Expects errors |
|---|---|---|
| _fixture_clean.das | *(helper)* the clean disk file the protocol test opens with broken buffer text | |
| test_lsp_project_root.das | the validate subtool under -project_root sees a project's own descriptors | |
| test_lsp_protocol.das | the LSP server over a stdio pipe - handshake, overlay diagnostics, navigation, shutdown | |

## match/

| File | Description | Expects errors |
Expand Down Expand Up @@ -1116,6 +1124,14 @@ Coverage of per-iteration `finally` semantics across every loop form. Each cell
|---|---|---|
| test_uri.das | URI parsing, normalize, rebase, query params, edge cases | |

## watchdog/

| File | Description | Expects errors |
|---|---|---|
| _fixture_watchdog_child.das | *(helper)* the supervised child - a run counter and a mode pick the story it acts out | |
| test_stdio_front.das | the --stdio front through both hosts - the server's own initialize result, local ping, lazy child, respawn after the server's shutdown | |
| test_watchdog.das | the supervisor - restart backoff, exit-code policy, stages, crash bundles, the stop ladder, the tray | |

## verify/

| File | Description | Expects errors |
Expand Down
29 changes: 29 additions & 0 deletions tests/lsp/test_lsp_project_root.das
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
options gen2
options no_unused_block_arguments = false

require dastest/testing_boost
require daslib/fio
require daslib/command_line
require strings

//! the validate subtool spawned with -project_root sees a project's descriptors: the pretend
//! root's module registers a C++ module and a require path, and the consumer validates clean
//! only through them
[test]
def test_validate_project_root(t : T?) {
let exe = get_full_file_name(get_das_exe())
let root = path_join(get_das_root(), "utils/mcp/tests/_pretend_root")
let script = path_join(root, "probe_consumer.das")
let validate = path_join(get_das_root(), "utils/lsp/subtools/validate.das")
t |> run("with -project_root the consumer validates clean") @(t : T?) {
var out : string
let rc = run_and_capture([exe, "-project_root", root, validate, "--", script], out, 120.0)
t |> equal(rc, 0, "validate exits 0: {out}")
t |> success(find(out, "\"ok\":true") >= 0 && find(out, "\"diagnostics\":[]") >= 0, "no diagnostics: {out}")
}
t |> run("without it the same file reports the require it cannot resolve") @(t : T?) {
var out : string
run_and_capture([exe, validate, "--", script], out, 120.0)
t |> success(find(out, "unit_probe/hello") >= 0, "the project's require path is what goes missing: {out}")
}
}
210 changes: 210 additions & 0 deletions tests/watchdog/test_stdio_front.das
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
options gen2
options no_aot
options no_unused_block_arguments = false

require dastest/testing_boost public
require daslib/fio
require daslib/json_boost
require daslib/command_line
require strings

//! the stdio front over a pipe, through both hosts where the exe is built: `initialize` and `ping`
//! are answered before any child exists, the first tool call spawns the daslang MCP server, the
//! server's own `shutdown` tool kills it, and the next tool call respawns it unseen

def private das_exe() : string {
return get_full_file_name(get_das_exe())
}

def private watchdog_exe() : string {
let beside = path_join(dir_name(das_exe()), "watchdog{get_platform_name() == "windows" ? ".exe" : ""}")
return fexist(beside) ? beside : ""
}

def private make_temp(t : T?) : string {
let tmp = create_temp_directory_result("das_stdio_front")
if (!(tmp is value)) {
t |> failure("could not create temp directory: {tmp as error}")
return ""
}
return tmp as value
}

//! the front's argv after the host: the daslang MCP server as the program, eager like .mcp.json
def private front_args(root, log : string) : array<string> {
return <- ["--stdio", "--name", "wdstdio", "--cwd", root, "--log", log,
"--program", das_exe(), "--", "-ignore-manifest", "utils/mcp/main.das"]
}

def private write_line(w : file; line : string) {
fprint(w, line)
fprint(w, "\n")
fflush(w)
}

//! one line from the front, however many fgets chunks it spans; empty on EOF
def private read_reply_line(r : file) : string {
return build_string() $(var w) {
while (!feof(r)) {
let chunk = fgets(r)
let len = length(chunk)
if (len == 0) break
write(w, chunk)
if (character_at(chunk, len - 1) == '\n') break // nolint:PERF003
}
}
}

//! one JSON line from the front; null on EOF
def private read_reply(r : file) : JsonValue? {
let line = read_reply_line(r)
return null if (empty(line))
var err : string
return read_json(line, err)
}

def private tool_text(js : JsonValue?) : string {
let text = js?["result"]?["content"]?[0]?["text"]
return text != null && text.value is _string ? text.value as _string : ""
}

def private id_of(js : JsonValue?) : int {
let id = js?["id"]
return id != null && id.value is _longint ? int(id.value as _longint) : -1
}

def private count_event(log_path, event : string) : int {
var count = 0
fopen(log_path, "rb") $(f) {
return if (f == null)
while (!feof(f)) {
let line = fgets(f)
break if (empty(line))
if (find(line, "\"event\": \"{event}\"") >= 0 || find(line, "\"event\":\"{event}\"") >= 0) {
count++
}
}
}
return count
}

//! the front is spawned from the temp directory, as an MCP client spawns it from wherever it
//! runs: `--cwd` is what makes the server's relative `utils/mcp/main.das` reach the tree
def private session(t : T?; host : array<string>; root : string) {
let log = path_join(root, "front.log")
var argv := host
argv |> push_from(front_args(get_das_root(), log))
let here = getcwd()
t |> success(chdir(root), "entered the temp directory before spawning the front")
var rc : int
unsafe {
rc = popen_argv_pipe(argv) $(w, r) {
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":\{\"protocolVersion\":\"2025-11-25\",\"capabilities\":\{\},\"clientInfo\":\{\"name\":\"dastest\",\"version\":\"0\"\}\}\}")
var init = read_reply(r)
t |> success(init != null && id_of(init) == 1, "initialize is answered")
let version = init?["result"]?["protocolVersion"]
t |> success(version != null && version.value is _string && (version.value as _string) == "2025-11-25", "the server's protocol version")
let name = init?["result"]?["serverInfo"]?["name"]
t |> success(name != null && name.value is _string && (name.value as _string) == "daslang", "the server's name")
t |> equal(count_event(log, "child_started"), 0, "no child was spawned to answer initialize")
unsafe {
delete init
}
write_line(w, "\{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"\}")
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"\}")
var pong = read_reply(r)
t |> success(pong != null && id_of(pong) == 2 && pong?["result"] != null, "ping is answered locally")
t |> equal(count_event(log, "child_started"), 0, "no child was spawned to answer ping")
unsafe {
delete pong
}
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":\{\"name\":\"list_modules\",\"arguments\":\{\}\}\}")
var listing = read_reply(r)
t |> success(listing != null && id_of(listing) == 3, "the first tool call is answered")
t |> success(find(tool_text(listing), "daslib/json") >= 0, "the child served list_modules")
t |> equal(count_event(log, "child_started"), 1, "the first tool call spawned the child")
unsafe {
delete listing
}
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/list\"\}")
let catalog_line = read_reply_line(r)
t |> success(length(catalog_line) > 16384, "the tool catalog is longer than one fgets chunk, so it crossed the front's line reader in pieces")
var err : string
var catalog = read_json(catalog_line, err)
t |> success(catalog != null && id_of(catalog) == 7, "the catalog line is one whole JSON message: {err}")
let tools = catalog?["result"]?["tools"]
t |> success(tools != null && tools.value is _array && length(tools.value as _array) > 10, "the child's catalog came through the front")
unsafe {
delete catalog
}
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":\{\"name\":\"shutdown\",\"arguments\":\{\}\}\}")
var gone = read_reply(r)
t |> success(gone != null && id_of(gone) == 4, "the shutdown call gets an answer, result or death report")
unsafe {
delete gone
}
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":\{\"name\":\"list_modules\",\"arguments\":\{\"json\":\"true\"\}\}\}")
var again = read_reply(r)
t |> success(again != null && id_of(again) == 5, "the call after the shutdown is answered")
t |> success(find(tool_text(again), "\"daslib\"") >= 0, "a respawned child served it")
t |> equal(count_event(log, "child_started"), 2, "the call after the shutdown spawned a second child")
unsafe {
delete again
}
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"resources/list\"\}")
var refused = read_reply(r)
t |> success(refused != null && id_of(refused) == 6 && refused?["error"] != null, "a method the server has no answer for is refused without a child")
unsafe {
delete refused
}
}
}
chdir(here)
t |> equal(rc, 0, "the front exits 0 when the client closes its input")
}

//! the `result` of one `initialize` from a program spawned with `argv`, compact; empty on no reply
def private initialize_result(argv : array<string>) : string {
var text : string
unsafe {
popen_argv_pipe(argv) $(w, r) {
write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":\{\"protocolVersion\":\"2025-11-25\",\"capabilities\":\{\},\"clientInfo\":\{\"name\":\"dastest\",\"version\":\"0\"\}\}\}")
var reply = read_reply(r)
let result = reply?["result"]
text = result != null ? write_json_compact(result) : ""
unsafe {
delete reply
}
}
}
return text
}

[test]
def test_stdio_front(t : T?) {
t |> run("the front's handshake is the server's") @(t : T?) {
let root = make_temp(t)
return if (empty(root))
let server = initialize_result([das_exe(), "-ignore-manifest", path_join(get_das_root(), "utils/mcp/main.das")])
var front_argv <- [das_exe(), path_join(get_das_root(), "utils/watchdog/main.das"), "--"]
front_argv |> push_from(front_args(get_das_root(), path_join(root, "front.log")))
let front = initialize_result(front_argv)
t |> success(!empty(server), "the server answered initialize")
t |> equal(front, server, "the front answers initialize with the server's own result")
}
t |> run("through the interpreter host") @(t : T?) {
let root = make_temp(t)
return if (empty(root))
session(t, [das_exe(), path_join(get_das_root(), "utils/watchdog/main.das"), "--"], root)
}
t |> run("through the static exe") @(t : T?) {
let exe = watchdog_exe()
if (empty(exe)) {
t |> skip("bin/watchdog is not built here")
return
}
let root = make_temp(t)
return if (empty(root))
session(t, [exe], root)
}
}
4 changes: 2 additions & 2 deletions utils/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ elseif(DAS_USE_SANITIZER STREQUAL "thread" OR DAS_USE_SANITIZER STREQUAL "tsan")
endif()

# Entries are paths relative to utils/; the exe/target name is the basename.
# mcp/lsp never build as exe: their python keep-alive dev setup means an exe
# form would never be dogfooded.
# mcp/lsp never build as exe: their development runs interpreted under a supervisor
# (the watchdog front, lsp_supervisor.py), so an exe form would never be dogfooded.
set(DAS_UTILS
aot
dascov
Expand Down
Loading
Loading