Design Philosophy: Simple adapter with robust error handling, async-aware, and future-extensible.
mcp-shellcheck is a Model Context Protocol (MCP) server that wraps the ShellCheck CLI tool, providing shell script linting as a service for AI agents (Claude Desktop, Claude Code, Cursor, VS Code, OpenCode).
Core Principle: One responsibility, done well — lint shell scripts via ShellCheck, nothing more.
┌─────────────┐
│ MCP Client│ (Claude, Cursor, etc.)
└──────┬──────┘
│ JSON-RPC over stdio
▼
┌─────────────────────────────┐
│ ShellCheck MCPServer │
│ ┌─────────────────────┐ │
│ │ list_tools() │───┼─> Tool definitions
│ │ call_tool() │ │ (shellcheck, shellcheck_info)
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ run_shellcheck_async│ │
│ │ (thread pool) │ │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ run_shellcheck_sync│ │
│ │ - validate inputs │ │
│ │ - build cmd │ │
│ │ - subprocess.run │ │
│ │ - parse JSON │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
│
▼
┌─────────────┐
│ ShellCheck │ (binary, v0.11.0 recommended)
└─────────────┘
Decision: Use asyncio.to_thread() / run_in_executor() to wrap blocking subprocess.run.
Rationale:
- ShellCheck is a CLI tool; no async Python bindings exist
- Must not block MCP server event loop (other requests would queue)
- Thread pool is acceptable for I/O-bound subprocess calls
- Simpler than rewriting to
asyncio.create_subprocess_execfor a single call
Trade-offs:
- Thread overhead minimal vs subprocess cost
- Easy to understand and maintain
- Could switch to native async later if needed
Decision: Always use -f json flag. Never parse human-readable output.
Rationale:
- Machine-readable, stable format
- Locale-independent
- Structured data (line, column, code, severity, message, fix)
- ShellCheck's JSON is well-defined
Before (v0.1.0): Fragile text parsing by splitting on :
After (v0.1.2): Robust json.loads()
Decision: Validate all inputs before spawning subprocess.
Validation checks:
- Exactly one of
file_pathorscript_contentprovided file_pathexists, is file, size < 10MBscript_contentsize < 10MBshellin allowed set (bash, sh, dash, ksh, ash)
Rationale:
- Fail fast with clear error messages
- Prevent DoS via huge inputs
- Avoid shellcheck errors that are hard to debug
- Security: don't pass arbitrary paths without checking
Decision: Single shellcheck_mcp_server.py file, not split across modules.
Rationale:
- Server is small enough (~500 lines) that module overhead hurts more than it helps
- Easy to deploy -- copy one file
- No package needed for basic use
- Can modularize if complexity grows
Decision: Use Python logging module, log to stderr.
Levels:
INFO: Startup, completion, countsDEBUG: Command built, subprocess detailsWARNING: JSON parse failures, fallbacksERROR: ShellCheck binary missing, timeouts
Rationale:
- Observability in production
- Debuggability when things go wrong
- MCP clients can capture stderr for diagnostics
- Configurable via
LOG_LEVELenv var
MCP Request (JSON)
│
▼
call_tool("shellcheck", {script_content="...", shell="bash", severity="warning"})
│
▼
validate_inputs() ──if invalid─→ error response
│
▼
run_shellcheck_async() (thread pool)
│
▼
run_shellcheck_sync()
│
├── build argv: ["shellcheck", "-s", "bash", "-f", "json", "-S", "warning", "-"]
├── subprocess.run(input=script_content, capture_output=True, timeout=30)
├── json.loads(stdout) ──if parse fail─→ error response
└── return {success, message, results, exit_code}
│
▼
json.dumps(result, indent=2) wrapped in TextContent
│
▼
JSON response to MCP client
| Variable | Default | Purpose |
|---|---|---|
SHELLCHECK_CMD |
"shellcheck" |
Override shellcheck binary path |
LOG_LEVEL |
"INFO" |
Logging level (DEBUG, INFO, WARNING, ERROR) |
python3 shellcheck_mcp_server.py --log-level DEBUG| Module/Function | Responsibility |
|---|---|
validate_inputs() |
Guard against bad inputs (files, sizes, shells) |
run_shellcheck_sync() |
Synchronous shellcheck invocation with JSON parsing |
run_shellcheck_async() |
Thread pool wrapper for async compatibility |
create_server() |
MCP server setup, tool definitions, error boundaries |
main() / main_sync() |
Entry points with arg parsing |
| Parameter | Type | Required | Default | Flag |
|---|---|---|---|---|
file_path |
string | no* | -- | path arg |
script_content |
string | no* | -- | stdin input |
shell |
string | no | "bash" |
-s |
check_sourced |
boolean | no | false |
-a |
enable_all |
boolean | no | false |
-o all |
exclude |
string | no | -- | -e |
include |
string | no | -- | -i |
severity |
string | no | -- | -S |
Resolved:
includeis now fully wired fromcall_toolthrough to shellcheck.
Returns: server version, shellcheck version, supported shells, max script size.
| Error Type | Handling |
|---|---|
| Validation | Return error response before subprocess |
| Timeout | Catch subprocess.TimeoutExpired, return error |
| Binary not found | Catch FileNotFoundError, return helpful message |
| JSON parse error | Log warning, return error response |
| Unexpected exception | Log stack trace, return generic error |
Philosophy: Never crash the server. Always return JSON error response.
- Timeouts: 30 seconds per shellcheck call
- Concurrency: Thread pool allows concurrent requests (GIL-limited but fine for I/O)
- Memory: Script content piped to subprocess stdin; 10MB limit prevents abuse
- JSON load: Entire ShellCheck output loaded into memory (
json.loads). Tested to 10K issues (~2.8MB JSON)
- Input validation (all edge cases)
- Command building (flags, JSON format, severity, check_sourced, enable_all)
- JSON parsing (large output, unicode, malformed, control chars, escape roundtrips)
- Error handling (timeout, not found, invalid JSON)
- Async wrapper (thread pool delegation)
- Real shellcheck on simple scripts
- Real shellcheck on 6,000-line generated scripts
- Full roundtrip: result dict -> json.dumps -> json.loads
- Single-threaded, blocking
- Text output parsing (fragile)
- No input validation
- No logging
- No tests
- Async-compatible via thread pool
- JSON output parsing (robust)
- Comprehensive input validation
- Structured logging
- 22 passing tests
- Decent documentation
oneOfremoved from inputSchema (Anthropic API 400 fix)- ShellCheck flags corrected (
-S->-a,-a->-o all, addedseverity->-S) includeparameter now fully wired fromcall_tool- 35 passing tests (22 original + 13 stress: JSON large output + escape roundtrips)
- shellcheck upgraded from system 0.8.0 to
shellcheck-py0.11.0 - First external contributor (@iav) merged 2 bugfix PRs
- Python 3.10 pinned via
.python-version mcp>=1.0.0,<2pin to avoid SDK v2 breaking change (targets 2026-07-27)
- SDK v2 migration (2026-07-27): stateful to stateless refactor
- Progress feedback: Stream results for large scripts
- Caching: File mtime + content hash eviction