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

The text output is padded columns; --json is the same facts as data.

mduct status --json | jq -r '.servers[] | "\(.name)\t\(.state)\t\(.auth.state)"'
# 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, so watching the state never changes it.

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

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

refreshable deliberately produces nothing here: the daemon renews that one by itself. Only a session nobody can save asks for a human. 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