Skip to content

Troubleshooting

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

Nothing happens / the call hangs

mduct status          # is the daemon up, and which instance answered?
mduct daemon --stop   # then run the next call — it restarts clean
mduct daemon          # foreground: the startup errors go to your terminal

Running it in the foreground is the fastest way to catch a server that fails to launch. A stdio server that writes to stdout at startup, or dies immediately, shows up there and nowhere else.

"unknown server" but it's right there in the config

You are probably talking to a different instance than you think.

mduct status
# config:  /home/you/.config/mduct-ci/servers.jsonc   ← MDUCT_PROFILE is set

MDUCT_PROFILE, MDUCT_CONFIG and an exported MDUCT_SOCKET all outlive anyone's memory of having set them.

A server works from my shell but not from the agent

The daemon inherits the environment of whichever process started it first. If that was the agent, the agent's environment is what the server sees.

Put what the server needs in the config (env), not in your shell profile:

"srv": { "command": "", "env": { "API_BASE": "https://internal.example.com" } }

Then mduct daemon --stop so the next call starts it fresh with the new config.

Config changes don't take effect

They should: the daemon watches the config file. When an entry changes, that server's connection is dropped so the next call reconnects with the new settings. Connections to unrelated servers are left alone on purpose.

So if an edit seems ignored, the file probably didn't parse:

mduct logs | tail -5
# config reload FAILED: server "srv": needs "command" (stdio) or "url" (http) — fix /home/you/.config/mduct/servers.jsonc

A config that fails to load is not applied. The daemon keeps running on the last good one instead of dropping every connection over a stray comma. That is the right behaviour, and it is also why a broken edit can look exactly like no edit. Check the logs before you go hunting for a cache.

${VAR} stayed a literal

Resolution is process.env first, then the secret store. If neither has it, the value is left as written, which usually surfaces as an auth error from the server rather than an error from mduct.

mduct secret list      # names only — is it actually stored?

Remember that the daemon's environment is not your shell's (see above). The secret store exists so this stops mattering.

OAuth stopped working

mduct auth <server>    # re-consent; the token is stored 0600 and refreshed automatically

A dead session fails with exactly that instruction. Tokens live next to the config, so a profile has its own.

To find out before a call fails, ask for the session state instead of waiting for it:

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

expired and unauthorized land there; refreshable does not, because the daemon renews that one on the next call. Worth wiring into whatever watches an unattended agent — a fleet that has gone quiet with everything idle and one expired login is not a fleet that is idle.

One thing this cannot see: a refresh token the provider revoked still reads as valid. Local state knows what is on disk, not what the server will accept. If calls fail while the state says valid, that is the case — re-run mduct auth.

Output isn't valid JSON

Use --json. Without it you get the server's text content, which for many servers is JSON wrapped in a sentence. With it you get the payload only.

If --json still gives you something odd, look at the envelope:

mduct call srv tool --raw

A tool is blocked and shouldn't be

mduct servers          # the guard is shown per server

Check the precedence: deny beats allow, a present allow list is exhaustive, and "allow": [] blocks everything by design. "No restriction" means leaving the key out.

Parallel calls to one server are slow

One at a time per server by default. The protocol has request ids and does not care, but not every MCP server is reentrant, and the failure path closes the transport, which is only safe when nothing else is using it.

If a server handles concurrency, raise its limit:

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

Measured with a 300 ms tool and five calls at once: 1561 ms at the default, 360 ms at maxConcurrent: 5. A tool-level error (the server answering "no") does not disturb the others; a transport that actually dies takes the in-flight calls with it and is rebuilt once, after the last one drains.

The catalogue is empty on a fresh machine

mduct mcp serves tool names out of the cache the daemon fills as servers get used. Nothing used yet means nothing to serve, and the namespace stays empty on exactly the first session you wanted it for.

mduct index --refresh      # connects to every server once and fills the cache

Worth wiring into whatever provisions the machine. After that it maintains itself, and the running catalogue picks up new entries without a restart.

Two of everything after an import

mduct doctor           # servers attached directly AND served here

An MCP server attached directly to your agent still loads its full schemas. If mduct also serves it, you are paying twice for one thing. Remove the direct attachment.

Clone this wiki locally