Skip to content

Cookbook

Kevin Straub edited this page Aug 5, 2026 · 6 revisions

Recipes. Paste, adjust the names, move on.

Keep a big result out of your context

The whole point. A tool that returns 20 issues returns 20 full issues. You wanted three fields.

# measured on a real tracker: 24,568 characters down to 1,768
mduct call tracker list_issues limit=20 --json | jq -c '.issues|map({id,title,status})'

--json strips the prose some servers wrap around their payload ("Found 20 issues:"), which is what makes the pipe work at all. It also skips the oversized-list guard, since you are obviously already slimming the thing.

Don't know the shape? Look once, then project:

mduct call srv some_tool --json | jq 'if type=="array" then .[0] else . end | keys'

List, then fetch each

mduct call tracker list_issues state=open --json \
  | jq -r '.[].id' \
  | while read -r id; do
      mduct call tracker get_issue id="$id" --json | jq -c '{id, title, assignee}'
    done

Project the second call as well, or you have moved the wall of text one line down and gained nothing.

Two servers in one pipeline

# every open MR whose branch no longer exists
mduct call gitlab list_merge_requests project_id=grp/proj state=opened --json \
  | jq -r '.[].source_branch' \
  | while read -r b; do
      git ls-remote --exit-code --heads origin "$b" >/dev/null 2>&1 || echo "stale: $b"
    done

Feed a file or stdin as arguments

mduct call srv create_thing --args @payload.json
jq -n '{title:"from a pipe"}' | mduct call srv create_thing --args -

A read-only server

Guards live in the daemon, so this holds however the caller phrases the request:

"prod-db": {
  "url": "https://mcp.internal/db",
  "guard": { "allow": ["list_*", "get_*", "describe_*"] }
}

Deny wins over allow. "deny": ["delete_*", "drop_*"] is the other direction when a server is mostly fine.

A throwaway instance for a script or CI

export MDUCT_PROFILE=ci        # ~/.config/mduct-ci/, own socket, own secrets, own daemon
mduct add tracker --url https://mcp.example.com/mcp
mduct call tracker list_issues --json | jq length
mduct daemon --stop            # tidy up

Nothing is shared with your default instance. Not the config, not the tokens.

Pin a CLI tool so every machine runs the same one

"playwright": {
  "run": "bunx",
  "args": ["playwright@1.61.1"],
  "check": "bunx playwright@1.61.1 --version",
  "setup": "bunx playwright@1.61.1 install chromium",
  "note": "headless browser"
}
mduct tool status              # shows "↑ update 1.61.1 → 1.62.0" when one is out
mduct tool update playwright   # bumps the pin
mduct run playwright test e2e/

The env and the wrapping travel with the config, so the tool behaves the same on a laptop and on the build box.

Move an existing Claude setup over

mduct import              # what's attached in ~/.claude* and .mcp.json
mduct import gitlab       # copy one across; literal tokens land in the secret store
mduct doctor              # anything still attached directly AND served here — you want zero
claude mcp remove gitlab  # ...then drop the direct attachment, or you pay for both

Show mduct's state in your own app

--json is what the text output shows, as data, plus the auth state it never showed at all.

mduct status --json | jq -r '.servers[] | [.name, .state, .auth.state] | @tsv'
# gitlab	connected	n/a
# notes	idle	refreshable
# tracker	idle	expired

Poll it as often as you like — neither status --json nor servers --json starts a daemon, and the daemon's answer doesn't postpone its idle sweep either, so watching the state never changes it. Measured at under 50 ms with nothing listening.

The one line worth putting in front of a human, before a call fails instead of after:

mduct status --json | jq -r '.servers[] | select(.enabled and .auth.fix) | .auth.fix'
# mduct auth tracker

refreshable deliberately produces nothing here: the daemon renews that one on the next call. The .enabled guard matters — a server you disabled still reports its dead login, and you don't want to be told to fix one you turned off. Full field reference in Commands.

Check a server before trusting it in a script

mduct servers                       # config + connection state
mduct tools srv | head -30          # names and signatures, no schemas
mduct schema srv the_tool           # the full schema, only when you need it
mduct call srv the_tool --raw       # the MCP envelope, if a result looks wrong

Batch a set of calls without re-handshaking

Sequential calls reuse the same warm connection, so a loop is cheap:

for p in api web worker; do
  mduct call gitlab list_pipelines project_id="grp/$p" --json | jq -c "{repo:\"$p\", last:.[0].status}"
done

One connection per server, and by default one call at a time on it. If the server tolerates concurrency, say so and the loop above overlaps:

"gitlab": { "command": "", "maxConcurrent": 4 }

Measured against the real GitLab API: four calls in 2.8s instead of 6.0s. Start at 3 or 4 and watch the server's rate limit, not mduct's.

Make failures loud in a script

set -euo pipefail
if ! out=$(mduct call srv risky_tool id=42 --json 2>err.txt); then
  echo "call failed: $(cat err.txt)" >&2   # errors go to stderr, exit code is nonzero
  exit 1
fi
echo "$out" | jq -e '.ok' >/dev/null

Error messages name the next action, e.g. unknown tool "x" — see: mduct tools srv.

Clone this wiki locally