Recently Written · git

dsl-agent

Claude Code plugin: an agent that speaks only DSL, and the MCP server that stores and runs the DSLs

git clone https://github.com/equwal/dsl-agent

Log | Files | Refs


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}