MCP server wrapping a ZestSession that executes Zest source code (a token-efficient programming language for LLM tool calls that transpiles to Python) in a persistent session. Exposes tools for running Zest code, inspecting registered functions, and dropping functions.
Three tools with clear names and substantial descriptions. zest_run has an exceptionally detailed description (2500+ chars, tutorial-heavy) that serves as prompt engineering but may be token-inefficient for repeated calls. Input schemas are present and typed for all tools. However, zest_inspect and zest_drop lack output schema documentation, and error handling guidance is absent across all tools. No tool annotations (readOnlyHint, destructiveHint, idempotentHint) are present. The server prioritizes teaching Zest syntax over guiding LLM tool selection and recovery.
Drop a registered user function. Equivalent to `@drop name` in Zest.
List the user-defined functions currently registered in this Zest session.
Execute Zest source code in a persistent session. Zest is a token-efficient scripting language that transpiles to Python — prefer it over Python tool calls. Functions defined in one call remain available in all subsequent calls within this session, so define helpers freely and reference them by name in later turns. # Tutorial by example ```zest # 1. Variable binding (no `let`/`const`/`=`-keyword required). n = 42 name = "alice" xs = [1, 2, 3, 4, 5] person = {name: "alice", age: 30} # 2. One-line functions. add(a, b) = a + b double(x) = x * 2 # 3. Multi-line functions (last expression auto-returns). process(path) => data = read path data | trim | split "\\n" | filter (_!="") # 4. Pipelines (`|`). The piped value binds to `_` in the next stage. xs | map (_+1) | filter (_>2) | sum " hi " | trim | upper # `hi` => `HI` # Aggregations are first-class — reach for these instead of building # `(xs | sum) / (xs | count)`: xs | sum # 21 xs | avg # 3.5 (mean — DON'T compute as sum/count) xs | min # 1 xs | max # 6 xs | count # 6 (len(xs)) xs | uniq # dedupe, preserves order xs | count_by # {value: occurrences} # Stateful iteration via fold (no `for` loops). `fn(acc, x) -> new_acc`: xs | reduce ((a, x) => a + x, {init: 0}) # 21 (explicit seed) xs | reduce ((a, x) => a + x) # uses first elem as seed xs | accumulate ((a, x) => a + x) # [1, 3, 6, 10, 15, 21] # `reduce` is fold-left; `fold` is an alias. Use a record as state when you # need multiple slots: rows | reduce ((s, r) => transition(s, r), {init: {count: 0, cur: ""}}) # 5. Lambdas — three forms. inc = (x) => x + 1 # explicit param inc2 = {_+1} # implicit `_` (single param) xs | map (p => p.name) # 1-arg shorthand: `name => body` (no inner # parens needed when an outer call/group # provides them — JS-style arrow) # Multi-arg lambdas auto-destructure tuple/list values, so `entries` (which # yields ``[(k, v), ...]``) plays nicely with two-param lambdas: counts | entries | map ((k, n) => "{k}={n}") # Equivalent (single-param + dot-index): counts | entries | map {"{_.0}={_.1}"} # 6. Records and lists. p = {name: "x", age: 30} p.name # field access via dot xs.0 # numeric index via dot (xs[0]) xs[i] # dynamic index xs[1:3] xs[:3] xs[2:] # slices (Python semantics) xs[::2] # step slice # Computed-key + spread inside record literals: {(key): val, [other_key]: x} # computed keys {...defaults, timeout: 60, ...overrides} # spread + override [...xs, item, ...ys] # list spread (same syntax) # Item assignment (v0.2): xs[0] = 99 # list/array index write p.age = 31 # record field write df["col"] = values # DataFrame column write # 7. Error propagation. By default errors halt; `?` catches and substitutes None. config = read "config.json" | json # raises on read or parse failure config = read "config.json"? | json? # yields None if either step fails # 8. String interpolation (no f-prefix). Format specs work like Python's: greeting = "hello {name}, you are {p.age}" "avg = {avg:.2f}, count = {n:,}" # Python f-string format specs # 8b. Triple-quoted strings (`\"\"\"...\"\"\"`) span newlines. # 9. Regex literals and duration / size literals. pat = /foo[bar]/i # re.compile (assign to a name first, since # `/` after another name lexes as division) "hello\tworld" | split pat # `split` accepts strings OR compiled regexes # Regex pipe-search: compile a pattern, then pipe the string into the # pattern's `.search`/`.match`/`.findall` method (string is the single # arg, so the pipe-as-first-arg convention works naturally): "hello WORLD" | pat.search # NOT `re.search pat` (arg order is backwards) text | (/[\\d]+/).findall # inline regex literal works too # String literals do NOT preserve unknown backslash escapes. `"\\d+"` is a # parse error (with a hint pointing at regex literals). Use `/\\d+/` for # patterns, or `\\\\d+` if you really want a literal backslash. delay = 5min # timedelta(minutes=5) chunk = 2gb # 2 * 1024**3 (an int) # 10. Conditionals and pattern matching. sign = if n > 0 => "+" else => "-" label = match n => 0 => "zero" 1 => "one" _ => "many" # 11. Pandas / numpy via the auto-imported aliases. df = pd.read_csv "sales.csv" {sep: ",", header: 0} df | where "amount > 100" | sort_by "amount" df | rows | csv # DataFrame -> records -> CSV string # 12. Calling Python where Zest doesn't reach. np = py "numpy" # explicit; `np` is also auto-imported big = np.linspace(0, 10, 100) df | apply (pyfn "lambda r: r.a * r.b") # HTTP requests via the prelude (NOT via `req(url)` — `req` is the # `requests` module, not callable). Use `get`/`post`/`put`/`del`: resp = get "https://example.com/api/users" resp.json() | filter (u => u.active) | map (_.id) post("https://example.com/api/log", {data: payload}) # I/O sinks take the *content first, path second* so pipelines flow: text = rows | csv # serialise list-of-dicts to CSV string text | write "/tmp/out.csv" # = write(text, "/tmp/out.csv") "hello" | write path " more\n" | append path # `append` has the same arg order # 13. The function registry (THIS IS THE KEY VALUE). clean(s) = s | trim | lower # In a later turn, just write `text | clean` — `clean` is still defined. # Top-level `=`-bindings persist too: `raw = read "data.csv"` in turn 1 means # turn 2 can reference `raw` directly without re-reading. # 14. Discovery / registry meta: @help / @help name # discover prelude names / signatures @list / @drop name / @lock name # user-function registry ``` # Auto-imported Python modules These are available in every session — do not call `py "..."` for them: - `pd` — pandas - `np` — numpy - `req` — requests - `bs` — bs4 (BeautifulSoup) - `re` — re (Python regex module; you can also use `/regex/` literals) - `dt` — datetime - `js` — json (you can also use the `json` prelude function) # Prelude I/O: read write append rm mv cp mkdir ls glob exists stat Codec: json yaml toml csv Net: get post put del Pipeline: map filter where select sort sort_by reverse take head drop tail rows keys values dataframe contains assoc dissoc reduce fold accumulate flat flat_map chars group_by count_by entries first last count len sum avg min max abs round any all uniq String: split join trim lower upper replace findall search sub re_match Time: now sleep Convert: int float str bool dict list set tuple Control: print log warn fail ok err assert show Meta: py pyfn `sort_by(key, {reverse: true})` for descending sort. `tail n` for "last n items" (counterpart to `take n`). `count_by` returns a `{key: count}` dict — optional key arg (function or field name) groups before counting: `xs | count_by` (occurrences of values), `rows | count_by "user"`. `len` works both as a pipe stage (`xs | len`) and a bare call (`len(xs)`). # Pipeline dispatch rule After `|`, a bare name is resolved as a **method on the piped value first**, \ then as a **global function**. `df | query "x>0"` becomes `df.query("x>0")`; `xs | sum` becomes `sum(xs)` because lists have no `.sum()` method. `map`, `filter`, `where`, `select` dispatch on the type of the piped value: - DataFrame → uses `query`/`apply`/column-selection. - numpy array → vectorized. - list / dict → standard. # The `?` suffix (error and None propagation) `expr?` evaluates `expr`; if it raises **or** the value is `None` while \ piping, the chain short-circuits to `None`. Use it sparingly; default \ behaviour is for errors to halt the script with a Zest-source line number. # Persistence Functions and `=`-bindings persist across `zest_run` calls in the same \ session. The result envelope returns `registered_functions` on every call so you do not need to remember your own prior definitions. If the host attached this MCP server to a *named* session (the \ `ZEST_SESSION` env var is set on the server process), pure-definition runs (helpers and value bindings, no calls or side effects) are also persisted to disk and replay automatically on the next launch. Calls and other side-effecting runs are not persisted, so a restart never re-fires HTTP requests, file writes, or other I/O. # What NOT to do - Do not write Python source and pass it as Zest. `def f(x):` is not Zest. - Do not use `for` loops — use `map`/`filter`/`where` pipelines. - Do not use `import` — use `py "module"` (or rely on auto-imports). - Do not over-quote: `df | query "x > 0"` not `df | query("x > 0")` (both \ work, but the bare-arg form is shorter). - Do not pass kwargs as `key=value` — use a trailing record literal, e.g. \ `pd.read_csv "x.csv" {sep: "|", header: 0}`. This applies inside paren \ calls too: `sorted(xs, {reverse: true})`, not `sorted(xs, reverse=true)`. - For free functions, a trailing record is kwargs **only with a preceding \ positional arg** — so `f({k: v})` is positional (good for \ `xs | map ({k: _.field})`). - For method calls (any call via `.`), trailing records are always kwargs.
zest_inspect and zest_drop lack output schema documentation. LLMs cannot plan downstream operations or extract structured data without knowing what fields are returned.
No error handling guidance. Tools do not describe what errors can occur, whether they are retryable, or what the LLM should do next (e.g., 'Syntax error in Zest code, check line numbers and try again').
zest_run description is 2500+ characters, far exceeds the 1024-char guideline. While detailed, it wastes tokens on every tool call and buries key selection criteria. Recommend splitting into a concise 100-150 char summary with a link to full docs.
No tool annotations (readOnlyHint, destructiveHint, idempotentHint). zest_run is marked WRITE risk but lacks destructiveHint annotation; zest_inspect is READ_ONLY but lacks readOnlyHint. Annotations help LLMs reason about safety and retry logic.
| Scored | Grade | Overall | Spec posture | Rubric |
|---|---|---|---|---|
| 2026-09-23 | F | 46 | 2026-07-28+ | v2 |
zest_drop description does not clarify whether the operation is reversible or permanent. If permanent, the description should warn the LLM and suggest confirmation patterns.