This guide distils hands-on recommendations for designing, building, and operating Model Context Protocol (MCP) servers with mcp-bash. It complements the normative guarantees tracked in SPEC-COMPLIANCE.md, the onboarding cues in README.md, and the workflows in TESTING.md.
- Quick reference index
- 1. Introduction
- 2. Environment & tooling
- 3. Project layout primer
- 4. MCP server development best practices
- 5. Testing & quality gates
- 6. Operational guidance
- 7. Security & compliance
- 8. Performance & limits
- 9. Integration patterns
- 10. Contribution workflow
- 11. Resources & further reading
- 12. Appendices
- Doc changelog
| Command | Purpose | Linked section |
|---|---|---|
bin/mcp-bash scaffold tool <name> |
Create SDK-ready tool skeleton (metadata + script) | §4.1 |
bin/mcp-bash scaffold prompt <name> |
Generate prompt template + .meta.json |
§4.1 |
bin/mcp-bash scaffold resource <name> |
Produce resource boilerplate wired to the file provider | §4.1 |
bin/mcp-bash scaffold test |
Create a lightweight test harness (test/run.sh, test/README.md) |
§4.1 |
bin/mcp-bash run-tool <name> |
Invoke a tool without starting the server (supports --dry-run, --roots, --timeout, --verbose, --minimal, --print-env) |
§5.2 |
bin/mcp-bash validate |
Validate project structure and metadata (--json, --strict, --explain-defaults for CI, --inspector for MCP Inspector CLI validation) |
§5.2 |
bin/mcp-bash doctor |
Diagnose environment and installation issues (--json for CI/log parsing; --dry-run/--fix for managed-install repair) |
§2 |
bin/mcp-bash registry status |
Show registry cache status (hash/mtime/counts) | §5.2 |
./test/lint.sh |
Run shellcheck + shfmt gates; wraps commands from TESTING.md | §5.2 |
./test/unit/run.sh |
Run unit tests (pass one or more *.bats to filter) |
§5.1 |
./test/integration/test_capabilities.sh |
End-to-end lifecycle/capability checks | §5.3 |
./test/examples/test_examples.sh |
Smoke runner ensuring scaffolds/examples stay healthy | §5.1 |
MCPBASH_LOG_LEVEL=debug bin/mcp-bash |
Start server with verbose diagnostics (README.md) | §6.3 |
| Helper | Purpose | Example |
|---|---|---|
mcp_args_require |
Extract required string, fail if missing | name="$(mcp_args_require '.name')" |
mcp_args_bool |
Parse boolean with truthy coercion | all="$(mcp_args_bool '.all' --default false)" |
mcp_args_int |
Parse integer with range validation | count="$(mcp_args_int '.count' --default 10 --min 1 --max 200)" |
mcp_args_get |
Low-level jq extraction | val="$(mcp_args_get '.nested.field // ""')" |
mcp_require_path |
Validate path against MCP roots | path="$(mcp_require_path '.path' --default-to-single-root)" |
mcp_emit_json |
Emit JSON result | mcp_emit_json '{"status":"ok"}' |
mcp_json_obj |
Build JSON object from pairs | mcp_json_obj status ok msg "done" |
mcp_fail_invalid_args |
Return -32602 error | mcp_fail_invalid_args "count must be positive" |
mcp_progress |
Report progress | mcp_progress 50 "Halfway done" 100 |
mcp_is_cancelled |
Check cancellation | if mcp_is_cancelled; then exit 1; fi |
mcp_log_info |
Structured logging | mcp_log_info "tool" "message" |
mcp_with_retry |
Retry with exponential backoff | mcp_with_retry 3 1.0 -- curl -sf "$url" |
mcp_download_safe ★ |
SSRF-safe HTTPS download | mcp_download_safe --url "$url" --allow "api.example.com" |
mcp_download_safe_or_fail ★ |
SSRF-safe download (fails on error) | path=$(mcp_download_safe_or_fail --url "$url" --out "$tmp" --allow "x.com") |
mcp_result_success |
Emit success CallToolResult envelope | mcp_result_success "$json_data" |
mcp_result_error |
Emit error CallToolResult envelope | mcp_result_error '{"type":"not_found"}' |
mcp_error ★ |
Convenience error helper with hints | mcp_error "not_found" "User missing" --hint "Check ID" |
mcp_result_text_with_resource ★ |
Combined text + embedded resources | mcp_result_text_with_resource "$json" --path /tmp/out.txt |
mcp_json_truncate |
Truncate large arrays for context limits | mcp_json_truncate "$arr" 10000 --array-path ".data" |
mcp_is_valid_json |
Validate single JSON value | if mcp_is_valid_json "$val"; then ... |
mcp_byte_length |
UTF-8 safe byte length | len=$(mcp_byte_length "$str") |
mcp_extract_cli_error |
Extract error from CLI stdout JSON or stderr | msg=$(mcp_extract_cli_error "$stdout" "$stderr" "$exit_code") |
mcp_run_with_progress |
Forward subprocess progress to MCP | mcp_run_with_progress --pattern '([0-9]+)%' --extract match1 -- wget ... |
mcp_config_load ★ |
Load configuration from env/file/defaults | mcp_config_load --env MY_CONFIG --file ./config.json --defaults '{}' |
mcp_config_get ★ |
Get value from loaded configuration | timeout=$(mcp_config_get '.timeout' --default 30) |
★ = New in v0.10.0
| Pattern | Purpose | Example |
|---|---|---|
cmd | jq '...' || echo '{}' |
Safe jq pipeline with fallback | data=$(mycli get "$id" 2>/dev/null | jq '.data // {}' || echo '{}') |
cmd 2>/dev/null || echo '{...}' |
JSON fallback on CLI failure | result=$(mycli query 2>/dev/null || echo '{"items":[]}') |
mcp_with_retry N delay -- cmd |
Retry transient failures | mcp_with_retry 3 1.0 -- curl -sf "$url" | jq '.' || echo '{}' |
| Variable | Description | Notes |
|---|---|---|
MCPBASH_FORCE_MINIMAL |
Forces the minimal capability tier even when JSON tooling is available | Useful for testing degraded mode per README.md |
MCPBASH_LOG_LEVEL / MCPBASH_LOG_LEVEL_DEFAULT |
Sets startup log verbosity (info default) |
Harmonises with logging/setLevel requests; see §6.2 |
MCPBASH_LOG_VERBOSE |
Exposes full paths and manual-registration script output in logs | Security risk: reveals file paths, usernames, cache locations. Use only in trusted environments; see docs/LOGGING.md |
MCPBASH_DEBUG_PAYLOADS |
Persists per-message payload logs under ${TMPDIR}/mcpbash.state.* |
Enable only during targeted debugging (storage heavy) |
MCPBASH_MAX_CONCURRENT_REQUESTS |
Caps worker count (default 16 per docs/LIMITS.md) | Right-size before shipping to resource-constrained hosts |
MCPBASH_MAX_PROGRESS_PER_MIN / MCPBASH_MAX_LOGS_PER_MIN |
Throttle progress/log notifications per request | Raise with caution to avoid client overload |
MCP_TOOLS_TTL, MCP_RESOURCES_TTL, MCP_PROMPTS_TTL |
Control registry cache lifetime (docs/REGISTRY.md) | Lower values increase IO load |
MCP_RESOURCES_ROOTS |
Restricts file/resource providers to approved roots (docs/SECURITY.md) | Mandatory for multi-tenant deployments |
MCPBASH_REGISTRY_MAX_BYTES |
Hard stop for registry cache size | Keep aligned with operator storage policies |
MCPBASH_HTTPS_ALLOW_HOSTS / MCPBASH_HTTPS_DENY_HOSTS |
Allow/deny lists (space/comma separated) for HTTPS resource hosts | Private/loopback always blocked; allow list required unless MCPBASH_HTTPS_ALLOW_ALL=true; redirects disabled; timeouts/size capped (see docs/SECURITY.md) |
MCPBASH_HTTPS_TIMEOUT / MCPBASH_HTTPS_MAX_BYTES |
HTTPS provider timeout/size controls | Capped internally (timeout ≤60s, max bytes ≤20MB) |
MCPBASH_ENABLE_GIT_PROVIDER |
Enable git+https:// resource provider | Default disabled; set to true to allow git+https:// |
MCPBASH_GIT_ALLOW_HOSTS / MCPBASH_GIT_DENY_HOSTS |
Optional allow/deny lists for git resource hosts | Private/loopback always blocked |
MCPBASH_GIT_TIMEOUT / MCPBASH_GIT_MAX_KB |
Git provider timeout/size controls | Capped internally (timeout ≤60s, max size ≤1GB; default 50MB) |
- Minimal mode – server only exposes lifecycle/ping/logging; often triggered by missing
jq/gojqor forced viaMCPBASH_FORCE_MINIMAL. - Compatibility toggles –
MCPBASH_COMPAT_BATCHESre-enables legacy JSON-RPC batch arrays for clients using newer protocols; protocol2025-03-26accepts arrays automatically. - Discovery churn –
notifications/*/list_changedloops may indicate fast TTLs or manual registry overrides; inspect.registry/*.json. - Cancellation –
mcp_is_cancelledreturning true mid-tool (seeexamples/03-progress-and-cancellation/tools/slow/tool.sh:5) highlights clients timing out; revisit tool timeouts and progress cadence. - Progress throttling – hitting the 100/minute default triggers warning logs and truncated progress; adjust
MCPBASH_MAX_PROGRESS_PER_MINwhen high-frequency updates matter.
- Protocol alignment – Every practice references the corresponding guarantees tracked in SPEC-COMPLIANCE.md. Use this guide for actionable advice; use the coverage matrix to confirm parity with the protocol.
- When to choose mcp-bash – Prefer this implementation when Bash-only deployments, stdio transports, portable tooling, or zero-dependency sandboxes are required. Reach for other stacks when you need native gRPC transports, long-lived HTTP streaming, or language-specific runtimes.
- Document scope – Focuses on day-2 operations, maintainability, and collaboration across tool/prompt authors, operators, and contributors. Feature requests, product positioning, and localization are intentionally out of scope (§Out-of-Scope in the original plan).
- Runtime prerequisites – Bash ≥3.2, POSIX coreutils, and one JSON parser (
gojqorjq) per README.md. Confirm capability mode by piping a ping request through the server:A healthy install responds withprintf '{"jsonrpc":"2.0","id":1,"method":"ping"}\n' | bin/mcp-bash{"jsonrpc":"2.0","id":1,"result":{}}. - Lint/format requirements – Install
shellcheckandshfmtbefore running./test/lint.sh. Withoutshfmt, lint fails withRequired command "shfmt" not found in PATH(see README.md). - Recommended tooling bundle:
| Tool | Check command | Notes |
|---|---|---|
gojq or jq |
jq --version |
Deterministic JSON handling unlocks full capability mode. |
shellcheck |
shellcheck --version |
Required by lint. |
shfmt |
shfmt --version |
Enforces consistent indentation/formatting. |
rg |
rg --version |
Useful for repo-wide metadata scans. |
- Debugging helpers – Keep
asciinemaor screen-recording ready when capturing scaffold workflows for documentation. Add captions/alt-text for accessibility per §Supporting Assets.
- Stable vs extension directories – Core runtime sits under
bin/,lib/,handlers/,providers/, andsdk/. Extension-friendly directories includetools/,resources/,prompts/,server.d/, and.registry/as illustrated in README.md. - Registration flows:
- Auto-discovery – Default path scanning populates
.registry/*.json(see docs/REGISTRY.md). Metadata is sourced from.meta.jsonthen inline# mcp:annotations, falling back to defaults. - Declarative registration – Prefer
server.d/register.jsonfor deterministic overrides/disablement without executing shell code (see docs/REGISTRY.md). Use[]to explicitly disable a kind, or omit/null a key to fall through to auto-discovery. - Hook registration – Use
server.d/register.shonly for dynamic/imperative cases; it executes shell code and is opt-in (MCPBASH_ALLOW_PROJECT_HOOKS=true). Seeexamples/advanced/register-sh-hooks/.
- Auto-discovery – Default path scanning populates
- Environment staging – Use
server.d/env.shto inject operator-specific configuration without editing tracked files. Document each variable inline for future maintainers.
- Run
bin/mcp-bash scaffold <type> <name>to create the initial directory structure. - Inspect the generated README/snippets, then layer your logic into
tools/<name>/tool.sh(or equivalent prompts/resources). - Add unit/integration coverage hitting the new artifacts (see §5).
- Update docs referencing the new capability, especially if operator steps, limits, or troubleshooting expectations shift.
The scaffolder and examples use a per-tool directory (for example tools/hello/tool.sh), and automatic discovery now requires tools to live under subdirectories of tools/ (root-level scripts like tools/foo.sh are ignored). Stay consistent within a project.
Use bin/mcp-bash scaffold test inside an existing project to generate a minimal harness (test/run.sh, test/README.md) that wraps run-tool for quick smoke tests. The command refuses to overwrite existing files, so remove stale test assets before re-scaffolding.
Asciinema tip: Record a short run of bin/mcp-bash scaffold tool sample.hello plus ./test/examples/test_examples.sh so newcomers can view the workflow end-to-end.
When LLMs use your MCP tools, the only context they have is your metadata. Rich descriptions directly improve tool selection accuracy and reduce errors.
Key practices:
-
Write multi-line descriptions - Include "when to use", "when NOT to use", and examples:
{ "description": "Export entries from a list.\n\nWhen to use: Retrieve entities belonging to a specific list.\nNot for: Searching all entities globally (use entity-search).\n\nExamples:\n- list-export --list-id 123\n- list-export --list-id 456 --filter 'Status=Active'" } -
Document every parameter - Include format, valid values, and examples:
{ "filter": { "type": "string", "description": "Filter expression. Operators: = != ~= (contains). Example: 'Status=Active'" } } -
Create domain model resources - For complex domains, provide a resource explaining your data model that LLMs can reference.
-
Include workflow hints - Mention related tools and typical sequences in descriptions.
See docs/LLM-CONTEXT.md for comprehensive patterns and a documentation checklist.
The SDK (sdk/tool-sdk.sh) provides helpers that eliminate boilerplate and ensure consistent behavior across tools. Source it at the top of every tool script:
#!/usr/bin/env bash
set -euo pipefail
source "${MCP_SDK:?MCP_SDK environment variable not set}/tool-sdk.sh"mcp_args_require – Extract a required string value; fails with -32602 if missing/null:
# Fails with "target is required" if .target is missing or null
target="$(mcp_args_require '.target' 'target is required')"
# Default message uses the pointer name
name="$(mcp_args_require '.name')" # Fails with ".name is required"mcp_args_bool – Parse boolean with truthy coercion (true/1 → "true", else "false"):
# Returns "true" or "false"; defaults to "false" if missing
all="$(mcp_args_bool '.all' --default false)"
# Without --default, missing value fails with invalid-args
verbose="$(mcp_args_bool '.verbose')" # Requires explicit valuemcp_args_int – Parse integer with optional range validation:
# With default and range validation
count="$(mcp_args_int '.count' --default 10 --min 1 --max 200)"
# Just default, no range
limit="$(mcp_args_int '.limit' --default 50)"
# Required integer with range
page="$(mcp_args_int '.page' --min 1)" # Fails if missing or < 1mcp_args_get – Low-level extraction for complex shapes; returns raw jq output:
# Extract with jq filter and default
name="$(mcp_args_get '.name // "World"')"
# Extract nested value
config="$(mcp_args_get '.options.config // empty')"
# Check if key exists
if [ "$(mcp_args_get '.debug // "false"')" = "true" ]; then
# debug mode
fiNote:
mcp_args_getrequires JSON tooling (jq/gojq). In minimal mode it returns exit code 1, which will terminate scripts usingset -e. For minimal-mode compatibility, usemcp_args_get ... 2>/dev/null || trueor prefer the typed helpers (mcp_args_bool,mcp_args_int) which accept--defaultvalues that work in minimal mode.
mcp_require_path – Validate and normalize paths with MCP roots enforcement:
# Basic required path (validates against configured roots)
target_path="$(mcp_require_path '.targetPath')"
# Default to single root when client has exactly one root configured
repo_path="$(mcp_require_path '.repoPath' --default-to-single-root)"
# Allow empty (returns empty string if not provided)
optional_path="$(mcp_require_path '.outputPath' --allow-empty)"- Use
server.d/policy.shwithmcp_tools_policy_check()to gate tool invocations in one place (read-only modes, allowlists, audit hooks). The framework sources this once per process and calls it before every tool run. - Keep the hook fast and deterministic; it runs on every invocation.
- Use
-32602for policy/invalid-params blocks;-32600for capability/auth failures. - Example read-only mode:
# server.d/policy.sh
mcp_tools_policy_check() {
local tool_name="$1"
if [ "${MYPROJECT_READ_ONLY:-0}" = "1" ] && [[ "${tool_name}" != myProj.get* ]]; then
mcp_tools_error -32602 "Read-only mode: ${tool_name} disabled"
return 1
fi
return 0
}This single helper replaces 15-20 lines of boilerplate:
# BEFORE: Manual path validation (don't do this)
roots_count="$(mcp_roots_count 2>/dev/null || printf '0')"
if [ -n "${repo_path_arg}" ]; then
repo_path="${repo_path_arg}"
else
if [ "${roots_count}" -eq 1 ]; then
repo_path="$(mcp_roots_list | head -n1)"
else
mcp_fail_invalid_args "repoPath is required when zero or multiple roots are configured"
fi
fi
if [ "${roots_count}" -gt 0 ] && ! mcp_roots_contains "${repo_path}"; then
mcp_fail_invalid_args "repoPath is outside configured MCP roots"
fi
if command -v realpath >/dev/null 2>&1; then
repo_path="$(realpath "${repo_path}")"
fi
# AFTER: One line with mcp_require_path
repo_path="$(mcp_require_path '.repoPath' --default-to-single-root)"Use server.d/health-checks.sh to verify external dependencies (CLIs, environment variables) are available before the server starts serving requests. The mcp-bash health command runs these checks.
#!/usr/bin/env bash
# server.d/health-checks.sh - verify external dependencies
# Check required CLI tools
mcp_health_check_command "xaffinity" "Affinity CLI"
mcp_health_check_command "jq" "JSON processor"
# Check required environment variables
mcp_health_check_env "AFFINITY_API_KEY" "Affinity API key"
mcp_health_check_env "MY_API_TOKEN" "Service API token"The helpers print checkmarks/crosses and return appropriate exit codes:
mcp_health_check_command <cmd> [message]– Verifies command exists in PATHmcp_health_check_env <var> [message]– Verifies environment variable is set
Health check results appear in mcp-bash health output:
{"status":"ok","projectChecks":"ok",...}Some MCP hosts (e.g., Claude Cowork) spawn server processes with minimal PATH that excludes version manager shims like ~/.pyenv/shims/ or ~/.local/bin/. Tools depending on CLIs installed via pyenv, asdf, mise, pipx, nvm, etc. will fail with "command not found" even though the CLI is installed.
Common misconception: server.d/env.sh does not run automatically at server startup—it's only sourced during CLI testing with --with-server-env. Setting paths there won't help.
Solution: Detect CLI paths at runtime in tool scripts, checking known shim locations:
#!/usr/bin/env bash
# lib/cli-detect.sh - add to your MCP server project
# Detect a CLI by searching version manager shim locations.
# Usage: CLI_PATH=$(mcp_detect_cli mycli "pip install pkg") || mcp_fail "not found"
#
# Override: Set ${NAME}_CLI (e.g., MYCLI_CLI) to skip detection.
# Bash 3.2 compatible (no ${var^^}).
mcp_detect_cli() {
local name="$1" install_hint="$2"
# Check user override (e.g., MYCLI_CLI for "mycli")
local var_name
var_name="$(printf '%s_CLI' "$name" | tr '[:lower:]-' '[:upper:]_')"
local override=""
eval "override=\"\${${var_name}:-}\""
[[ -n "$override" ]] && { printf '%s\n' "$override"; return 0; }
# Search common locations (version managers, Homebrew, system)
local candidate
for candidate in \
"${HOME}/.pyenv/shims/${name}" \
"${HOME}/.asdf/shims/${name}" \
"${HOME}/.local/share/mise/shims/${name}" \
"${HOME}/.rbenv/shims/${name}" \
"${HOME}/.goenv/shims/${name}" \
"${HOME}/.local/bin/${name}" \
"${HOME}/.cargo/bin/${name}" \
"${HOME}/.volta/bin/${name}" \
"${HOME}/.local/share/fnm/aliases/default/bin/${name}" \
"/opt/homebrew/bin/${name}" \
"/usr/local/bin/${name}"
do
[[ -x "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; }
done
# nvm: check all installed node versions (use newest)
local nvm_match
for nvm_match in "${HOME}"/.nvm/versions/node/*/bin/"${name}"; do
[[ -x "$nvm_match" ]] && { printf '%s\n' "$nvm_match"; return 0; }
done
# Fall back to PATH
command -v "$name" 2>/dev/null && return 0
# Not found - emit actionable error
printf 'ERROR: %s not found.' "$name" >&2
[[ -n "$install_hint" ]] && printf ' Install: %s' "$install_hint" >&2
printf ' Or set %s=/path/to/%s\n' "$var_name" "$name" >&2
return 1
}Usage in tools:
source "${MCPBASH_PROJECT_ROOT}/lib/cli-detect.sh"
MYCLI=$(mcp_detect_cli mycli "pip install my-package") || mcp_fail "mycli not found"
"${MYCLI}" --versionCovered version managers:
| Category | Managers | Paths checked |
|---|---|---|
| Python | pyenv, asdf, mise, pipx, uv | ~/.pyenv/shims/, ~/.asdf/shims/, ~/.local/share/mise/shims/, ~/.local/bin/ |
| Node.js | nvm, fnm, volta, asdf, mise | ~/.nvm/versions/node/*/bin/, ~/.local/share/fnm/aliases/default/bin/, ~/.volta/bin/ |
| Ruby | rbenv, asdf, mise | ~/.rbenv/shims/ |
| Go | goenv, asdf, mise | ~/.goenv/shims/ |
| Rust | cargo/rustup | ~/.cargo/bin/ |
| System | Homebrew, system | /opt/homebrew/bin/, /usr/local/bin/ |
This pattern works because the server has the correct $HOME—shim paths exist, they're just not in $PATH.
Tools can query the client-provided roots:
# Get count of configured roots
count="$(mcp_roots_count)"
# List all root paths (newline-separated)
mcp_roots_list | while read -r root; do
echo "Root: ${root}"
done
# Check if a path is within configured roots
if mcp_roots_contains "/some/path"; then
echo "Path is allowed"
fimcp_emit_json – Emit JSON result (compacted if JSON tooling available):
mcp_emit_json '{"status":"ok","count":42}'mcp_json_obj – Build JSON objects from key/value pairs (all values stringified):
# Simple object
mcp_emit_json "$(mcp_json_obj status ok message "Operation completed")"
# Output: {"status":"ok","message":"Operation completed"}
# Multiple fields
mcp_emit_json "$(mcp_json_obj \
status ok \
message "Rebase completed" \
repoPath "${repo_path}" \
newHead "${new_head}" \
)"mcp_json_arr – Build JSON arrays:
mcp_emit_json "$(mcp_json_arr "item1" "item2" "item3")"
# Output: ["item1","item2","item3"]mcp_emit_text – Emit plain text result:
mcp_emit_text "Hello, ${name}!"Add file content to the result.content array so clients receive both text and attached files:
Preferred: Use mcp_result_text_with_resource
payload_path="${MCPBASH_PROJECT_ROOT}/resources/report.txt"
printf 'Report content' >"${payload_path}"
mcp_result_text_with_resource \
"$(mcp_json_obj message "See embedded report")" \
--path "${payload_path}" --mime text/plainSee Embedding resources in tool responses for full documentation.
Manual approach (for advanced cases):
- Write to
MCP_TOOL_RESOURCES_FILEdirectly. TSV format:path<TAB>mimeType<TAB>uri(mime/uri optional). - JSON format is also accepted:
[{"path":"/tmp/result.png","mimeType":"image/png","uri":"file:///tmp/result.png"}] - Binary files are base64-encoded into the
blobfield; text stays intext. - Keep paths inside allowed roots; invalid/unreadable entries are skipped (debug logs will mention the skip).
mcp_fail – Return structured JSON-RPC error and exit:
# Generic failure with code
mcp_fail -32603 "Internal error" '{"detail":"something broke"}'
# Invalid arguments (code -32602)
mcp_fail_invalid_args "count must be positive"
mcp_fail_invalid_args "invalid config" '{"field":"count","value":-1}'mcp_progress – Report progress (throttled by framework):
mcp_progress 25 "Processing files..." 100 # 25%, message, total
mcp_progress 50 "Halfway done" # 50%, message, no totalmcp_is_cancelled – Check if client requested cancellation:
for i in $(seq 1 100); do
if mcp_is_cancelled; then
mcp_fail -32001 "Operation cancelled"
fi
# do work...
mcp_progress "${i}" "Processing item ${i}"
doneProgress-aware timeout extension – Long-running tools can extend their timeout automatically. When enabled, the timeout resets each time activity is detected (any pattern match when using mcp_run_with_progress, or any write to the progress stream), allowing legitimate operations to run longer while still catching truly stuck tools.
Enable globally via environment:
export MCPBASH_PROGRESS_EXTENDS_TIMEOUT=true
export MCPBASH_MAX_TIMEOUT_SECS=300 # Hard cap (default: 600s)Or per-tool in tool.meta.json:
{
"timeoutSecs": 30,
"progressExtendsTimeout": true,
"maxTimeoutSecs": 300,
"timeoutHint": "Use dryRun=true first. For large datasets, try limit <= 100."
}Timeout hints – The optional timeoutHint field provides actionable guidance that is appended to timeout error messages. This helps LLMs understand how to adjust their approach (e.g., use smaller batches, enable dry-run mode, or adjust parameters).
Important: The client must provide a progressToken in the request for progress to be written. Emit progress every ~5 seconds to keep the timeout from triggering.
Use structured logging instead of echo to stderr:
mcp_log_debug "mytool" "Starting operation"
mcp_log_info "mytool" "Processing ${count} items"
mcp_log_warn "mytool" "Deprecated option used"
mcp_log_error "mytool" "Failed to connect"When to use: Use
mcp_download_safewhenever your tool needs to fetch content from external URLs. It handles SSRF protection, automatic retries with exponential backoff, and returns structured JSON responses. For most cases, prefermcp_download_safe_or_failwhich fails the tool on error. Only use rawcurlwhen you need features not supported by the helper (e.g., POST requests, custom headers beyond User-Agent).
mcp_download_safe – Download content from HTTPS URLs with SSRF protection:
# Basic usage with explicit allowlist
result=$(mcp_download_safe --url "https://api.example.com/data" --out "/tmp/data.json" --allow "api.example.com")
if [[ $(echo "$result" | jq -r '.success') == "true" ]]; then
cat /tmp/data.json # Process downloaded file
else
error=$(echo "$result" | jq -r '.error.message')
mcp_log_error "mytool" "Download failed: ${error}"
fi
# Using environment variable for allowlist (useful for CI/operator config)
export MCPBASH_HTTPS_ALLOW_HOSTS="api.example.com,cdn.example.com"
result=$(mcp_download_safe --url "$url" --out "/tmp/data.json")
# Custom timeout and max size
result=$(mcp_download_safe --url "$url" --out "/tmp/data.json" --allow "example.com" \
--timeout 30 --max-bytes 5242880)
# Custom User-Agent
result=$(mcp_download_safe --url "$url" --out "/tmp/data.json" --allow "example.com" \
--user-agent "my-tool/1.0")Return format – Always returns JSON with exit code 0 (safe for set -e):
// Success:
{"success":true,"bytes":<size>,"path":"<output_path>"}
// Error:
{"success":false,"error":{"type":"<error_type>","message":"<message>"}}Error types (in .error.type):
invalid_url– URL is empty or not https://invalid_params– Invalid/missing parameters (e.g., missing --out, non-numeric --timeout)host_blocked– Host is private, obfuscated, or not in allowlistprovider_unavailable– HTTPS provider not found or curl missingnetwork_error– Connection failed (retries exhausted)size_exceeded– Response exceeds --max-bytes limitwrite_error– Could not write to output pathprovider_error– Provider script failed unexpectedlyredirect– URL returned 3xx redirect (location in.error.location)
Security features:
- SSRF protection: Blocks private IPs (127.x, 10.x, 192.168.x, etc.) and DNS rebinding
- Deny-by-default: Requires explicit host allowlist
- Obfuscated IP detection: Rejects integer/hex IP literals
- No redirects: Prevents redirect-based SSRF attacks
- Automatic retry: Exponential backoff for transient failures
Simple usage with mcp_download_safe_or_fail:
For tools that should fail on download error (most cases):
# Downloads or fails tool with -32602
path=$(mcp_download_safe_or_fail --url "https://api.example.com/data" \
--out "/tmp/data.json" --allow "api.example.com")
process_file "$path"Handling redirects:
URLs that redirect return a redirect error with the target location:
result=$(mcp_download_safe --url "$url" --out "$tmp" --allow "example.com")
if [[ $(echo "$result" | jq -r '.error.type') == "redirect" ]]; then
target=$(echo "$result" | jq -r '.error.location')
mcp_log_info "mytool" "Hint: use ${target} instead"
fimcp_config_load – Load and merge configuration from multiple sources:
# Load config with precedence: env var > file > example > defaults
mcp_config_load \
--env MY_TOOL_CONFIG \
--file "${MCPBASH_PROJECT_ROOT}/config.json" \
--example "${MCPBASH_PROJECT_ROOT}/config.example.json" \
--defaults '{"timeout": 30, "retries": 3}'Precedence (highest to lowest):
- Env var (
--env) – Can contain JSON directly or path to a config file - Config file (
--file) - Example file (
--example) - Inline defaults (
--defaults)
Behavior:
- Shallow merge: later sources override earlier at top level
- Missing files are silently skipped (not errors)
- Invalid JSON logs warning and skips that source
- Result stored in
MCP_CONFIG_JSONenv var
mcp_config_get – Get values from loaded configuration:
# Get required value (fails if missing)
api_key=$(mcp_config_get '.api.key') || mcp_fail_invalid_args "Missing config: api.key"
# Get optional value with default
timeout=$(mcp_config_get '.timeout' --default 30)
# Get nested value (requires jq; minimal mode only supports top-level keys)
endpoint=$(mcp_config_get '.api.endpoint' --default 'https://api.example.com')Complete example:
#!/usr/bin/env bash
set -euo pipefail
source "${MCP_SDK:?}/tool-sdk.sh"
# Load configuration
mcp_config_load \
--env MY_TOOL_CONFIG \
--file "${MCPBASH_PROJECT_ROOT}/config.json" \
--defaults '{"timeout": 30, "retries": 3}'
# Get values
api_key=$(mcp_config_get '.api_key') || mcp_fail_invalid_args "Missing api_key in config"
timeout=$(mcp_config_get '.timeout' --default 30)
retries=$(mcp_config_get '.retries' --default 3)
# Use configuration
mcp_log_info "mytool" "Using timeout=${timeout}, retries=${retries}"Minimal mode notes:
- Without jq: merge uses last-source-wins (not shallow merge)
mcp_config_get: Only top-level simple keys supported (.key)- Nested paths (
.api.endpoint) return default or fail in minimal mode
Request user input when the client supports elicitation:
# Simple string input
response="$(mcp_elicit_string "Enter your name:" "name")"
# Yes/no confirmation
response="$(mcp_elicit_confirm "Proceed with deletion?")"
# Choice from options
response="$(mcp_elicit_choice "Select environment:" "dev" "staging" "prod")"
# Custom schema
schema='{"type":"object","properties":{"port":{"type":"integer"}},"required":["port"]}'
response="$(mcp_elicit "Configure server:" "${schema}")"#!/usr/bin/env bash
set -euo pipefail
source "${MCP_SDK:?}/tool-sdk.sh"
# Parse arguments with validation
repo_path="$(mcp_require_path '.repoPath' --default-to-single-root)"
count="$(mcp_args_int '.count' --default 10 --min 1 --max 200)"
all="$(mcp_args_bool '.all' --default false)"
mcp_log_info "my-tool" "Processing ${count} items in ${repo_path}"
# Check for cancellation in long operations
for i in $(seq 1 "${count}"); do
if mcp_is_cancelled; then
mcp_fail -32001 "Cancelled by user"
fi
mcp_progress "${i}" "Processing item ${i}" "${count}"
# ... do work ...
done
# Return structured result
mcp_result_success "$(mcp_json_obj \
status ok \
message "Processed ${count} items" \
path "${repo_path}" \
)"| Scenario | Strategy | Configuration |
|---|---|---|
| Fast operations (<30s typical) | Static timeout | "timeoutSecs": 30 |
| Variable runtime, natural progress points | Dynamic (progress-aware) | "progressExtendsTimeout": true |
| Long-running, no natural progress | Static with high limit | "timeoutSecs": 300 |
| Unbounded external API calls | Wrap with synthetic progress | See below |
Use when:
- Operations complete quickly and predictably
- Failure is preferable to long waits
- There's no meaningful progress to report
{
"name": "quick-lookup",
"timeoutSecs": 10
}Use when:
- Runtime varies significantly based on input
- The operation has natural progress points (loops, batches, pagination)
- You want fast failure for stuck operations but flexibility for legitimate long runs
{
"name": "batch-processor",
"timeoutSecs": 30,
"progressExtendsTimeout": true,
"maxTimeoutSecs": 600
}How it works: The timeout resets each time activity is detected (pattern match or progress emission). If no activity occurs within timeoutSecs, the tool times out. The maxTimeoutSecs provides a hard cap regardless of activity.
Some operations (like external API calls) don't have natural progress points. Strategies:
1. Wrap sequential API calls:
total_calls=10
for i in $(seq 1 "$total_calls"); do
mcp_progress "$((i * 100 / total_calls))" "API call $i of $total_calls"
make_api_call "$i"
done2. Time-based heartbeat for single long calls:
# Run the long operation in background with heartbeat
# Note: mcp_progress clamps percent to 0-100; use 0 for indeterminate progress
# (0 signals "in progress but unknown percentage", not "0% complete")
long_running_api_call &
api_pid=$!
elapsed=0
while kill -0 "$api_pid" 2>/dev/null; do
mcp_progress 0 "Working... (${elapsed}s elapsed)"
sleep 5
elapsed=$((elapsed + 5))
done
wait "$api_pid"3. Accept the static timeout: If progress doesn't make sense, use a generous static timeout and accept that stuck operations won't fail fast:
{
"timeoutSecs": 300
}When a tool times out, the response uses isError: true (not a JSON-RPC error) so LLMs can see the details and potentially adjust their approach. The structured error includes:
| Field | Description |
|---|---|
type |
Always "timeout" |
reason |
"fixed" (static), "idle" (no progress), or "max_exceeded" (hit hard cap) |
timeoutSecs |
The timeout duration that was exceeded |
exitCode |
124 (timeout), 137 (SIGKILL), or 143 (SIGTERM) |
progressExtendsTimeout |
Present when progress-aware timeout is enabled |
maxTimeoutSecs |
Present when progress-aware timeout is enabled |
hint |
Present when timeoutHint is configured in tool.meta.json |
The error message also includes the timeoutHint text if configured in tool.meta.json:
Tool timed out after 30s
Suggestion: Use dryRun=true first. For large datasets, try limit <= 100.
| Pitfall | Symptom | Fix |
|---|---|---|
| Progress-aware enabled but no activity detected | reason: "idle" timeout |
Ensure pattern matches subprocess output, or add explicit mcp_progress calls |
timeoutSecs too low for variable workloads |
Intermittent timeouts on large inputs | Enable progress-aware timeout or increase static limit |
No maxTimeoutSecs with progress-aware |
Tool can run indefinitely | Always set maxTimeoutSecs as a safety cap |
| Activity too infrequent | Timeout between activity | Ensure activity (pattern match or progress) at least every timeoutSecs / 2 |
| Tool times out despite emitting progress-like events | Unexpected reason: "idle" timeout |
Verify --pattern regex matches the actual output; use --dry-run to debug extraction |
Declare behavior hints in <tool>.meta.json to help clients present appropriate UI cues (MCP 2025-03-26):
{
"name": "file-delete",
"description": "Delete a file from the filesystem",
"inputSchema": { ... },
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
}| Annotation | Default | Description |
|---|---|---|
readOnlyHint |
false |
Tool does not modify its environment |
destructiveHint |
true |
Tool may destructively modify environment (only when readOnlyHint is false) |
idempotentHint |
false |
Multiple calls with same args have same effect as one call |
openWorldHint |
true |
Tool may interact with external systems |
Clients may use these hints to show confirmation dialogs, group tools by risk level, or enable/disable tools based on user preferences. Annotations are advisory—do not rely on them for security decisions.
Place reusable scripts under lib/ in your project and source them via MCPBASH_PROJECT_ROOT:
# tools/my-tool/tool.sh
# shellcheck source=../../lib/helpers.sh disable=SC1091
source "${MCPBASH_PROJECT_ROOT}/lib/helpers.sh"Keep shared code under project roots to avoid leaking out-of-scope paths; consider a lib/ README to describe available helpers.
MCP distinguishes between Protocol Errors and Tool Execution Errors. This distinction is critical for enabling LLM self-correction. See docs/ERRORS.md for the full reference.
| Error Type | When to Use | LLM Can Self-Correct? |
|---|---|---|
Protocol Error (-32xxx) |
Malformed request, unknown tool, server failures | ❌ No |
Tool Execution Error (isError: true) |
Invalid input the LLM could fix | ✅ Yes |
Key insight: When a tool receives valid JSON but the values are wrong (bad date, out-of-range number, invalid path), return a Tool Execution Error so the LLM can retry with corrected input.
#!/usr/bin/env bash
source "${MCP_SDK:?}/tool-sdk.sh"
count="$(mcp_args_get '.count // 10')"
# ❌ DON'T use Protocol Error for correctable input issues
# mcp_fail_invalid_args "count must be positive" # Returns -32602
# ✅ DO provide actionable feedback the LLM can learn from
if [ "${count}" -lt 1 ]; then
mcp_result_error "$(mcp_json_obj \
error "count must be between 1 and 100" \
received "${count}" \
suggestion "Try count=10 for a reasonable default"
)"
fiUse Protocol Errors (mcp_fail) for:
- Missing required parameters that the SDK can't default
- Truly malformed input (not JSON, wrong types)
- Authorization/permission failures
- Internal server errors
Use Tool Execution Errors (exit non-zero with message) for:
- Values out of valid range
- Invalid formats (dates, emails, paths)
- Business logic failures ("file not found", "API rate limited")
- Any case where your error message teaches the LLM how to fix it
# Protocol error (tool stops, error goes to client)
mcp_fail -32602 "authorization required"
mcp_fail_invalid_args "targetPath is required"
# Tool execution error (LLM gets actionable feedback)
if [ ! -f "${target_path}" ]; then
mcp_result_error "$(mcp_json_obj \
error "File not found" \
path "${target_path}" \
hint "Check available files with list-files tool"
)"
fi- Use
mcp_fail(ormcp_fail_invalid_args) to return structured JSON-RPC errors with propercode/message/datadirectly from tools; it survivestool_env_mode=minimal/allowlistvia the injectedMCP_TOOL_ERROR_FILE. - Only return
-32603(internal error) for unknown failures; otherwise map to specific JSON-RPC errors spelled out in the protocol. - Capture stderr and propagate actionable diagnostics; see
examples/01-args-and-validation/tools/echo-arg/tool.sh:30-36for human-readable error surfaces. - Wrap risky filesystem/network calls in helper functions so they can be retried or mocked in unit tests.
- For richer tool failure context, enable
MCPBASH_TOOL_STDERR_CAPTUREand tuneMCPBASH_TOOL_STDERR_TAIL_LIMIT(default 4096 bytes). Timeouts include exit code and stderr tail whenMCPBASH_TOOL_TIMEOUT_CAPTUREis on (default). - For tricky shell tools, opt into tracing with
MCPBASH_TRACE_TOOLS=true; traces go to per-invocation logs underMCPBASH_STATE_DIR(cap viaMCPBASH_TRACE_MAX_BYTES, adjust PS4 withMCPBASH_TRACE_PS4). SDK helpers suppress xtrace around secret-bearing args/_meta payload expansions, but tools can still leak secrets if they print values explicitly. Trace lines are added toerror.data.traceLinewhen enabled.
When your tool wraps external commands (CLIs, APIs via curl, etc.), handle failures explicitly to avoid empty output breaking jq pipelines.
Note: If your CLI is installed via a version manager (pyenv, asdf, nvm, etc.) and fails with "command not found" in certain MCP hosts, see Runtime CLI detection above.
Anti-pattern (fails silently):
# ❌ If external_cli fails, $data becomes empty string, then jq fails with "invalid JSON"
data=$(external_cli get-entity "$id" 2>/dev/null | jq -c '.data')Correct pattern (with fallback):
# ✅ Two-layer defense: jq handles null/missing keys, || handles empty stdin
data=$(external_cli get-entity "$id" 2>/dev/null | jq -c '.data // {}' || echo '{}')
# For arrays:
items=$(external_cli list-items 2>/dev/null | jq -c '.items // []' || echo '[]')Why this matters:
2>/dev/nullhides CLI stderr but still produces empty stdout on failure- Empty string piped to jq causes:
jq: parse error (unexpected end of input) - This breaks
--argjsonwhen building composite responses
Composing multiple external sources:
# Fetch data from multiple sources with fallbacks
entity=$(my_cli entity get "$id" 2>/dev/null | jq -c '.data.entity // {}' || echo '{}')
notes=$(my_cli notes ls --entity-id "$id" 2>/dev/null | jq -c '.data.notes // []' || echo '[]')
metadata=$(my_cli metadata get "$id" 2>/dev/null | jq -c '.data // null' || echo 'null')
# Now safe to compose - all variables contain valid JSON
mcp_result_success "$(jq -n \
--argjson entity "$entity" \
--argjson notes "$notes" \
--argjson metadata "$metadata" \
'{entity: $entity, notes: $notes, metadata: $metadata}'
)"Fallback defaults by JSON type:
| Expected Type | Fallback |
|---|---|
| Object | '{}' |
| Array | '[]' |
| String | '""' |
| Null | 'null' |
Preserving errors for debugging:
If you need to log CLI errors while still having a fallback:
tmp_err="$(mktemp)"
result=$(failing_command 2>"$tmp_err" | jq -c '.data // {}' || echo '{}')
if [[ -s "$tmp_err" ]]; then
mcp_log_warn "mytool" "CLI warning: $(cat "$tmp_err")"
fi
rm -f "$tmp_err"Retry with exponential backoff:
For transient failures (network issues, rate limits), use the SDK retry helper:
# mcp_with_retry <max_attempts> <base_delay> -- <command...>
# Exit codes 0-2 are not retried; 3+ trigger retry with backoff
# Retry curl up to 3 times with 1s base delay (1s, 2s, 4s + jitter)
data=$(mcp_with_retry 3 1.0 -- curl -sf "https://api.example.com/data" | jq -c '. // {}' || echo '{}')
# Retry a CLI command
result=$(mcp_with_retry 5 0.5 -- my_cli get-entity "$id" | jq -c '.data // {}' || echo '{}')The helper uses exponential backoff with jitter to prevent thundering herd on rate-limited APIs. It logs retry attempts via mcp_log_debug.
Parallel external calls:
For fetching data from multiple sources concurrently:
# Create temp directory for results
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
# Launch parallel fetches
(my_cli users list > "$tmp_dir/users.json" 2>/dev/null) &
(my_cli teams list > "$tmp_dir/teams.json" 2>/dev/null) &
(my_cli projects list > "$tmp_dir/projects.json" 2>/dev/null) &
# Wait for all
wait
# Collect results with fallbacks
users=$(cat "$tmp_dir/users.json" 2>/dev/null | jq -c '. // []' || echo '[]')
teams=$(cat "$tmp_dir/teams.json" 2>/dev/null | jq -c '. // []' || echo '[]')
projects=$(cat "$tmp_dir/projects.json" 2>/dev/null | jq -c '. // []' || echo '[]')
mcp_result_success "$(jq -n --argjson u "$users" --argjson t "$teams" --argjson p "$projects" \
'{users: $u, teams: $t, projects: $p}')"Rate limiting external APIs:
For APIs with rate limits, implement throttling at the tool level:
# Simple file-based throttle (10 calls/minute)
rate_limit_file="/tmp/myapi.rate"
while true; do
now=$(date +%s)
last=$(cat "$rate_limit_file" 2>/dev/null || echo 0)
if (( now - last >= 6 )); then # 10 calls/min = 1 per 6s
echo "$now" > "$rate_limit_file"
break
fi
sleep 1
done
# Now safe to call the APIRate limiting semantics vary by API (per-second, per-minute, sliding windows), so implement at the tool level with knowledge of your specific API's limits.
Capturing stdout and stderr separately:
Tools wrapping external CLIs often need separate access to stdout and stderr:
# Create temp files for capture
_stdout_file=$(mktemp)
_stderr_file=$(mktemp)
trap 'rm -f "$_stdout_file" "$_stderr_file"' EXIT
# Execute with capture
"${cmd[@]}" >"$_stdout_file" 2>"$_stderr_file"
_exit_code=$?
# Read results
_stdout=$(cat "$_stdout_file")
_stderr=$(cat "$_stderr_file")
# Use results
if [ "$_exit_code" -eq 0 ]; then
mcp_result_success "$_stdout"
else
# Stream stderr via stdin to avoid argv pressure with large error messages
mcp_result_error "$(printf '%s' "$_stderr" | "${MCPBASH_JSON_TOOL_BIN}" -Rsc --argjson c "$_exit_code" \
'{type: "cli_error", message: ., exitCode: $c}')"
fiHandling CLIs with structured JSON errors:
Some CLIs output structured error responses to stdout when using --json flags, rather than using stderr. Use mcp_extract_cli_error to handle both patterns:
# Execute with capture
"${cmd[@]}" >"$_stdout_file" 2>"$_stderr_file"
_exit_code=$?
_stdout=$(cat "$_stdout_file")
_stderr=$(cat "$_stderr_file")
if [ "$_exit_code" -eq 0 ]; then
mcp_result_success "$_stdout"
else
# Extract error from JSON stdout or fall back to stderr
_error_msg=$(mcp_extract_cli_error "$_stdout" "$_stderr" "$_exit_code")
mcp_result_error "$(printf '%s' "$_error_msg" | "${MCPBASH_JSON_TOOL_BIN}" -Rsc --argjson c "$_exit_code" \
'{type: "cli_error", message: ., exitCode: $c}')"
fiThe helper checks these patterns in order:
| Pattern | Extraction | Example CLIs |
|---|---|---|
{"error": {"message": "..."}} |
.error.message |
REST API wrappers |
{"error": "..."} |
.error |
npm, yarn |
{"success": false, "message": "..."} |
.message |
Custom CLIs |
{"errors": [{"message": "..."}]} |
.errors[0].message |
GraphQL CLIs |
| Traditional stderr | Raw stderr | Most Unix tools |
Extraction priority: The helper uses first-match ordering. If a response contains both .error.message and a top-level .error string, .error.message wins.
Why this matters: LLMs use the message field to understand failures and self-correct. Empty messages lead to generic "Tool failed" errors that don't help the LLM retry intelligently.
MCP-Bash tools use set -euo pipefail by default. Here are common gotchas:
Post-increment from zero fails under set -e:
# ❌ Exits with code 1 when count=0 (arithmetic evaluates to 0 = falsy)
count=0
((count++)) # Script exits here!
# ✅ Pre-increment always succeeds
count=0
((++count)) # count is now 1
# ✅ Or use addition assignment
count=0
((count += 1)) # count is now 1
# ✅ Or use arithmetic expansion (never fails)
count=0
count=$((count + 1))Why: ((expr)) returns exit code based on the expression's value. ((0++)) evaluates to 0 before incrementing, and 0 is falsy → exit code 1 → set -e aborts.
Empty arrays in strict mode:
# ❌ Fails with "unbound variable" under set -u
items=()
for item in "${items[@]}"; do echo "$item"; done
# ✅ Use conditional expansion
items=()
for item in "${items[@]+"${items[@]}"}"; do echo "$item"; done
# ✅ Or check length first
if (( ${#items[@]} > 0 )); then
for item in "${items[@]}"; do echo "$item"; done
fiPiped commands and exit codes:
# ❌ Only checks grep's exit code, not cmd's
cmd | grep pattern
# If cmd fails but grep succeeds, script continues
# ✅ pipefail catches failures in any pipeline stage (already set by default)
set -o pipefail
cmd | grep pattern # Now fails if cmd failsExternal CLI error handling under set -e:
# ❌ Error branch unreachable — set -e exits on non-zero CLI return
set -euo pipefail
source "${MCP_SDK:?}/tool-sdk.sh"
result=$(mycli get "$id") # script exits here if mycli returns non-zero
if [[ -z "$result" ]]; then
mcp_result_error "$(mcp_json_obj type "not_found" message "Not found")" # never reached
fi
# ✅ Use set -uo pipefail (no -e) when wrapping CLIs with business errors
set -uo pipefail
source "${MCP_SDK:?}/tool-sdk.sh"
result=$(mycli get "$id" 2>&1) || {
mcp_result_error "$(mcp_json_obj type "cli_error" message "CLI failed: ${result}")"
exit 0
}
mcp_result_success "$(printf '%s' "$result" | jq -c '.')"Why: The scaffold default set -euo pipefail is correct for tools that only use SDK helpers (which are designed to work under -e). But external CLIs often return non-zero exit codes for business logic (e.g., "record not found", "no matches"), not just fatal errors. With -e, the script exits before your error handling runs. Drop the -e and handle errors explicitly when wrapping CLIs. See example 15-cli-wrapper for a complete working example.
- Use
MCPBASH_LOG_LEVELfor startup defaults, then rely onlogging/setLevelrequests for runtime tuning (§6.2). - Enable
MCPBASH_LOG_VERBOSE=truewhen debugging path-related issues; paths and manual-registration script output are redacted by default. Warning: verbose mode exposes file paths and usernames—disable after troubleshooting. See docs/LOGGING.md. - Enable
MCPBASH_DEBUG_PAYLOADSonly while debugging parser bugs; purge${TMPDIR}/mcpbash.state.*afterward to avoid leaking sensitive payloads.
- Every snippet in this guide and in new PRs should cite the source path/line to keep drift manageable.
- When adding diagrams, include descriptive text such as "Mermaid sequence describing lifecycle negotiation" so text-only readers stay informed (§Supporting Assets).
The SDK provides helpers for building MCP CallToolResult responses with consistent {success, result} envelope patterns. These helpers handle structuredContent, content[].text population, and isError flag management.
Use mcp_result_success to emit a success envelope:
#!/usr/bin/env bash
source "${MCP_SDK:?}/tool-sdk.sh"
# Return structured data
data=$(some_command | jq -c '.')
mcp_result_success "$data"The helper produces:
{
"content": [{"type": "text", "text": "...summary..."}],
"structuredContent": {"success": true, "result": <your_data>},
"isError": false
}Use mcp_result_error to emit error envelopes with isError: true:
#!/usr/bin/env bash
source "${MCP_SDK:?}/tool-sdk.sh"
if [ ! -f "$target_path" ]; then
mcp_result_error "$(jq -n --arg p "$target_path" '{type:"not_found", path:$p}')"
exit 0 # Tool completed, error is in structured response
fiThe helper produces:
{
"content": [{"type": "text", "text": "error..."}],
"structuredContent": {"success": false, "error": {"type": "not_found", "path": "..."}},
"isError": true
}Use mcp_error to simplify error construction with consistent schema:
#!/usr/bin/env bash
source "${MCP_SDK:?}/tool-sdk.sh"
# Simple error
mcp_error "not_found" "User not found"
# With LLM-actionable hint
mcp_error "validation_error" "Count must be positive" \
--hint "Try count=10 for a reasonable default"
# With structured data for context
mcp_error "validation_error" "Value out of range" \
--hint "Adjust value to be within bounds" \
--data '{"received": -5, "min": 1, "max": 100}'The helper builds a normalized error object and delegates to mcp_result_error:
{
"type": "validation_error",
"message": "Value out of range",
"hint": "Adjust value to be within bounds",
"data": {"received": -5, "min": 1, "max": 100}
}Recommended error types (conventions, not enforced):
| Type | Use case |
|---|---|
not_found |
Entity doesn't exist |
validation_error |
Input fails validation |
invalid_json |
JSON parsing failed |
permission_denied |
Access not allowed |
file_error |
File system operation failed |
network_error |
Network request failed |
timeout |
Operation timed out |
cli_error |
External command failed |
internal_error |
Unexpected/fallback error |
When to use mcp_error vs mcp_fail:
- Use
mcp_errorfor tool execution errors the LLM can potentially recover from (bad input values, missing resources) - Use
mcp_failfor protocol errors that indicate structural problems (missing required parameters, internal server errors)
Use mcp_json_truncate to safely truncate large arrays before returning them:
#!/usr/bin/env bash
source "${MCP_SDK:?}/tool-sdk.sh"
# Fetch potentially large dataset
items=$(my_cli list-all | jq -c '.')
# Truncate to fit context window (default 100KB)
truncated=$(mcp_json_truncate "$items" 102400)
mcp_result_success "$truncated"Specifying the array path: When your API returns arrays in a non-standard field, use --array-path to specify which array to truncate:
# Stripe-style API with .data array
result=$(mcp_json_truncate "$response" "$max_bytes" --array-path ".data")
# Elasticsearch-style with nested hits
result=$(mcp_json_truncate "$response" "$max_bytes" --array-path ".hits.hits")
# Jira-style with .issues array
result=$(mcp_json_truncate "$response" "$max_bytes" --array-path ".issues")Without --array-path, the function falls back to heuristics:
- Top-level arrays (truncated directly)
- Objects with
.resultsarray (backward compatibility)
For other structures (.data, .items, etc.), explicitly specify the path.
Return format: The helper wraps results in a metadata envelope:
{
"result": {...original structure with truncated array...},
"truncated": true,
"kept": 50,
"total": 1000
}When truncation isn't needed (truncated: false), kept equals total.
Error handling: Invalid paths return structured errors instead of crashing:
| Error Type | Cause |
|---|---|
path_not_found |
--array-path points to missing key |
invalid_array_path |
Path exists but is not an array |
invalid_path_syntax |
Malformed path (must be .key or .key.nested) |
output_too_large |
Even empty array exceeds max_bytes |
Path syntax: Only simple dot-notation paths are supported (.data, .response.items). Index notation (.data[0]), hyphenated keys (.data-items), and special characters require jq bracket syntax which is not supported for security reasons.
mcp_is_valid_json – Check if a value is valid single-value JSON (handles false and null correctly):
if mcp_is_valid_json "$user_input"; then
mcp_result_success "$user_input"
else
mcp_result_error '{"type":"invalid_json","message":"Input is not valid JSON"}'
fimcp_byte_length – Get UTF-8 safe byte length for size calculations:
payload_size=$(mcp_byte_length "$data")
if [ "$payload_size" -gt 1048576 ]; then
mcp_log_warn "mytool" "Large payload: ${payload_size} bytes"
fi| Scenario | Use |
|---|---|
| Simple data return (legacy) | mcp_emit_json |
| Structured envelope with success/error | mcp_result_success / mcp_result_error |
| LLM needs actionable error context | mcp_result_error with detailed error object |
| Large arrays that may exceed context | mcp_json_truncate + mcp_result_success |
The result helpers are recommended for new tools as they provide consistent response shapes that clients can rely on.
Use mcp_result_text_with_resource to return text/structured data with optional embedded resources:
# Text with single resource
mcp_result_text_with_resource "Report ready" --path /tmp/report.pdf --mime application/pdf
# Multiple resources
mcp_result_text_with_resource '{"status":"complete"}' \
--path /tmp/data.csv --mime text/csv \
--path /tmp/chart.png --mime image/png
# MIME auto-detection (requires `file` command)
mcp_result_text_with_resource '{"done":true}' --path /tmp/output.txtResources are embedded in the content[] array alongside the text. MIME type is auto-detected if --mime is omitted (requires file command; falls back to application/octet-stream).
Note: Resources require MCP_TOOL_RESOURCES_FILE to be set (automatic in tool context). Outside tool context, resources are logged as warnings and skipped.
Warning: This helper overwrites MCP_TOOL_RESOURCES_FILE. Do not mix with direct file writes; the helper will replace any existing content.
When wrapping external CLIs that emit progress information to stderr, use mcp_run_with_progress to automatically parse and forward MCP progress notifications:
mcp_run_with_progress [OPTIONS] -- COMMAND [ARGS...]
Options:
--pattern REGEX Match progress lines (required)
--extract MODE Extraction mode: json (default), match1, ratio
--stdout FILE Write stdout to file (default: stdout)
--interval SEC Polling interval (default: 0.2)
--progress-file FILE Read progress from file instead of stderr
--total N Pre-computed total for percentage calculation
--dry-run Output JSON to stderr instead of emitting
--quiet Suppress debug logging of non-progress lines
--stderr-file FILE Write non-progress stderr lines to file| Mode | Description | Use Case |
|---|---|---|
json |
Parse line as JSON, extract .progress and .message |
CLIs with NDJSON output |
match1 |
Use first regex capture group as percentage (0-100) | CLIs with 50% style output |
ratio |
Calculate capture[1] * 100 / capture[2] |
CLIs with [5/10] style output |
Timeout extension: When progressExtendsTimeout is enabled, any line matching --pattern extends the timeout—even if extraction fails or the line lacks a .progress field. This decouples "activity detection" (pattern match = process is alive) from "progress reporting" (.progress field = percentage complete). This allows tools that emit structured events like {"type":"step_start"} to keep the timeout extended without needing artificial progress values.
NDJSON Progress (Custom CLIs):
# Pattern: {"progress": 50, "message": "Working..."}
mcp_run_with_progress \
--pattern '^\{.*"progress"' \
--extract json \
-- my-cli --progress-jsonPercentage-based CLIs (wget, curl, pip):
# wget example
mcp_run_with_progress \
--pattern '([0-9]+)%' \
--extract match1 \
-- wget -q --show-progress https://example.com/file.zip
# curl example (use stdbuf for unbuffered output on Linux)
mcp_run_with_progress \
--pattern '([0-9]+)\.?[0-9]*%' \
--extract match1 \
-- stdbuf -oL curl -# -O https://example.com/file.zipCounter-based CLIs (rsync, batch scripts):
# rsync example
mcp_run_with_progress \
--pattern '\[([0-9]+)/([0-9]+)\]' \
--extract ratio \
-- rsync -av --info=progress2 src/ dest/
# Batch processing example
mcp_run_with_progress \
--pattern '([0-9]+) of ([0-9]+)' \
--extract ratio \
-- batch-convert *.pngffmpeg (Progress File):
ffmpeg uses a dedicated progress output, not stderr:
# Get total duration first
total_us=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 input.mp4 | awk '{print int($1*1000000)}')
# Run with progress file
mcp_run_with_progress \
--progress-file /tmp/ffprogress \
--total "$total_us" \
--pattern '^out_time_us=([0-9]+)' \
--extract match1 \
-- ffmpeg -i input.mp4 -progress /tmp/ffprogress output.mp4Docker Build:
mcp_run_with_progress \
--pattern '^\{.*"progress"' \
--extract json \
-- docker build --progress=rawjson -t myimage .Progress notifications are throttled to 100 per request per minute (configurable via MCPBASH_MAX_PROGRESS_PER_MIN). The mcp_progress helper handles throttling internally—excess notifications are dropped with a warning log. If a CLI emits progress faster than the poll interval, lines are batched in a single poll cycle.
Some CLIs buffer stderr even in non-TTY mode:
# Linux: use stdbuf to disable buffering
mcp_run_with_progress -- stdbuf -oL some-cli --progress
# macOS: stdbuf not available by default
# Option 1: Install GNU coreutils (brew install coreutils → gstdbuf)
# Option 2: Use script trick to force PTY-like behavior
mcp_run_with_progress -- script -q /dev/null some-cli --progress
# Python scripts: use -u flag or environment variable
PYTHONUNBUFFERED=1 mcp_run_with_progress -- python myscript.pyWhen you need to report CLI errors to the MCP client, use --stderr-file to capture non-progress stderr lines:
stderr_file=$(mktemp)
trap 'rm -f "$stderr_file"' EXIT
mcp_run_with_progress \
--pattern '^\{.*"progress"' \
--extract json \
--stderr-file "$stderr_file" \
-- my-cli --json >"$stdout_file"
exit_code=$?
if [[ $exit_code -ne 0 ]]; then
error_msg=$(cat "$stderr_file")
mcp_result_error "$("${MCPBASH_JSON_TOOL_BIN}" -n --arg msg "$error_msg" '{error: $msg}')"
fiNote: The caller is responsible for cleaning up the stderr file.
- Float progress values: Decimal progress values (e.g.,
{"progress": 50.5}) are accepted and truncated to integers. - Line timeout: Progress lines must complete within 10 seconds. For extremely slow CLIs, use
--progress-filewith a dedicated progress output file. - Progress file truncation: When using
--progress-file, the file is truncated at startup. A warning is logged if the file was non-empty.
| Layer | Scope | Expected gates before merge |
|---|---|---|
| Lint/format | ./test/lint.sh (shellcheck+shfmt) |
Always required. |
| Unit | Focused libraries (./test/unit/run.sh lock.bats) |
Required when touching lib/*.sh or SDK helpers. |
| Integration | Full capability smoke (test/integration/test_capabilities.sh) plus targeted suites (test/integration/test_tools.sh, etc.) |
Required for handler/protocol changes. |
| Smoke/examples | test/examples/test_examples.sh |
Run whenever scaffolded assets change. |
| Stress/soak | Custom loops combining time ./bin/mcp-bash with replayed JSON |
Required before large releases or concurrency changes. |
./test/lint.sh./test/unit/run.sh lock.bats(or pass one or more relevant*.bats)./test/integration/test_capabilities.sh- Focused suite(s) matching touched subsystem(s)
./test/examples/test_examples.sh
The run-tool CLI command lets you invoke tools directly without starting the full MCP server. This is invaluable for rapid iteration, debugging, and CI integration:
# Basic invocation
mcp-bash run-tool my-tool --args '{"name":"test"}'
# Simulate MCP roots (comma-separated)
mcp-bash run-tool my-tool --args '{"path":"/repo/file.txt"}' --roots '/repo,/other'
# Dry-run: validate args and metadata without executing
mcp-bash run-tool my-tool --args '{"count":5}' --dry-run
# Override timeout (seconds)
mcp-bash run-tool slow-tool --args '{}' --timeout 120
# Stream tool stderr for debugging
mcp-bash run-tool my-tool --args '{}' --verbose
# Use cached registry (skip refresh for faster iteration)
mcp-bash run-tool my-tool --args '{}' --no-refresh
# Test minimal mode behavior
mcp-bash run-tool my-tool --args '{}' --minimal
# Specify project root explicitly
mcp-bash run-tool my-tool --args '{}' --project-root /path/to/projectWindows/Git Bash users: Set
MSYS2_ARG_CONV_EXCL="*"before running commands with path arguments (--roots,--project-root) to prevent automatic path mangling. Example:MSYS2_ARG_CONV_EXCL="*" mcp-bash run-tool my-tool --roots '/repo'. See docs/WINDOWS.md for details.
Notes:
--rootsandMCPBASH_ROOTSrequire existing, readable paths; invalid entries fail fast.- If no roots are provided by client/env/config, the project root is used as a single default.
- Client roots timeouts keep the current cache; no warning is emitted for the fallback.
Use cases:
| Scenario | Command |
|---|---|
| Quick iteration on tool logic | mcp-bash run-tool my-tool --args '{"x":1}' --verbose |
| Test roots enforcement | mcp-bash run-tool my-tool --args '{"path":"/outside"}' --roots '/allowed' |
| Validate input schema handling | mcp-bash run-tool my-tool --args '{"invalid":true}' --dry-run |
| Reproduce bug with specific inputs | mcp-bash run-tool my-tool --args '{"buggy":"input"}' |
| CI smoke test | mcp-bash run-tool my-tool --args '{}' --timeout 10 |
Dry-run output:
Tool: my-tool
Args: 42 bytes
Roots: 2
Timeout: 30
Status: Ready to execute (re-run without --dry-run to run)
Notes:
- Roots are comma-separated (not colon) to avoid conflicts with Windows drive letters
- Elicitation is not supported in CLI mode; tools requiring user input will receive decline responses
- Exit codes propagate from the tool; non-zero indicates failure
- Generate
test/run.shandtest/README.mdwithbin/mcp-bash scaffold test(inside an existing project). - Run
./test/run.sh [--verbose] [--force]to exercise tools viarun-tool; validation runs first unless--forceis set. - The scaffold refuses to overwrite existing files so contributors do not lose local edits.
Cache results by exporting MCP_TESTS_SKIP_REMOTE=1 when remote fixtures are unavailable. Document skipped suites in your PR description.
| Failure | Symptom | Likely fix |
|---|---|---|
Required command "shfmt" not found |
Lint stage exits 1 immediately | Install shfmt or pin GO111MODULE=on go install mvdan.cc/sh/v3/cmd/shfmt@latest. |
JSON parse failure in integration logs |
CI uses minimal mode due to missing jq/gojq |
Ensure runners have jq or gojq installed. |
timeoutSecs exceeded |
Tool exits via watchdog | Adjust <tool>.meta.json timeout, optimise logic, or move to async worker. |
registry cache exceeded MCPBASH_REGISTRY_MAX_BYTES |
Discovery fails after adding many assets | Increase env var for CI only or introduce manual registration. |
| Windows-specific path issues | Tests referencing /tmp fail |
Mirror mitigations from docs/WINDOWS.md (use cygpath, avoid /proc). |
- Launch-time environment variables (
MCPBASH_*,MCP_*) server.d/env.shexports- Manual registration inputs (
server.d/register.json/server.d/register.sh) overriding discovery output - Client-initiated negotiation (capabilities, logging)
Document configuration in server.d/README.md (if present) so on-call operators know which knobs are safe to adjust.
- Use log levels consistent with RFC-5424 (enforced by
handlers/logging.sh). - Route structured diagnostics through the logging handler instead of writing raw stderr.
- When running in production, forward logs to a collector by wrapping
bin/mcp-bashwith a process supervisor that captures stdout/stderr separately. - Consider enabling payload tracing only on reproducing hosts; sanitize archives before sharing externally.
- Watch
${TMPDIR}/mcpbash.state.*for runaway payload dumps. - Set up synthetic clients that periodically send
pingandlogging/setLevelto verify request handling. - Use the following operational readiness checklist before exposing a new instance:
- Ping smoke test (
printf '{"jsonrpc":"2.0","id":1,"method":"ping"}\n' | bin/mcp-bash) returns{"jsonrpc":"2.0","id":1,"result":{}}in full capability mode. -
MCPBASH_MAX_CONCURRENT_REQUESTStuned for host CPU/memory (default 16). - Registry TTLs set to balance discovery churn vs freshness.
-
server.d/env.shcommitted or documented for reproducibility. - Monitoring hooks emit at least heartbeat metrics (request count, failures).
- Backups created for
.registry/*.jsonif manual overrides exist. - Rollback plan (git tag, release archive, or container image) reviewed.
| Model | Description | Security notes |
|---|---|---|
| Local dev | Direct bin/mcp-bash over stdio |
Keep payload tracing off unless needed; align with developer laptops. |
| Containerised | Wrap server inside OCI image with volume mounts for tools/, resources/ |
Mount read-only wherever possible; use MCP_RESOURCES_ROOTS to limit host exposure. |
| Remote via stdio proxy | Pair with docs/REMOTE.md gateways to bridge HTTP/SSE | Maintain session headers, restrict outbound networking, audit proxies regularly. |
- Keep
.registry/out of version control (already.gitignored) and monitor growth. - Run a nightly job that executes
bin/mcp-bash registry refresh --no-notifyto rebuild registries; use--project-rootin CI/offline contexts. - Validate metadata via project-local checks before merging large batches of tools/resources.
- Use
mcp-bash bundleto create distributable.mcpbpackages for one-click installation in Claude Desktop and other MCP clients. - Add
mcpb.confto customize bundle metadata (name, version, author, repository); values fall back toserver.d/server.meta.json,VERSIONfile, and git config. - Include an
icon.pngoricon.svgin your project root for visual identification in client UIs. - Test bundles locally by extracting and running
./server/run-server.sh --healthbefore distribution. - See docs/MCPB.md for complete bundling documentation.
- Start with docs/SECURITY.md for threat model context. Apply principle of least privilege by scoping
MCP_RESOURCES_ROOTSand keeping tool environments minimal (MCPBASH_TOOL_ENV_MODE). - Prefer declarative registration (
server.d/register.json) when possible; it avoids executing project shell code during list/refresh flows. If you use hook registration, it is opt-in (MCPBASH_ALLOW_PROJECT_HOOKS=true) and must be owned by the current user with no group/world write bits—review and signserver.d/register.shlike application code. - Secrets management: rely on OS keychains or inject short-lived tokens at launch. Avoid long-lived tokens in
.envfiles that might leak through scaffolds. - Validate third-party scaffolds before execution. Run
shellcheckmanually on contributions and require signed commits for sensitive providers. KeepMCPBASH_TOOL_ALLOWLISTscoped to the minimal set of tools; use*only in fully trusted projects. - For compliance regimes, map MCP logs and payload dumps to your retention policies; scrub
mcpbash.state.*directories after incidents. - Treat local validation helpers as privileged operations:
mcp-bash validate --fixis intended for trusted project trees; it will make scripts executable but deliberately skips auto-fixing symlinked scripts and warns instead so you can audit the targets.mcp-bash doctorsurfaces framework location, version, PATH wiring, and basic project layout for quick diagnosis; it is designed for local operators and should not be exposed as a public-facing tool in multi-tenant environments.
- Consult docs/LIMITS.md before tuning concurrency, payload sizes, or progress frequency.
- Batch and pagination tuning – When returning large lists, paginate aggressively via
lib/paginate.shhelpers and returnnextCursorwhen more items remain to prevent client overload. - Timeout strategies – Prefer short defaults with retries over very long-running tools. If clients require streaming, add progress signals every ~5 seconds to keep the channel alive.
- Benchmarking – Capture
time bin/mcp-bash < sample.jsonmetrics before/after optimisation. When adjusting concurrency, monitor CPU steal and memory pressure to avoid thrashing. - Stress testing – Re-run integration suites concurrently (e.g.,
GNU parallelaroundtest/integration/test_capabilities.sh) to validate lock coordination inlib/lock.sh.
- Lifecycle negotiation – Follow the message flow from the Mermaid outline below to keep clients and server aligned on protocol versions:
Mermaid sequence describing lifecycle negotiation and capability downgrades.
sequenceDiagram
participant Client
participant Server
Client->>Server: initialize
Server-->>Client: serverInfo (protocol handshake guarantees)
Client->>Server: setLevel / subscribe
Server-->>Client: notifications (tools/resources/prompts)
Client->>Server: tool invocation
Server-->>Client: result + nextCursor
- Full vs minimal mode – Document which commands require full JSON tooling. For example, completing resource list pagination depends on
jqorgojqfor canonicalisation; clients should handleminimalcapability flags gracefully. - Backward compatibility – Protocol
2025-03-26accepts JSON-RPC array batches automatically; newer protocols remain strict single-object per line unlessMCPBASH_COMPAT_BATCHES=trueis set for legacy clients. - Transport considerations – When tunnelling through gateways, preserve stdio framing (one JSON object per line) and forward
Mcp-Session-Id. Reference docs/REMOTE.md for gateway-specific nuances. - Example transcripts – Capture happy-path sequences (initialize → tools/list → tool invocation) using your recording method of choice and store sanitized transcripts under
examples/run/. (No built-in--transcriptflag today.)
- Follow repository coding style (tab-indent per
.editorconfig,set -euo pipefailheaders,shellcheckvisible directives). Use# shellcheck disable=…only with in-line justification (seeexamples/01-args-and-validation/tools/echo-arg/tool.sh:3). - Update documentation anytime you change behaviour surfaced in README.md, SPEC-COMPLIANCE.md, or this guide.
- Keep commits focused; each should include docs/tests when touching behaviour.
- Release cadence suggestion: tag monthly and document release notes referencing limits, operational changes, and compatibility toggles.
-
./test/lint.sh+ required unit/integration suites pass locally. - Added/updated tools have
.meta.jsonwith accurate descriptions, arguments, andtimeoutSecs. - Docs updated (README, coverage matrix, this guide) when behaviour or limits shift.
- Added snippets cite source path/line.
- Accessibility review performed for diagrams/media (alt text, captions).
- SECURITY/LIMITS implications reviewed and documented if applicable.
- Verify capability negotiation remains correct (no regressions in
handlers/lifecycle.sh). - Confirm tests illustrate new edge cases (unit + integration as applicable).
- Check metadata/registry changes for deterministic ordering and size limits.
- Ensure operator docs mention new configuration/environment toggles.
- Validate security boundaries (no unchecked user input reaching shell).
- Capture the scenario/problem (one paragraph).
- Cite relevant protocol areas and repo paths.
- Provide runnable snippet referencing
path:line. - List verification steps/tests.
- Update cross-links (README/coverage/test docs) and changelog entry below.
- README.md – Overall scope, runtime guarantees, scaffolding summary.
- SPEC-COMPLIANCE.md – Protocol parity checklist.
- TESTING.md – Command references for lint/unit/integration suites.
- docs/SECURITY.md – Threat model + least-privilege defaults.
- docs/LIMITS.md – Performance guardrails.
- docs/REMOTE.md – Guidance for HTTP/SSE gateways.
- MCP community resources (Discord, issue tracker) – Ask about roadmap or interoperability quirks; link PRs referencing this guide when requesting feedback.
- Why does minimal mode trigger unexpectedly? – Double-check that
jqorgojqis onPATH. When absent, the server falls back to minimal mode, which limits features. - How do I keep registry scans fast? – Limit directory depth, set TTL to ≥5 seconds, and avoid generating thousands of entries per type; consider manual registration for static sets.
- Can I embed binary resources? – Prefer referencing files via resource providers; if binary data is required, base64-encode it within a resource response and document size implications per docs/LIMITS.md.
- Capability mode – Either
full(JSON tooling available) orminimal(reduced handler surface). - Discovery – Automated scanning of
tools/,resources/,prompts/resulting in cached registries. - TTL – Time-to-live controlling how long registries remain cached before rescans.
- Worker slot – Concurrent request execution slot bounded by
MCPBASH_MAX_CONCURRENT_REQUESTS.
# Lint & format
./test/lint.sh
# Run unit tests
./test/unit/run.sh
# Run integration suite
./test/integration/test_capabilities.sh
# Smoke examples
./test/examples/test_examples.sh
Mermaid decision tree describing common failure triage paths.
flowchart TD
start([Failure detected]) --> lint{Lint failed?}
lint -- yes --> lintfix[Install/repair shellcheck or shfmt]
lint -- no --> minimal{Minimal mode?}
minimal -- yes --> jqcheck[Install jq/gojq or set FORCE_MINIMAL=false]
minimal -- no --> registry{Registry errors?}
registry -- yes --> clean[Clear .registry/*.json and verify metadata]
registry -- no --> timeout{Tool timeouts?}
timeout -- yes --> adjust[Decrease runtime or raise timeoutSecs]
timeout -- no --> escalate[Inspect logs, enable MCPBASH_DEBUG_PAYLOADS temporarily]
adjust --> done([Issue resolved])
clean --> done
jqcheck --> done
lintfix --> done
escalate --> done
| Date | Version | Notes |
|---|---|---|
| 2026-01-16 | v1.7 | Added mcp_error convenience helper for tool execution errors with consistent schema, optional hints, and structured data. Updated SDK quick reference table. Added "Convenience error helper" section to §4.7. Updated ERRORS.md with recommended error types. |
| 2026-01-07 | v1.6 | Added §4.2 "LLM-friendly tool metadata" covering rich descriptions, parameter documentation, and domain model resources. Created companion doc LLM-CONTEXT.md with comprehensive patterns. Renumbered subsequent sections (§4.3-§4.9). Updated scaffold template with description guidance. |
| 2026-01-06 | v1.5 | Added §4.9 "Progress passthrough from subprocesses" covering mcp_run_with_progress helper for forwarding CLI progress to MCP. Includes common patterns for NDJSON, percentage, counter, and ffmpeg-style CLIs. Updated SDK quick reference table. |
| 2026-01-04 | v1.4 | Added §4.7 "Building CallToolResult responses" covering mcp_result_success, mcp_result_error, mcp_json_truncate, mcp_is_valid_json, and mcp_byte_length helpers. Updated SDK quick reference table. Added "Capturing stdout and stderr separately" pattern to §4.3. |
| 2026-01-03 | v1.3 | Added mcp_with_retry SDK helper for retrying transient failures. Added parallel external calls and rate limiting patterns. Added project health checks hook (server.d/health-checks.sh). Added "Common Bash Pitfalls" section (§4.4) covering ((var++)) under set -e, empty arrays, and pipefail. |
| 2026-01-03 | v1.2 | Added "Calling external CLI tools" section (§4.3) with safe jq pipeline patterns, fallback defaults table, and external command quick reference. Added cross-reference in ERRORS.md. |
| 2025-12-05 | v1.1 | Expanded SDK documentation: comprehensive coverage of mcp_args_require, mcp_args_bool, mcp_args_int, mcp_require_path, structured output helpers, and run-tool CLI usage patterns. Added SDK quick reference table. |
| 2024-10-18 | v1.0 | Initial publication covering development, testing, operations, and contribution guidance. |