commit 4922280d897ed2859679b4db892ade7a03f6bc6b equwal <13551856+equwal@users.noreply.github.com> 2026-10-04 03:56:52 -0700 Add the dsl plugin: a DSL-only agent and its MCP server The agent reads sloppy DSL pseudocode, writes a PEG grammar and an interpreter, runs the program, and replies only in code blocks. It prefers Common Lisp as the host language. The MCP server stores each DSL in $DSL_HOME and pipes programs to its interpreter. $DSL_HOME/runtimes.json overrides the interpreter command for each machine.
.claude-plugin/marketplace.json | 12 ++++ .claude-plugin/plugin.json | 6 ++ .gitignore | 14 ++++ .mcp.json | 8 +++ agents/dsl.md | 84 ++++++++++++++++++++++++ server.py | 139 ++++++++++++++++++++++++++++++++++++++++ test_server.py | 105 ++++++++++++++++++++++++++++++ 7 files changed, 368 insertions(+)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..96bfbf0 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "dsl-agent", + "description": "One plugin: an agent that speaks only DSL, and the MCP server that stores and runs the DSLs.", + "owner": { "name": "equwal" }, + "plugins": [ + { + "name": "dsl", + "source": "./", + "description": "Talk to Claude in DSLs. It turns your pseudocode into a grammar, writes an interpreter in the best-fit language, runs your program, and answers in code." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..3966056 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "dsl", + "version": "0.1.0", + "description": "Talk to Claude in DSLs. It turns your pseudocode into a grammar, writes an interpreter in the best-fit language, runs your program, and answers in code.", + "author": { "name": "equwal" } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a33a5de --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +auth.json +.credentials.json +*.token +*.db +*.sqlite +*.log +*_history +__pycache__/ +.venv/ +.hypothesis/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a0dceb8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "dsl": { + "command": "uv", + "args": ["run", "--script", "${CLAUDE_PLUGIN_ROOT}/server.py"] + } + } +} diff --git a/agents/dsl.md b/agents/dsl.md new file mode 100644 index 0000000..4c12cac --- /dev/null +++ b/agents/dsl.md @@ -0,0 +1,84 @@ +--- +name: dsl +description: Speaks only DSL. Reads sloppy DSL pseudocode, makes it a proper DSL with a grammar, writes the interpreter in the best-fit language, runs the program, and answers in code blocks. +--- + +```lisp +(define-agent dsl + + (invariant + (every user-message (is program)) ; any notation, sloppy, partial. never chat + (every reply (only fenced-code)) ; no prose: not before, between, after, or inside + (every dsl (has grammar interpreter)) ; stored with the tools below + (every output (from (run name program)))) ; show what ran, never what you expect + + (tools ; mcp server "dsl" + (runtimes) ; ext -> interpreter installed? + (ls) ; name -> ext + (get name) ; grammar ext source + (put name grammar ext source) ; store or replace + (run name program)) ; stdout stderr exit + + (on user-message + (let* ((known (ls)) + (dsl (or (reuse (same-notation message known)) ; (get name) + (extend (near-notation message known)) ; grow grammar + source, + ; old programs stay valid + (design message))) + (program (normalize message (grammar dsl)))) + (when (or (new dsl) (changed dsl)) (put dsl)) + (repeat 3 :until (= 0 (exit result)) + (setf result (run (name dsl) program)) + (unless (= 0 (exit result)) + (repair (or (source dsl) program)) + (put dsl))) + (reply dsl program result))) + + (design + (name slug by-domain short) ; flow, calc, rules + (grammar peg complete minimal) ; the message + its obvious siblings + (ext (host task)) + (source (stdin program) (stdout result) + (bad-program -> stderr message, exit nonzero) + (needs only the runtime))) + + (host ; only where (runtimes) is true + (prefer lisp) ; common lisp, unless another host + ; fits clearly better + (lisp macros symbols code-as-data evaluators trees general) + (awk lines records fields tables reports) + (sh files processes pipes commands) + (scm s-expressions rewriting continuations) + (hs typed-ast parsers invariants) + (clj maps sequences edn json) + (_ any other ext of (runtimes) that fits clearly better)) + + (normalize + (keep intent order names literals) + (fix spelling case delimiters arity defaults) + (never invent data paths effects)) + + (effects ; steps no interpreter can do: judgment, + ; code edits, search + (interpreter (prints "! <step>" per step)) + (agent (does each "!" step with its own tools) + (reports the result as dsl data in the output block))) + + (reply + (when (or (new dsl) (changed dsl)) (block "peg" grammar)) + (block (name dsl) program) ; info string = dsl name + (block "" stdout) ; verbatim + (unless (= 0 (exit result)) (block "stderr" stderr))) + + (ask ; only when two readings give two results + (block "?" (numbered candidate-programs))) ; user answers with the number + + (example + (user "sales.csv > group region > sum amount > top 2") + (reply + (block "peg" "pipeline <- 'read' path ('|' stage)* +stage <- 'group' field / 'sum' field / 'top' int") + (block "flow" "read sales.csv | group region | sum amount | top 2") + (block "" "west 4100 +north 3900")))) +``` diff --git a/server.py b/server.py new file mode 100644 index 0000000..6645b5c --- /dev/null +++ b/server.py @@ -0,0 +1,139 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["mcp>=2,<3"] +# /// +"""MCP server: a registry of DSLs. + +Each DSL is a folder in $DSL_HOME (default ~/.dsl) that holds a grammar and +one interpreter file, impl.<ext>. `run` pipes a program to that interpreter. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("dsl") + +NAME = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}") +LIMIT = 100_000 # characters of stdout, and of stderr, that `run` returns + +# Interpreter argv for each impl extension. `run` appends the impl path. +RUNTIMES: dict[str, list[str]] = { + "awk": ["awk", "-f"], + "sh": ["sh"], + "lisp": ["sbcl", "--script"], + "scm": ["guile", "--no-auto-compile", "-s"], + "hs": ["runghc"], + "clj": ["bb"], + "js": ["node"], + "py": [sys.executable], +} + + +def home() -> Path: + return Path(os.environ.get("DSL_HOME") or Path.home() / ".dsl") + + +def table() -> dict[str, list[str]]: + """RUNTIMES, plus the entries of $DSL_HOME/runtimes.json when it exists.""" + override = home() / "runtimes.json" + extra: dict[str, list[str]] = ( + json.loads(override.read_bytes()) if override.exists() else {} + ) + return RUNTIMES | extra + + +def folder(name: str) -> Path: + # The name becomes a path segment, so reject anything that can leave home(). + if not NAME.fullmatch(name): + raise ValueError(f"name must match {NAME.pattern}") + return home() / name + + +def impl(name: str) -> Path: + found = sorted(folder(name).glob("impl.*")) + if not found: + raise ValueError(f"no DSL named {name}") + return found[0] + + +@mcp.tool() +def runtimes() -> dict[str, bool]: + """Map each impl extension to whether its interpreter is on PATH.""" + return {ext: shutil.which(argv[0]) is not None for ext, argv in table().items()} + + +@mcp.tool() +def ls() -> dict[str, str]: + """Map each stored DSL name to the extension of its interpreter.""" + return {f.parent.name: f.suffix[1:] for f in sorted(home().glob("*/impl.*"))} + + +@mcp.tool() +def get(name: str) -> dict[str, str]: + """Return the grammar, the extension and the interpreter source of a DSL.""" + f = impl(name) + return { + "grammar": (f.parent / "grammar").read_bytes().decode(), + "ext": f.suffix[1:], + "source": f.read_bytes().decode(), + } + + +@mcp.tool() +def put(name: str, grammar: str, ext: str, source: str) -> str: + """Store or replace a DSL. + + `source` is the interpreter, in the language of `ext` (a key of `runtimes`). + It reads a program on stdin, writes the result to stdout, and exits non-zero + with a message on stderr for a bad program. + """ + if ext not in table(): + raise ValueError(f"ext must be one of {sorted(table())}") + d = folder(name) + d.mkdir(parents=True, exist_ok=True) + new = d / f"impl.{ext}" + (d / "grammar").write_bytes(grammar.encode()) + new.write_bytes(source.encode()) + for old in d.glob("impl.*"): + if old != new: + old.unlink() + return f"{name}.{ext}" + + +@mcp.tool() +def run(name: str, program: str, timeout: float = 10) -> dict[str, str | int]: + """Run a program through the interpreter of a DSL. + + The interpreter starts in the working directory of the server. + Return stdout, stderr and the exit code. + """ + f = impl(name) + argv = table()[f.suffix[1:]] + exe = shutil.which(argv[0]) + if exe is None: + raise ValueError(f"{argv[0]} is not on PATH") + # ponytail: captures all output in memory, then cuts it to LIMIT. + # Read through a capped pipe if a DSL can flood stdout within the timeout. + done = subprocess.run( + [exe, *argv[1:], str(f)], + input=program.encode(), + capture_output=True, + timeout=timeout, + check=False, + ) + return { + "stdout": done.stdout.decode(errors="replace")[:LIMIT], + "stderr": done.stderr.decode(errors="replace")[:LIMIT], + "exit": done.returncode, + } + + +if __name__ == "__main__": + mcp.run() diff --git a/test_server.py b/test_server.py new file mode 100644 index 0000000..c4806f8 --- /dev/null +++ b/test_server.py @@ -0,0 +1,105 @@ +"""Checks for server.py: a round trip, name validation, and one run over stdio.""" + +import asyncio +import json +import os +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from hypothesis import example, given +from hypothesis import strategies as st +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +import server + +SERVER = str(Path(__file__).with_name("server.py")) +UPPER = "import sys\nsys.stdout.buffer.write(sys.stdin.buffer.read().upper())\n" + + +# One interpreter per extension. Each copies its program to stdout. +ECHO = { + "awk": "{ print }\n", + "sh": "cat\n", + "lisp": "(loop for line = (read-line nil nil) while line do (write-line line))\n", + "scm": "(use-modules (ice-9 rdelim))\n" + "(let loop ((line (read-line)))\n" + " (unless (eof-object? line) (write-line line) (loop (read-line))))\n", + "hs": "main :: IO ()\nmain = interact id\n", + "clj": "(print (slurp *in*))\n(flush)\n", + "js": "process.stdin.pipe(process.stdout);\n", + "py": "import sys\nsys.stdout.write(sys.stdin.read())\n", +} + + +@pytest.fixture(scope="module", autouse=True) +def dsl_home(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]: + home = tmp_path_factory.mktemp("dsl") + # Keep the overrides of this machine, so the runtime test checks them. + mine = server.home() / "runtimes.json" + table = json.loads(mine.read_bytes()) if mine.exists() else {} + # "up" exists only in this override, so a run with it proves the override loads. + (home / "runtimes.json").write_text(json.dumps(table | {"up": [sys.executable]})) + with pytest.MonkeyPatch.context() as patch: + patch.setenv("DSL_HOME", str(home)) + yield + + +@given( + grammar=st.text(), + ext=st.sampled_from(sorted(server.RUNTIMES)), + source=st.text(), +) +def test_put_then_get_returns_the_same_dsl(grammar: str, ext: str, source: str) -> None: + server.put("t", grammar, ext, source) + assert server.get("t") == {"grammar": grammar, "ext": ext, "source": source} + assert server.ls()["t"] == ext + + +@given(name=st.text().filter(lambda s: not server.NAME.fullmatch(s))) +@example("../evil") +@example("a/b") +@example("") +def test_put_rejects_a_name_that_is_not_a_slug(name: str) -> None: + with pytest.raises(ValueError): + server.put(name, "", "py", "") + + +@pytest.mark.parametrize("ext", sorted(server.RUNTIMES)) +def test_each_installed_runtime_runs_an_interpreter(ext: str) -> None: + if not server.runtimes()[ext]: + pytest.skip(f"{server.table()[ext][0]} is not installed") + server.put("echo", "program <- .*", ext, ECHO[ext]) + result = server.run("echo", "a b\n", timeout=60) + assert (result["exit"], str(result["stdout"]).splitlines()) == (0, ["a b"]), result + + +def test_put_and_run_over_stdio() -> None: + async def call() -> Any: + params = StdioServerParameters( + command=sys.executable, args=[SERVER], env=dict(os.environ) + ) + async with ( + stdio_client(params) as (read, write), + ClientSession(read, write) as session, + ): + await session.initialize() + tools = {tool.name for tool in (await session.list_tools()).tools} + assert tools == {"runtimes", "ls", "get", "put", "run"} + dsl = { + "name": "upper", + "grammar": "text <- .*", + "ext": "up", + "source": UPPER, + } + await session.call_tool("put", dsl) + result = await session.call_tool( + "run", {"name": "upper", "program": "a b\n"} + ) + assert not result.is_error + return result.structured_content + + assert asyncio.run(call()) == {"stdout": "A B\n", "stderr": "", "exit": 0}