glm-workflow-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@glm-workflow-mcpimplement user registration endpoint with tests"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
glm-workflow-mcp
A zero-dependency MCP server that delegates real, autonomous coding work to Z.ai's GLM models.
glm_agent is not a prompt wrapper. It runs a genuine agent loop: GLM reads files, searches the tree, edits, writes, runs your test suite, reads the failures, fixes them, and re-runs — for 3–15 minutes — then hands back a structured report whose file list and exit codes are computed from git and from real processes, not from the model's claims.
It exists so that a Claude Code session can fan work out: dispatch a dozen scoped implementation tasks to GLM, keep orchestrating while they run, and collect verified diffs.
No runtime dependencies. Node 20.19+/22.12+/24+ built-ins only (
node:*and globalfetch).No build step. Plain ESM, published as source.
macOS and Linux only. WSL works. The shell tool spawns
/bin/bashdirectly and kills by POSIX process group, sopackage.jsondeclares"os": ["darwin", "linux"]and npm refuses the install on Windows withEBADPLATFORMrather than letting everybashcall fail at run time.stdio transport. One MCP client spawns one process, so the concurrency semaphore, circuit breaker and spend quota are genuinely process-wide across every caller sharing that client.
Contents
The safety model ← read this before enabling bash
Related MCP server: Z.ai Integration MCP Server
Install
You need a Z.ai API key from https://z.ai. Note which product the key is billed against — a coding-plan subscription and pay-as-you-go credit use different base URLs and are not interchangeable. The default here is the coding-plan endpoint; see Endpoint and model and the 1113 entry in troubleshooting.
.mcp.json
Drop this in your project root:
{
"mcpServers": {
"glm": {
"type": "stdio",
"command": "npx",
"args": ["-y", "glm-workflow-mcp@0.1.1", "--root", "${PWD}"],
"timeout": 1800000
}
}
}Three things about that snippet are load-bearing:
Pin the version. A bare
npx -y glm-workflow-mcpsilently upgrades a package that writes files and runs shell commands, on every single launch. Pin it and bump deliberately.timeoutmust exceedGLM_MAX_RUNTIME_SECONDS(default 720 s).1800000ms gives plenty of head-room. The server emits MCP progress notifications on every agent iteration, which is what stops Claude Code's idle timer firing during a 12-minute run.--rootis mandatory and there is no working-directory fallback.npxinherits the MCP client's cwd, which is frequently$HOME. Refusing to guess is the point.
There is deliberately no env: block in that snippet. Supply the key out-of-band instead — see Provide your key immediately below. The common "env": { "GLM_API_KEY": "${GLM_API_KEY}" } line is a trap: when GLM_API_KEY is not set in the MCP client's own environment, Claude Code substitutes nothing and hands the server the literal string ${GLM_API_KEY}. That literal is not a key — the server ignores it (it does not shadow a valid credentials file), but it does not supply one either, so with env: as your only channel you get a degraded server and a .mcp.json that is silently wrong. Only add that env: line if you have actually exported GLM_API_KEY in the shell that launches your client.
Provide your key
You need a Z.ai API key from https://z.ai (see the note above about which billing product it is for). Pick one of these two methods — the server reads them in this order, and a value that is not a credential (empty, an unexpanded ${VAR}, or shorter than 16 characters) is treated as absent and does not shadow the next source.
Method A — the credentials file (preferred, and required if you enable bash). A key passed through the environment stays readable in /proc/<pid>/environ on Linux; a 0600 file does not. This is why Key custody recommends it and why the .mcp.json above has no env: block.
mkdir -p ~/.config/glm-workflow-mcp
printf '{"apiKey":"YOUR_KEY"}\n' > ~/.config/glm-workflow-mcp/credentials.json
chmod 600 ~/.config/glm-workflow-mcp/credentials.jsonThe file must contain exactly {"apiKey":"..."} and must be mode 0600 (owner read/write only) — the server refuses to read it at any looser mode rather than use a group- or world-readable key file, and names chmod 600 on stderr if it finds one. Its default location is $XDG_CONFIG_HOME/glm-workflow-mcp/credentials.json, falling back to ~/.config/glm-workflow-mcp/credentials.json; override it with GLM_CREDENTIALS_FILE or --credentials-file <path>. With the file in place, leave the env: block out of .mcp.json entirely.
Method B — an environment variable. Export the key in the shell that launches your MCP client, before the client starts:
export GLM_API_KEY=YOUR_KEY # ZAI_API_KEY / ZHIPU_API_KEY are also acceptedOnly if you use Method B does the "env": { "GLM_API_KEY": "${GLM_API_KEY}" } line belong in .mcp.json — and it works solely because GLM_API_KEY is now set in the client's own environment for ${GLM_API_KEY} to expand against. The key is deleted from process.env immediately after boot reads it and is held in memory only, but on Linux with bash enabled it remains recoverable from /proc/<pid>/environ (Method A avoids that).
Either way, restart your MCP client after supplying the key. Then jump to Verify it works. If you skip this step, the server still starts — degraded:
If the key is missing
The server does not exit when no key can be found. It starts in a degraded mode instead, exit code 0, and writes a full diagnosis to stderr — every source it checked, in order, with the resolved path and what each one said, then the two ways to fix it:
glm-workflow-mcp: no usable GLM API key was found. Sources checked, in order:
1. environment variable GLM_API_KEY -> the literal string "${GLM_API_KEY}" — your MCP client never expanded the variable, so this is not a key and is ignored
2. environment variable ZAI_API_KEY -> not set
3. environment variable ZHIPU_API_KEY -> not set
4. credentials file /Users/you/.config/glm-workflow-mcp/credentials.json
-> no such file
...
(a) the credentials file ... (b) export the variable ...
glm-workflow-mcp: starting anyway — glm_health reports this, the initialize instructions
glm-workflow-mcp: carry it, and every model-calling tool will refuse with this reason
glm-workflow-mcp: until a key is configured.That is deliberate: initialize, tools/list and glm_health need no key, and a server that exits at boot appears to the client as "failed to start" with the reason buried in a log nobody opens. The practical consequence for you is that your MCP client will show the server as connected and healthy while it is not usable. glm_health reports api_key_present: false and ok: false; glm_agent and glm_ask return isError: true with a message naming the credentials file. The full diagnosis above is also prepended to the first tool result and carried in the server's connect-time instructions, so you see it without hunting for the stderr log. Check glm_health first, not the process list — and see Where are the logs? for where your client keeps that stderr.
Global install
npm i -g glm-workflow-mcp
glm-workflow-mcp --versionThen point command at glm-workflow-mcp instead of npx in the config above. Running glm-workflow-mcp --root /path/to/project by hand does not give you a REPL — see below.
Verify it works
This is a stdio JSON-RPC server, not a CLI. Launched from an interactive shell it waits on a stdin that never reaches EOF, prints a few lines to stderr, and appears to hang. There is nothing to type at it. Two checks that do terminate:
# 1. the binary resolves and runs at all (prints the version to stderr, exits 0)
npx glm-workflow-mcp@0.1.1 --version
# 2. it speaks MCP — pipe one request in; closing stdin exits the process
printf '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' \
| GLM_API_KEY=... npx glm-workflow-mcp@0.1.1 --root "$PWD"The second prints a single JSON-RPC line on stdout containing "serverInfo":{"name":"glm-workflow-mcp",…} and exits 0. It does not call the API, so it proves wiring, not credentials — and because of the degraded-start behaviour above it succeeds even with a bad key.
To verify the key and the endpoint, ask Claude Code — against the server Claude Code spawned from your .mcp.json, which is a different process from anything you start in a terminal:
call glm_health with probe: trueLook for "probe": { "ok": true, … }. A failing probe prints the error kind (auth_error, quota_exhausted, api_contract_error) — start from Troubleshooting.
The five tools
Tool | Blocking | What it is for |
| yes, 3–15 min | One autonomous coding task, start to verified finish. |
| no, <1 s | Same parameters; returns a |
| optional | Poll a run, or block on it with |
| yes, 5–40 s | One question, one answer. No tools, no filesystem. |
| yes, <1 s | Config, caps, bash confinement posture, semaphore, breaker, quota, live runs, recent errors. |
glm_agent
Parameter | Type | Default | Notes |
| string, 20–20000 | — | Required. Self-contained brief: goal, constraints, acceptance criterion, how to verify. No questions — nobody answers them. |
| string | server root | Absolute path; must resolve inside the root. |
| string, ≤20000 | — | Facts you already know: file paths, framework, conventions, a prior run's blockers. Strongly recommended; it directly buys back iterations. |
| int 4–80 | server's configured value (30 by default) | A request for less, never more — see Per-run budgets. 10–15 for a small localised change, 40–60 for a multi-file feature. |
| int 60–1500 | server's configured value (720 by default) | Wall clock. Also clamped down to the operator's ceiling. |
| enum |
|
|
| bool | server default |
|
|
|
|
|
glm_agent_status
Parameter | Type | Default | Notes |
| string | — | Omit to list every run in this session. |
| int 0–300 | 0 | Block for up to this long instead of busy-polling. |
| bool | false | Last 200 log lines from the run transcript. |
| string | — | Filter those lines, e.g. |
glm_ask
Parameter | Type | Default |
| string, 1–400000 | required |
| string, ≤8000 | — |
| enum |
|
|
|
|
| number 0–1 | 0.3 |
| int 64–32000 | 4096 — the schema accepts the whole range, and anything below 4096 is raised to 4096 on the wire, see below |
| bool | false |
glm_ask cannot see your repository. Paste in whatever it needs. The whole call is abandoned after 300 s, including time spent waiting for a model-call slot or the circuit breaker.
glm_health
probe: true additionally makes one real minimal request to confirm the key and endpoint work end to end (30 s deadline). Call it before a large fan-out to check quota, and after an unexplained failure.
Read bash_confinement if you care about the security posture:
cwd_confinedis alwaysfalse— the shell is not confined to the root;path_deny_backendnames the OS mechanism actually protecting the credential paths:sandbox-execon macOS,nonewhere there is no backend;protected_pathsis the count of paths that deny covers.
state_dir is deliberately not reported, because a tool result is readable by anything that can influence the transcript; it is in the server's boot log instead.
Worked example: parallel fan-out
The pattern this server was built for. Suppose you are adding six REST endpoints to a WordPress plugin and you want them written in parallel.
1 — Check you have headroom.
glm_health {}Look at scheduler.quota.remaining and scheduler.runs. Each agent iteration costs one call from the hourly bucket, so a six-way fan-out at 30 iterations is up to 180 calls out of the 600/hour default.
2 — Dispatch all six without waiting.
Because they all touch one repository, use isolation: "worktree" so they do not serialise behind the per-directory lock:
glm_agent_start {
"task": "Add a REST route `GET /wp-json/acme/v1/settings` in inc/class-rest-settings.php returning the plugin options as JSON. Register it on `rest_api_init`. Follow the existing controller pattern in inc/class-rest-products.php exactly: same namespace constant, same permission_callback using current_user_can('manage_options'), same schema shape. Acceptance: `composer exec phpunit -- --filter Settings` passes, and `php -l` is clean on every file you touch.",
"context": "PHP 8.2, WordPress 6.6. Autoloading is PSR-4 via composer.json under the Acme\\ namespace. Tests live in tests/ and use WP_UnitTestCase. inc/class-rest-products.php is the canonical example of a controller in this codebase.",
"isolation": "worktree",
"max_iterations": 35
}…and five more like it. Each returns in under a second:
GLM agent dispatched: run 20260722T143108Z-a3f9c1d2
running. Poll with glm_agent_status.3 — Do other work, then collect. Poll with a long wait rather than a busy loop — one call per run, each blocking up to two minutes:
glm_agent_status { "run_id": "20260722T143108Z-a3f9c1d2", "wait_seconds": 120 }4 — Triage on facts, not prose. For each returned envelope:
status: "success"→ readverification; every entry has a realexit_code(ornull, which means the harness could not match the model's claimed command to a command it actually ran — treat that as unverified).status: "partial"→ readblockersfirst. A blocker is usually answerable ("could not find the test command"), so resolve it and re-dispatch with the answer incontext.status: "failed"→ readblockersandstats.recovered_errors.retryable: true→ the failure was environmental (quota, queue, provider tool-call parser); re-dispatch as-is later.
Never trust summary over files_changed. files_changed comes from git diff --numstat; summary is model prose. If the model claimed a file that git says is unchanged, it has already been dropped and counted in stats.recovered_errors.hallucinated_files.
5 — Apply the worktree patches. Each run's diff_path points at a real patch file:
git apply ~/.glm-workflow-mcp/runs/20260722T143108Z-a3f9c1d2/diff.patch6 — Debug a bad run.
glm_agent_status { "run_id": "...", "include_log": true, "log_grep": "ERROR" }Writing a good task brief
The single biggest quality lever. The agent has no human to ask, so an ambiguity becomes an assumption.
Do state the acceptance criterion as a command: "Acceptance:
npx vitest run src/cartpasses."Do name the file to imitate: "follow the pattern in
src/lib/session.ts."Do put known paths, the framework and the conventions in
context. Every fact you supply is an iteration it does not spend rediscovering.Don't ask questions. Don't say "let me know if…". Nobody is listening.
Don't bundle three unrelated changes. Dispatch three runs.
Don't ask for anything that needs a long-lived background process. Nothing survives a single
bashcall — see Shell.
Configuration
CLI flags beat environment variables. Everything is read once at boot into a frozen config. Two exceptions read process.env directly at the moment they are used, and are therefore environment-only with no flag: GLM_KEEP_WORKTREE and GLM_TOOLCHAIN_ENV.
Required
Variable | Flag | Notes |
| — | Or |
|
| The directory the server's file tools are confined to. |
Variable | Flag | Default |
|
|
|
Endpoint and model
Variable | Flag | Default |
|
|
|
|
|
|
| — |
|
| — |
|
| — |
|
Get the base URL right before anything else. Z.ai serves two OpenAI-shaped chat-completions paths and they are billed against different products:
Base URL | Billed against |
| a Z.ai coding-plan subscription — the default here |
| pay-as-you-go API credit |
A key issued for one returns error 1113 on the other, for every model — and it arrives with HTTP 200, so anything that only checks the status code reads it as success. The default is the coding-plan path because that is what a Z.ai coding-plan key needs; set GLM_BASE_URL to the paas path if you are on pay-as-you-go credit instead. Boot logs a warn to stderr when the configured URL is the pay-as-you-go one, so the mismatch is visible before the first failed run. See Troubleshooting.
You may give either form, with or without the /chat/completions suffix — a bare base such as https://api.z.ai/api/coding/paas/v4 (the form the docs and the Z.ai tooling use) gets the suffix appended rather than being POSTed to as-is.
Boot also rejects any URL containing /api/openai/ — that path answers HTTP 200 with {"code":500,"msg":"404 NOT_FOUND"}, which looks like success to naive clients and is a genuinely nasty misconfiguration to debug.
The model list is hardcoded, because the agent must validate a model name before it spends a call and this API has no models endpoint. An unknown name fails at boot naming the eight valid ones. There are no -flash/-flashx variants of the 4.7 or 5.x lines.
GLM_MAX_TOKENS_PER_TURN deserves a word: GLM-5.2 is a reasoning model, and reasoning tokens are spent from the same budget before a single output token appears. A small cap does not give you a short answer, it gives you finish_reason: "length" with content: "" and no tool call at all — verified: max_tokens: 20 produced 15 reasoning tokens and nothing else. Because max_tokens is a cap rather than an allocation, a floor costs nothing when the turn is short, so the client raises any request below 4096 to 4096 before sending, then caps it at the model's own output ceiling. This applies to glm_ask's max_tokens too: its schema accepts the documented 64–32000 range (rejecting a value the docs call valid would cost the caller a turn for nothing), and the raise happens on the wire. Setting either to 256 therefore does not actually get you 256.
Shell access
Variable | Flag | Default |
|
|
|
|
| see below — replaces, does not extend |
| — |
|
| — | unset. |
State
Variable | Flag | Default |
|
|
|
| — |
|
| — | unset. Any non-empty value keeps a run's throwaway worktree on disk for debugging. |
Run directories, diffs and transcripts live under $GLM_STATE_DIR/runs/<run_id>/ — never inside your repository. That keeps git status clean, keeps diff attribution honest, and stops the inner agent from reading its own transcript.
isolation:"worktree" checkouts do not live in the state directory. They are created with mkdtemp under os.tmpdir() — /var/folders/…/glm-worktree-XXXXXX/w on macOS, /tmp/glm-worktree-XXXXXX/w on Linux — and removed with git worktree remove --force when the run ends. That move is what allows the entire state directory to be denied to the agent; see Protected paths.
The server refuses to boot if $GLM_STATE_DIR contains --root, because the whole state directory is denied and nothing in the workspace would then be readable.
Per-run budgets
Every one of these is clamped, not rejected: a value outside the range is silently pulled to the nearest bound, so GLM_MAX_READ_BYTES=104857600 gives you 64 MiB and GLM_MAX_COST_USD=5000 gives you $1000. Check glm_health → caps for what the server actually settled on.
Variable | Default | Clamped to |
|
| 4–80 |
|
| 60–1500 |
|
| 10000–1000000 |
|
| 0.01–1000 |
|
| 4096–67108864 (64 MiB) |
|
| 4096–67108864 (64 MiB) |
|
| 1–10000 |
|
| 1–10000 |
GLM_MAX_ITERATIONS and GLM_MAX_RUNTIME_SECONDS are ceilings, not defaults. They also have flags (--max-iterations <n>, --max-runtime <seconds>). The effective budget for a run is:
effective = min(tool argument, operator's configured value)so a caller can only ask for less than the operator allowed. If you set --max-iterations 10, every run on this server gets at most 10 iterations no matter what glm_agent was called with. The clamp is silent but visible after the fact: the effective value is echoed in every envelope's stats.max_iterations, and glm_health reports the ceilings as caps.max_iterations / caps.max_runtime_seconds. The tool schema separately rejects arguments outside 4–80 and 60–1500 as invalid, and boot clamps the operator's own value into the same ranges.
This matters because the section below calls the hourly quota the only thing standing between a runaway fan-out and a large bill. It is not quite the only thing — these two ceilings and GLM_MAX_COST_USD bound a single run — but the quota is the only server-wide one.
Server-wide concurrency and quota
Clamped the same way.
Variable | Default | Clamped to | Notes |
|
| 1–8 | Starting value only; AIMD then moves it between 1 and 8 from observed rate limits. |
|
| 1–64 | Beyond this, runs queue (10-minute cap → |
|
| 1–100000 | Leaky bucket across the whole server, refilling continuously. |
Not configurable: at most 6 bash children run concurrently across the whole server, whatever the run concurrency is. Further calls queue.
Misc
Variable | Flag | Default |
|
|
|
|
| unset (blocked) |
The safety model
Be precise about what this does and does not give you. Short version: the file tools have a real boundary; the shell does not. If you need one for the shell, run the server in a container or a VM.
Key custody
At boot, before anything else can observe the environment, the server takes the key and then deletes GLM_API_KEY, ZAI_API_KEY, ZHIPU_API_KEY, ANTHROPIC_API_KEY and OPENAI_API_KEY from process.env. It is held in memory only.
Child shells get an allowlisted environment built from scratch — process.env is never spread into it. Only these pass through: PATH, LANG, LC_ALL, LC_CTYPE, TZ, TERM, TMPDIR, USER, LOGNAME, SHELL, COLUMNS, LINES. Any variable whose name matches KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|_AUTH|SESSION|COOKIE is dropped, and a kill list removes the loader- and shell-hijacking variables (BASH_ENV, ENV, SHELLOPTS, BASHOPTS, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, NODE_OPTIONS, PYTHONSTARTUP, PYTHONPATH, PERL5OPT, RUBYOPT, GIT_SSH_COMMAND, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM). BASH_ENV in particular was verified to re-source rc files under bash -c and hand a scrubbed key straight back.
HOME and every XDG_* variable are then repointed at a scratch directory — see The scratch HOME — and GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM are pinned to /dev/null, after the merge, so no caller can put the real ones back.
Environment scrubbing is incomplete on Linux when bash is enabled. delete process.env.X does not rewrite /proc/<pid>/environ, so a key handed in through .mcp.json's env: block stays recoverable by any command the agent runs. There is a tripwire that refuses commands mentioning /proc/*/environ, but a tripwire on a shell string is not a boundary.
If you enable bash, prefer the credentials file over the environment:
mkdir -p ~/.config/glm-workflow-mcp
printf '{"apiKey":"YOUR_KEY"}' > ~/.config/glm-workflow-mcp/credentials.json
chmod 600 ~/.config/glm-workflow-mcp/credentials.jsonThe server refuses to read that file at any mode looser than 0600. With it in place you can omit the env: block from .mcp.json entirely.
Its location is $XDG_CONFIG_HOME/glm-workflow-mcp/credentials.json, falling back to ~/.config/glm-workflow-mcp/credentials.json; override with GLM_CREDENTIALS_FILE or --credentials-file <path>. It lives outside $GLM_STATE_DIR on purpose, and both it and its directory are on the deny list below.
$GLM_STATE_DIR/config.json is a legacy key location and is not supported for new installs. A key found there on boot is copied into the credentials file, stripped from the old file (which is deleted if nothing else was in it), and a note is written to stderr. If the migration fails the server says so loudly and tells you to run with --bash off until you move it yourself. Do not put a key there.
Protected paths
Before the key is even loaded, the server registers a deny list that no tool — file tool or shell — may read, write or list:
the credentials file and its directory
the entire
$GLM_STATE_DIR— transcripts, logs, manifests, diffs, and the legacyconfig.jsonthe operator's own credential stores:
~/.ssh,~/.aws,~/.gnupg,~/.kube,~/.docker,~/.config/gh,~/.npmrc,~/.netrc,~/.git-credentials,~/.pypirc,~/.config/glm-workflow-mcp
Each entry is registered both lexically and by realpath, so a symlinked parent is not a free bypass. On a typical macOS install that comes to 17 registered paths; glm_health → bash_confinement.protected_paths reports the live count.
The whole state directory can be denied because isolation:"worktree" checkouts moved out of it and into os.tmpdir(). Earlier versions had to leave $GLM_STATE_DIR/worktrees/ readable — it was the agent's working directory — which put a ../.. traversal path from the agent's own cwd to the key file. That carve-out no longer exists. If you are reading an older description of this project that says the worktree subtree is deliberately unprotected, it is describing code that is gone.
This list is checked before GLM_ALLOW_SECRET_FILES. That flag exists so a run can edit the project's own .env; it must never unlock the key that pays for the run.
Enforcement differs by tool, and the difference is the whole point:
File tools (
read_file,write_file,edit_file,list_dir,search): an ordinary path check, applied to reads, writes, directory listings and filename-mode searches alike.list_dirdoes not descend into a protected directory and reports what it withheld as a count with no names ([3 entries withheld: credentials paths are not listed]);searchwithmode:"files"applies the same filter its content mode always did, and refuses apathargument that points at a credentials path rather than returning a misleading "no matches".bash: string tripwires everywhere, plus a kernel-level deny only where the platform provides a backend — which today means macOS only. On macOS the shell child is wrapped insandbox-execwith a profile that deniesfile-read*/file-write*on those paths (and, per-command, every other run's scratch home). On Linux there is no path-deny backend at all, so the credentials file and the operator's credential stores are guarded by string tripwires only — a substring match over the command text, whichcut,rev,xxd, apython -c, or a hundred other splitters defeat. That is not a boundary. On Linux, treatbashas able to read this server's own key file and your~/.ssh,~/.aws,~/.npmrcand the rest: if that is unacceptable for your repository, run with--bash offor run the server in a container.glm_healthreportspath_deny_backend— the honest answer, which isnoneon Linux.
Three honest caveats about the kernel deny, even where it exists:
sandbox-execis deprecated by Apple. Thesandbox-exec(1)command has carried a deprecation warning for years; it still works on current macOS and is the only user-space path-deny mechanism Apple exposes, but it is not a contract Apple has promised to keep. If a future macOS removes it, this backend becomesnone— the same posture as Linux — and the deny falls back to tripwires only.It fails open. The wrapper is probed once at boot with
/usr/bin/true. If that probe stops succeeding, commands run unwrapped rather than every command breaking. That is reported aspath_deny_backend: "none"and nothing else fails loudly. The smoke test asserts the backend issandbox-execon darwin precisely so a regression here cannot pass silently.It covers those paths only. It is not general containment, and
bashis not a sandbox. The first-word allowlist and the tripwires are footgun-catchers; a permitted command (cat,node,python,find, all allowlisted) can read anything the user running the server can read. The kernel deny narrows that to everything except the credentials file, the operator's credential stores, and other runs' scratch homes. Everything else on the machine your account can read,bashcan read. For real containment, use--bash offor a container.
Filesystem confinement — file tools only
This applies to the agent's five file tools (read_file, write_file, edit_file, list_dir, search). It does not apply to bash; see Shell below, and do not conflate the two.
The boundary is the run's working directory, not the server root: for isolation:"none" that is the run's cwd (itself validated to resolve inside --root before the run starts), and for isolation:"worktree" it is the throwaway checkout under os.tmpdir(). Every path the model supplies must be relative and must resolve inside it. Absolute paths are rejected outright — that kills a whole bug class rather than trying to adjudicate it.
Containment is tested with
path.relative, neverstartsWith. The usualstartsWith(root + sep)fix for CVE-2025-53110 breaks on a root with a trailing slash;path.relativehandles both, and correctly denies the sibling-prefix escape (/repomust not contain/repo_secrets).The root is
realpath'd at boot. Without that, a root of/tmpon macOS (which is really/private/tmp) denies every legitimate operation underneath it.NUL bytes are rejected before any path arithmetic, because
path.resolve('/a\0b')happily succeeds and carries the NUL through to a syscall that truncates at it.~and~/…are refused rather than expanded.For a path that does not exist yet, the nearest existing ancestor is
realpath'd and re-checked. Validating the merely lexical dirname here was CVE-2025-53109.All I/O opens with
O_NOFOLLOW, and readsfstatthe already-open descriptor rather thanstat-ing the path and then opening it.realpathcovers intermediate directory symlinks;O_NOFOLLOWcovers the final component. Both are needed.Non-regular files (fifos, devices, directories) are rejected.
Credential files
Refused by the file tools for read, write and listing, regardless of containment, unless GLM_ALLOW_SECRET_FILES=1:
.env, .env.*, .envrc, .envrc.*, .npmrc, .netrc, _netrc, .git-credentials,
.pypirc, .htpasswd, .pgpass, .my.cnf
id_rsa, id_dsa, id_ecdsa, id_ed25519, id_*, *.pem, *.key, *.p12, *.pfx,
*.keystore, *.jks, *.tfvars, *.tfvars.json
credentials, credentials.json, service-account*.json, wp-config.php
anything under .ssh/, .aws/, .gnupg/, .docker/, .kube/, .config/gh/Those directory names are matched against the path relative to the root, not the absolute path — a checkout that happens to live under a directory called .docker used to block its own entire tree with a misleading message.
This is what stops the file tools reading the .env you sourced the key from. It is a filename policy, not a boundary: bash does not consult it, and only the protected paths above are denied to the shell.
The scratch HOME
Every child process — the agent's bash calls and the harness's own git invocations — runs with HOME and XDG_* pointed at a throwaway scratch directory, mode 0700, never the operator's real home. Without that, ~/.ssh/id_ed25519, ~/.npmrc and ~/.aws/credentials are one shell expansion away, and ~ is expanded by bash itself so no tripwire ever sees the real path.
There are three kinds of scratch home, and the split is itself a control:
One per run. Each run gets its own home, torn down when the run ends.
One server-private "harness" home, used by the harness's own
gitsubprocesses and by nothing the agent influences. This is why the git-derived change report is trustworthy: an agent cannot plant a.gitconfigwhere the reportinggitwill read it.A per-call, deliberately non-existent path if directory creation itself fails. An unset HOME is survivable; a shared or leaked one is not.
This directory is writable by the run that owns it — it is a real HOME, agent-writable for the life of the run, not "an empty scratch dir" after the first command. A single shared scratch HOME was a genuine privilege escalation: run A plants $HOME/.gitconfig with diff.external, run B — even one dispatched with allow_bash:false, which has no shell at all — executes it during change reporting. GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM are additionally pinned to /dev/null so "which gitconfig does this git read" does not depend on HOME at all.
Distinct is not isolated — and three things, not distinctness, are what make these a boundary. Every scratch home lives inside one shared parent directory, and:
The parent is mode
0300(-wx, deliberately notr). Nothing — including the very uid that owns it — can list it:ls, a glob,findandreaddir()all fail withEACCES, because POSIX enforces the owner-class bits against the owner. Traversal into a home whose exact name you already hold still works, which is all a child needs. This is the only layer present on every platform, and it is what kills enumeration.The names are unguessable — 32 hex characters of CSPRNG output, not a run-id-derived tag. With enumeration blocked by (1), a run cannot name another run's home at all.
A per-command kernel deny, where the platform has one. On macOS each
bashcall'ssandbox-execprofile denies the whole scratch parent, then re-allows only the calling run's own home (and the server's protected paths, which win last). Verified: under that profile another run's home is unreadable and unwritable, and the caller's own home is fully usable.
Honest limits: running the server as root defeats layer (1), because root ignores the permission bits. And on a platform with no kernel backend — Linux today — layers (1) and (2) are the whole story, so a sibling process that learns a home path by some other channel (Linux /proc, which this README already documents as unclosable) is not stopped by a permission bit it does not need. If that matters, run in a container.
Lifetime, stated plainly: a run's home is torn down at the end of that run. Two backstops sit behind that primary teardown: any home left behind is reaped once it has been idle for 45 minutes (comfortably above the 1500 s hard runtime ceiling, so a live run's home can never be collected), the live set is capped, and everything — run homes, the harness home and the parent — is removed on server exit. What the parent's 0300 mode and the unguessable names buy is a containment boundary; the teardown is disk hygiene on top of it.
The cost is real and you should expect it. Every run starts with cold npm/pip/cargo caches, and toolchains that resolve their installation through $HOME — rustup, pyenv, nvm, sdkman, volta, rbenv — will not find themselves. That can make a verification command fail for a reason that has nothing to do with the agent's change, which would poison the one part of the envelope that is supposed to be trustworthy. Two mitigations:
When a command exits non-zero with an error characteristic of a scrubbed HOME, the harness appends a plain-language note to its stderr naming the scratch directory and telling the agent this is a harness limitation to report, not a defect to edit the project around.
GLM_TOOLCHAIN_ENV=1passes 26 toolchain locator variables through:CARGO_HOME,RUSTUP_HOME,GOPATH,GOROOT,GOCACHE,GOMODCACHE,NVM_DIR,VOLTA_HOME,PNPM_HOME,BUN_INSTALL,DENO_DIR,PYENV_ROOT,PIPX_HOME,VIRTUAL_ENV,CONDA_PREFIX,RBENV_ROOT,GEM_HOME,GEM_PATH,JAVA_HOME,GRADLE_USER_HOME,M2_HOME,MAVEN_HOME,SDKMAN_DIR,ANDROID_HOME,ANDROID_SDK_ROOT,DOTNET_ROOT. Each value is validated first: it must be absolute, must not be the real home, must not be a protected path, and must contain no.ssh/.aws/.gnupg/.docker/.kube/.config/ghsegment or secret basename — checked structurally, soGLM_ALLOW_SECRET_FILES=1cannot reach this decision.
No credential-bearing config is ever passed through, opt-in or not. NPM_CONFIG_USERCONFIG, COMPOSER_HOME and friends are exactly what the HOME scrub exists to hide. If you need a private registry inside a run, put the token in the project's own configuration where the task can legitimately see it, or run the server in a container with the credentials you are willing to expose.
Shell: a deliberate judgment call
restricted is the default — bash is ON. This is a considered deviation from "secure by default", and you should know why before you accept it.
bash is not confined to the root. It is a real /bin/bash running as the user account that started the server, so it can read and write anything that user can, inside the root or anywhere else on the machine. The root confinement and the credential-filename policy above are properties of the file tools; they say nothing about the shell. The only hard guarantees for the shell are the scrubbed environment with a per-run scratch HOME, and the protected paths — kernel-enforced only where the platform provides a backend.
The product contract is delegate real coding work and verify it. With bash off, the agent cannot run your tests, so every run comes back partial / unverified and the tool is close to worthless. The risk is bounded instead by defence in depth:
an
argv[0]allowlist (restricted mode)a pattern tripwire list that applies in every mode
a scrubbed, allowlisted environment with
HOME/XDG_*at a per-run scratch directory and git's global/system config pinned to/dev/nulla kernel path-deny on the credential stores where the platform offers one
/bin/bash --noprofile --norc— never a login shellno stdin, so nothing can hang on an interactive prompt
process-group kill on every exit path, not only on timeout (see below)
output caps (a stream is killed after 512 KiB; what you see is the first 8 KiB and last 16 KiB with the middle elided), a per-run command budget, and at most 6 concurrent shell children server-wide
every command logged to stderr before it runs
Set GLM_BASH=off (or per-call allow_bash:false) if you want none of it, or GLM_BASH=unrestricted if you have containment elsewhere. If a repository contains secrets that must not reach a model, that decision belongs to whoever starts the server — --bash off, or run the whole thing in a container.
No process survives a bash call
The child is spawned detached: true and its entire process group is SIGKILLed the moment the call returns — on a clean exit 0 just as much as on a timeout or an abort. &, nohup, disown and double-forking do not defeat this: a double-forked grandchild stays in the process group and is reaped with everything else. Verified: (python3 -m http.server 8731 &) in one call, then a fetch 700 ms later in the next call, gets connection refused.
This is deliberate — the alternative leaked orphaned servers past the end of the run and past server shutdown — but silent reaping would make an agent burn its whole iteration budget debugging a server the harness killed. So when a command exits cleanly and the harness finds its process group still populated, the result carries leftBehind: true and an explicit note in stderr saying the processes were killed and that a server needed for a test must be started, used and stopped inside a single command. The system prompt and the bash tool description say the same thing in the same words.
The command policy is not a security boundary, and this README will not pretend otherwise. Any denylist applied to a shell string can be defeated — find -delete, python -c, a base64 heredoc, a hundred other ways. The argv[0] allowlist is a footgun catcher: it stops the model casually running curl | sh or rm -rf /, not a determined adversary. If you need real containment, run the whole MCP server in a container or VM. That is the boundary.
Three specific non-claims:
This is not filesystem isolation. The allowlist gates the first word of each segment; it does not gate what that program then reads.
cat,find,nodeandpythonare all on the allowlist.This is not network isolation.
curl,wget,nc,ssh,scpand friends are merely off the allowlist, which only gates the first word of a segment —$(curl …),xargs curlandfind -exec curlall still run. Andnpm,pip,composer,goandgitreach the network legitimately and are all on the allowlist. Treat the agent as having network access.The tripwire denylist applies in every mode, including
unrestricted(rm -rf /, fork bombs,sudo,doas,mkfs,dd of=/dev/…, recursivechown/chmod 777, pipe-to-shell,crontab,launchctl,systemctl,shutdown/reboot,/proc/*/environ,~/.sshand$HOME/.ssh-style expansions,/etc/, shellhistory, force-push, …). It catches accidents. It does not stop attacks.
One more honest note: a segment whose first word cannot be identified — a long chain of leading VAR=value assignments, or stacked env/nice/command wrappers — is refused, not waved through. That path used to fail open.
Restricted mode checks the first word of each command. Only that word has to be on the allowlist; the shell grammar around it is not restricted. Redirections (2>&1, >f, >>f, &>f, <f), heredocs (<<EOF, <<<), the shell keywords (if/then/else/fi, for/while/until/do/done, case/esac), grouping ({ …; }, ( … )), negation (!), the separators && / || / | / ;, a trailing &, and leading VAR=value prefixes all work — but the allowlist still applies to every command inside them, so for f in *.js; do curl "$f"; done is refused on curl. A segment whose first word cannot be identified (a long chain of VAR=value or env/nice wrappers) and unbalanced quotes/parens/braces are refused, not waved through.
Allowlisted first words:
node npm npx pnpm yarn bun deno tsc vitest jest mocha eslint prettier
php composer wp phpunit pest
python python3 pip pip3 pytest ruff mypy black
go cargo rustc rustup make cmake gradle mvn dotnet ruby bundle rake
git ls cat head tail wc sort uniq cut tr sed awk grep egrep rg find fd
mkdir cp mv touch ln diff patch stat file basename dirname realpath
echo printf pwd true false test [ which type env date sleep tee xargs cd
tar unzip gzip gunzip jq yq
read set unset export shift exit wait seq :The last line is shell builtins the loop/condition forms need (e.g. while read line; do …); eval, source and . are deliberately not on the list, because they execute text the first-word check never sees. The bash tool description shows the model this same set, generated from the same constant checkPolicy enforces, so the tool description and the enforcement cannot drift — this README block is a hand-maintained copy of that constant, so trust glm_health → bash_allowlist if you need the live value.
Redaction
Every string leaving the process — toward the model, toward Claude, or into the run log — passes through a redactor: the literal API key, sk-… tokens, the BigModel <id>.<secret> form, Bearer … headers, GitHub PATs (ghp_, github_pat_) and AWS access-key ids.
Redaction is telemetry hygiene, not a control. It is trivially defeated by cut, rev, xxd or any other splitter, which is exactly why the credential paths get a kernel deny rather than relying on this.
Process hygiene
Children spawn detached: true and are killed with process.kill(-pid, …) — the whole process group, SIGTERM then SIGKILL after 2 s when a kill is initiated by timeout/abort/output-limit, and an immediate unconditional group SIGKILL when the call returns. This matters: child.kill() and spawn's own timeout/killSignal both leak orphans when the command backgrounded anything. On shutdown every live child is group-killed, every run aborted, and every scratch HOME removed.
How a run works
Confine. Resolve
cwd, check it resolves inside the root, then take a lock before competing for a global run slot (the other order starved runs aimed at unrelated directories).isolation:"none"takes an exclusive lock oncwdfor the whole run. The lock matches by path containment in both directions, so a run in/repoblocks one in/repo/srcand vice versa.isolation:"worktree"takes a short per-repository lock named<repoRoot>#glm-worktree-add, held only whilegit worktree addruns, then released. That key is a sibling of the repository in the lock's path space, so it never contends with a real working directory — a worktree run does not queue behind a plain run in the same repo, which is the whole reason the option exists. Two worktree creations in one repo still serialise against each other, which is what.git/worktreesrequires.
Snapshot. Record
HEAD+git status(or, outside a repo, a hashed file manifest, capped at 20000 files).Loop, up to
max_iterations: stream a turn from GLM, execute every tool call in the turn, in array order, feed the results back. GLM-5.2 does emit parallel tool calls — tworead_files in onetool_callsarray is routine — and the API requires exactly onerole:"tool"reply pertool_call_id, so a harness that handled only the first would drop work and then desync the transcript. Progress notifications fire every iteration.Manage context. Over ~120K estimated tokens, stale large tool results have their bodies replaced in place — the message, its role and its
tool_call_idall stay, because deleting a tool message orphans the precedingtool_callsentry and earns a deterministic 400 that no retry fixes. Over ~200K, the agent writes itself a handoff note and the transcript is rebuilt around it (max 2 compactions).Converge. At 75% and 90% of the iteration budget a warning is appended to the last tool result. On exhaustion the tool list is narrowed to
[finish]and the model is told to wrap up, with a fresh 90 s budget. If that fails, the harness synthesises the report from the diff and the command log and marks itdegraded.Verify from facts. Compute the diff from git, pair each claimed verification command with its real exit code, drop hallucinated files, apply the downgrade rules.
The agent gets seven tools — read_file, write_file, edit_file, list_dir, search, bash, finish (six with bash off). Every schema is flat: no nested objects anywhere, because escaping depth is what breaks GLM's argument serialisation.
An identical tool call with identical arguments is not re-executed: the second identical call returns the cached result with a note, and a third is refused.
Auto-downgrades
The harness does not take status: "success" at face value:
success+ emptyverification+ files changed →partial, with a follow-up saying so.success+ any verification command exited non-zero →partial.Claimed files that git reports unchanged → dropped, counted in
stats.recovered_errors.hallucinated_files.success+ git reports zero changes on a task that did write work →failed.Any
exit_reasonother thanmodel_called_finishadds a blocker, and downgradessuccesstopartial.
Rule 1 exists specifically to mitigate GLM-5.2's disclosed tendency to over-report success.
Malformed tool arguments
GLM-5.2 truncating or mangling tool-call arguments is treated as a certainty, not a risk. Arguments go through a repair ladder numbered 0–9 — rung 0 an already-parsed object, rung 1 the string as-is, rungs 2–8 the repairs (trim, strip fences, slice to the outermost object, close unbalanced brackets and string literals, escape raw newlines, undo double-encoding, strip trailing commas), each tried both cumulatively and individually. Nothing throws.
Two rungs are lossy: slicing to the outermost object cuts at the last } in the text — for a code payload that brace is usually inside a string value — and closing unbalanced literals then tidily terminates the string that was cut. The result parses, validates, and is short. That is silent source corruption reported as a successful edit, so those two rungs are refused outright for write_file, edit_file and finish: a truncated write comes back as a failure telling the model to resend smaller. When a lossy rung is used anywhere else it is counted in stats.recovered_errors.lossy_args.
If the arguments are unrecoverable the model gets a concrete instruction back — split the payload, do not resend it — and only the broken call in the batch fails; the rest execute.
A last-resort schema-aware salvage (rung 9) applies only to read_file, search and list_dir, where a single plausible token can be read as a path or a pattern. It never applies to write_file, edit_file, bash or finish, because guessing a content argument corrupts a file.
Six consecutive unparseable calls ends the run with tool_call_serialization_failure and retryable: true — at that point the provider's tool-call parser is broken, not the model, and you should fail over.
The return envelope
One text block: a human header, then a fenced JSON object.
GLM agent: PARTIAL (23/30 iterations, 6m12s, $0.42)
7 files changed (+284/-51). Verification: 1 passed, 1 failed.
Full diff: ~/.glm-workflow-mcp/runs/20260722T143108Z-a3f9c1d2/diff.patch{
"status": "partial",
"exit_reason": "model_called_finish",
"run_id": "20260722T143108Z-a3f9c1d2",
"degraded": false,
"retryable": false,
"task": "…first 400 chars…",
"cwd": "/Users/you/project",
"isolation": "none",
"summary": "…model prose, redacted, ≤4000 chars…",
"files_changed": [
{ "path": "inc/class-rest-settings.php", "action": "created", "added": 148, "removed": 0, "note": "new REST controller" }
],
"diff_path": "…",
"diff_preview": "…≤2000 chars…",
"verification": [
{ "command": "composer exec phpunit", "exit_code": 1, "outcome": "11 passed, 1 failed: settings_update_rejects_invalid_nonce" }
],
"follow_ups": ["…"],
"blockers": ["…"],
"stats": {
"iterations_used": 23, "max_iterations": 30, "duration_ms": 372410,
"prompt_tokens": 486210, "completion_tokens": 31844, "cost_usd": 0.42,
"model": "glm-5.2",
"tool_calls": { "read_file": 14, "search": 8, "edit_file": 6, "bash": 9 },
"recovered_errors": {
"malformed_args": 2, "repaired_at_rung": [5,5], "lossy_args": 1,
"tool_failures": 3, "api_retries": 1, "nudges": 0, "hallucinated_files": 0
},
"compactions": 0
},
"log_path": "…/transcript.jsonl"
}Every field shown is always present. Notes on the ones that are easy to misread:
verification[].exit_codemay benull. That means the model named a command the harness has no record of running — withallow_bash:falsethat is every entry.nullis reported rather than a guessed result.recovered_errors.lossy_argscounts calls where a repair rung that can discard content was used. It is the only signal that a tool ran on arguments that were not exactly what the model sent.degraded: truemeans the harness, not the model, wrote the report.The whole text block is capped at 48 KiB; over that,
diff_previewis dropped first, thensummaryandfiles_changedare trimmed, so the JSON always stays parseable.
blockers (why it stopped — often answerable, then re-dispatchable) is kept separate from follow_ups (optional future work) so an orchestrator can act on the difference.
Errors: three layers
Layer | Looks like | When |
JSON-RPC error |
| Structural only: parse error, unknown method, unknown tool name. |
Tool error ( |
| Harness misconfiguration: missing key, |
Successful call, failed task |
| Everything the agent run itself can produce. |
A failed task is a successful tool call. It is never thrown, because a thrown error makes the calling agent abandon its plan instead of reading the diagnostics.
exit_reason values: model_called_finish, iteration_budget_exhausted, runtime_budget_exhausted, token_budget_exhausted, cost_budget_exhausted, local_quota_exhausted, queue_timeout, no_tool_call, tool_call_serialization_failure, content_filter, context_overflow, auth_error, api_unavailable, api_contract_error, quota_exhausted, cancelled, harness_error.
retryable: true is set for exactly five of them: local_quota_exhausted, queue_timeout, tool_call_serialization_failure, api_unavailable, quota_exhausted.
Cost and rate-limit control
Z.ai publishes no rate-limit headers and no concurrency ceiling, so the server assumes nothing and drives everything from what it observes.
AIMD semaphore. Starts at
GLM_MAX_CONCURRENT_MODEL_CALLS(default 5); every rate-limited response drops it by one (floor 1), and after a clean minute a success raises it by one (ceiling 8).Shared circuit breaker, two states — no half-open probe. Five consecutive rate-limited responses across all runs open it for 30 s. When the window elapses it simply closes; there is no probe request deciding anything. Because the consecutive counter is cleared only by a success, the next rate limit after a window elapses re-trips immediately with the window doubled, up to a 300 s cap; a success resets the window to 30 s. Blocked callers each wake at the deadline plus their own jitter (up to 1.5 s), so a 16-way fan-out is smeared rather than released on one millisecond.
glm_healthreportsbreaker.stateasclosedoropenonly — if you are looking for a half-open state or a probe result, they do not exist.Hourly quota bucket.
GLM_MAX_CALLS_PER_HOUR(default 600), refilling continuously. Exhaustion ends runs withlocal_quota_exhaustedandretryable: true. This is the only server-wide thing standing between a runaway fan-out and a several-hundred-dollar bill. Lower it before you trust a new workflow, and set--max-iterations/--max-runtimetoo — those are hard ceilings a caller cannot exceed.Per-run cost stop at
GLM_MAX_COST_USD(default $2.00) triggers a forced wrap-up, so you get the partial work rather than losing it.GLM_TOKEN_BUDGETdoes the same on tokens.Retries use full-jitter exponential backoff (
min(60 s, 2 s × 2ⁿ) × [0.5, 1.5)), capped at 300 s of combined retrying per turn, with a 90 s inter-chunk stall watchdog on the stream. Authentication failures and quota walls are never blind-retried — with a wide fan-out, retrying a bad key just burns quota.
Costs in stats.cost_usd are computed from a local price table and are estimates for budget enforcement, not billing.
Verified against GLM-5.2
The live end-to-end group (npm run test:live) was run against glm-5.2 on the coding-plan endpoint. Stated factually, without extrapolation:
GLM read files with
read_fileand located code withsearchbefore editing.It emitted four tool calls in a single
tool_callsarray, and the harness executed all four in array order and returned exactly onerole:"tool"message pertool_call_id. This is the case a first-call-only harness gets wrong, and it desyncs the transcript rather than failing visibly.It applied a one-line fix with
edit_fileand then ran the project's test command withbashto confirm it, reading the real exit code.The envelope was truthful. In the
allow_bash:falsevariant the model still listed a verification command; because the harness had no record of running it, the entry came back withexit_code: nullrather than an invented pass, and the run was auto-downgraded topartial.
That is one model on one set of tasks. It is evidence that the loop, the parallel-tool-call handling and the fact-derived reporting work as described — not a benchmark, and not a claim about any other model in the list.
The free test suite (npm test, 157 assertions, no network and no key) covers the protocol framing, path confinement, the bash policy and restricted-mode grammar, the JSON repair ladder, the kernel path-deny, per-run and cross-run scratch-HOME isolation (including that one run cannot enumerate another's), credential resolution and precedence, unknown-flag rejection, source-text integrity, the instructions budget and the endpoint constants.
Troubleshooting
glm-workflow-mcp: a root directory is required — pass --root. Deliberate; there is no fallback. This one does exit 1.
The server looks connected but every model call fails — it started degraded because there was no usable key. Call glm_health: api_key_present: false. The full reason — every credential source it checked, with paths — is prepended to your first tool result, carried in the connect-time instructions, and written to the server's stderr log (see Where are the logs? for that log's location).
GLM_API_KEY is the literal string "${GLM_API_KEY}" — your MCP client substitutes ${VAR} only when VAR is set in its environment. Export it before launching, or use the 0600 credentials file. The server starts anyway; it does not exit.
… is mode 0644 but must be 0600 — the credentials file is world- or group-readable and the server refuses to read it rather than use it anyway. chmod 600 the path it names.
Error 1113 — "insufficient balance or no resource package" (surfaced as kind: "auth_error" / exit_reason: "auth_error", never retried) — read this before you top up. It almost always means the wrong base URL for your key's billing type, not an empty wallet. Z.ai has two chat-completions paths and a key works on exactly one of them:
You have |
|
a Z.ai coding-plan subscription |
|
pay-as-you-go API credit |
|
Cross the streams and every model fails identically — including the cheapest ones, which is the tell: if glm-4.5-air also returns 1113, it is not a balance problem. The response arrives with HTTP 200 and a {"error":{"code":"1113",…}} body (code is a string, not a number), so a client that trusts the status code sees a successful call. This server checks the body explicitly. Confirm with glm_health { "probe": true } after changing the URL.
wrong base URL — check GLM_BASE_URL — a different failure: you are pointing at a path that answers HTTP 200 with a non-conforming JSON envelope, almost always /api/openai/v1/, which boot refuses outright. Use one of the two URLs above.
Runs come back partial with "ran no verification commands" — either bash is off, or the agent could not find your test command. Put it in context: "Run the tests with npm test."
A test that needs a dev server fails with connection refused — nothing survives a bash call. The agent must start the server, wait for its port, run the test and kill it inside one command. If a run left processes behind, the tool result says so explicitly (leftBehind: true plus a note in stderr).
npm install fails with E401, or cargo/rustup/pyenv "not found" inside a run — that is the scratch HOME, not your project. The failing command's stderr carries a harness note saying so. Start the server with GLM_TOOLCHAIN_ENV=1 to pass toolchain locator variables through; registry credentials are never passed through by design.
Everything queues and nothing runs — two runs want the same directory. Only one run per directory at a time; use isolation: "worktree", which does not contend with plain runs. Check with glm_health → scheduler.runs.
exit_reason: "api_contract_error" — the request was rejected as malformed and is never retried. The full request body is logged to stderr. Usually an orphaned tool_call_id.
The model returns nothing, and the log shows finish_reason: "length" with empty content — the turn was spent entirely on hidden reasoning tokens, which come out of the same max_tokens budget as the answer. The client already floors every request at 4096 to prevent this, so if you still see it the transcript is simply too long for the remaining budget: raise GLM_MAX_TOKENS_PER_TURN, or split the task.
npm error code EBADPLATFORM, or every bash call returns spawn /bin/bash ENOENT — you are on Windows. Use WSL. The package declares "os": ["darwin", "linux"]; the second symptom means it was force-installed past that.
Where are the logs? The server writes to stderr, and your MCP client captures that stream into a per-server log. A few plain-text lines during boot carry the config, the resolved state_dir/credentials_file/root, and any key diagnostics; after that it is one JSON object per line.
In Claude Code, that stderr log lives under a per-project cache directory:
macOS: ~/Library/Caches/claude-cli-nodejs/<url-escaped-project-path>/mcp-logs-<server-name>/
Linux: ~/.cache/claude-cli-nodejs/<url-escaped-project-path>/mcp-logs-<server-name>/<server-name> is the key you gave the server in .mcp.json (glm in the examples here), and <url-escaped-project-path> is your project's absolute path with the separators escaped. The newest file in that directory holds the most recent boot — this is where the degraded-start reason lands if you did not see it on the first tool result. (Other MCP clients keep their own server logs; consult your client if you are not using Claude Code.)
Per-run transcripts are separate, at $GLM_STATE_DIR/runs/<run_id>/transcript.jsonl, or via glm_agent_status { include_log: true }. Note that the state directory is denied to the agent, so it cannot read its own transcript — you read it, not it.
Known limitations
bashis not a sandbox, is not confined to the root, and has no kernel path-deny backend on Linux. A permitted command can read anything the user running the server can read. On macOS the credential paths are denied at the kernel viasandbox-exec— which Apple has deprecated, so it is not a guarantee for future macOS releases; on Linux they are protected by string tripwires only, which any splitter defeats, so that is not a boundary. The macOS backend also fails open if its boot probe stops succeeding, reported aspath_deny_backend: "none". If you need real containment, run with--bash offor run the server in a container.The
/proc/<pid>/environkey exposure on Linux with bash enabled is not closable by this server. Use the credentials file instead ofenv:in.mcp.json.The change report runs
gitin the repository the agent is editing, and honours that repository's own.git/config. The global and system gitconfigs are pinned to/dev/nulland the harness'sgituses a HOME no agent can write, but a repo-localdiff.external,core.pageroralias.*— which an agent may legitimately write, since it is a file inside its own working directory — is still in force during change detection. If you are running untrusted tasks, that is another reason for a container.Per-run scratch HOMEs are torn down at the end of each run. A 45-minute idle reaper, a cap on the live set, and a full sweep at server exit are backstops behind that, so a crashed or leaked run cannot accumulate directories under
os.tmpdir()indefinitely.Cold caches, every run. The per-run
HOMEmeans no shared npm/pip/cargo cache between runs. That is the price of one run not being able to write dotfiles the next one executes..gitignoresupport is minimal by design. Only the repository-root.gitignoreis read, supportingname,name/,*.ext,dir/**and a leading/. Negations (!) and nested.gitignorefiles are ignored. A hardcoded ignore list (.git,node_modules,vendor,dist,build,.next,out,__pycache__,.venv,venv,target,.cache,coverage,.turbo,.parcel-cache,.glm-agent) always applies.searchuses JavaScript regular expressions, not ripgrep syntax. It is pure JS — norg, nogrep, no spawn — so behaviour is identical on every machine. Files over 2 MiB and files whose first 4 KiB contain a NUL byte are skipped.list_dirshows at most 400 entries and descends at most 5 levels.Outside a git repository, change detection falls back to a hashed manifest (max 20000 files). New files get a real unified diff; modified files get accurate metadata but no before/after patch, because there is no object store to recover the previous bytes from. Run agents inside a git repo.
Cost figures are estimates, computed from a local price table for budget enforcement. They are not billing.
The command allowlist is a footgun catcher, not a sandbox. Said three times because it matters.
Development
npm test # 157 assertions: groups A-D, F-N and the lint checks — no key, no network, free
npm run test:live # adds group E: real, metered calls against your key
npm run lint:stdout # proves nothing outside src/mcp.js writes to stdoutGroup | Covers |
A | MCP protocol framing, |
B | Path confinement, symlink escapes, NUL bytes, secret-file policy |
C | Bash policy: tripwires, allowlist chaining, |
D | The JSON repair ladder and the API error taxonomy |
F | The kernel path-deny, with six evasion attempts ( |
G | Per-run scratch HOME isolation, driven through |
H | The |
I | The endpoint default constants, plus one end-to-end read-back from |
J | Cross-run scratch HOME isolation — one run cannot read, write or enumerate another run's home or the harness's |
K | Source-text integrity: no NUL bytes and valid UTF-8 across |
L | Restricted-mode grammar: |
M | Credential resolution and precedence: env vs. the |
N | Unknown CLI flags are a startup error, and the correctly spelled ceilings still reach |
Lint | Nothing outside |
E | Live API: a |
The smoke test spawns the real server and speaks JSON-RPC to it over stdio. Its most important assertion is that every byte the server writes to stdout parses as exactly one JSON object per line — anything else silently destroys the MCP session.
Group E costs money and takes minutes, so npm test clears GLM_API_KEY / ZAI_API_KEY / ZHIPU_API_KEY for the child and group E always skips — including under prepublishOnly, where an exported key would otherwise have made npm publish quietly spend money. Opt in explicitly with npm run test:live, which sets GLM_LIVE_TESTS=1 and passes your key through.
License
MIT © Jawad Ahbab
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/JawadAhbab/glm-workflow-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server