Status: ✅ shipped · daemon transport, preset-packaged
Supertool talks to MCP servers (cclsp, custom LSP wrappers, anything that speaks the
Model Context Protocol) so ops like resolve,
refs, diag, hover, rename, and workspace can return real LSP answers
instead of grep heuristics.
LLMs navigating a codebase with grep and glob patterns are slow and inaccurate. A
language server (intelephense, pyright, typescript-language-server, gopls, etc.) knows
where every symbol is defined, what references it, and what types it has — instantly,
once warm. The problem: an LSP cold-starts in 5–60s on a large repo, and supertool is
a CLI that exits between calls.
The integration:
- A small MCP daemon stays alive between supertool invocations
- The daemon owns one MCP server subprocess (e.g.
cclspwrappingintelephense) - The LSP indexes once, stays warm, answers later calls in milliseconds
- Supertool connects to the daemon over a Unix socket, sends NDJSON JSON-RPC
Result: an LLM agent gets editor-quality navigation with no per-call indexing penalty.
┌─────────────────────┐ UDS ┌──────────────────┐ stdio ┌──────────────┐
│ supertool (per-call)│ ◀─NDJSON──▶│ MCP daemon │ ◀──────▶│ MCP server │
│ MCPClient │ │ (long-lived) │ │ (cclsp, etc.)│
└─────────────────────┘ │ owns subprocess │ │ │
│ idle-timeout 10m │ │ wraps LSP │
└──────────────────┘ └──────────────┘
│ stdio
▼
┌──────────────┐
│ language │
│ server │
│ (intelephense│
│ /pyright/…) │
└──────────────┘
Components:
| Piece | Lives in | Role |
|---|---|---|
MCPClient |
supertool.py |
UDS client; connects to daemon, auto-spawns one on first call |
presets/mcp/daemon.py |
preset | Daemon: owns MCP subprocess + bridges UDS↔stdio |
presets/mcp/status.py |
preset | List running daemons |
presets/mcp/stop.py |
preset | Graceful stop (SIGTERM, SIGKILL fallback) |
presets/mcp.json |
preset | Declares mcp_daemon / mcp_status / mcp_stop / mcp_stop_all ops |
Socket path: /tmp/supertool-mcp-<sha1(cwd+name)[:12]>.sock — per-repo + per-server isolation.
Wire format: newline-delimited JSON-RPC 2.0 — same encoding the official MCP Python SDK speaks over stdio.
Five supertool ops route through MCP when configured. Each maps to a specific MCP tool
via the mcp.<server>.tools block.
| Supertool op | Syntax | What it does | Heuristic fallback |
|---|---|---|---|
resolve |
resolve:SYMBOL:FILE |
Symbol → file path. With LSP: workspace-wide. Without: glob heuristic on PHP FQN / Python dotted / JS relative | ✓ |
refs (via workspace) |
workspace:FILE (References section) |
Find all references to the file's main symbol | ✓ grep |
diag |
diag:FILE |
LSP diagnostics (errors, warnings, hints) for the file | ✗ no LSP → error message |
hover |
hover:SYMBOL:FILE |
Type signature + doc for the symbol. Internally: locate via resolve tool, then hover tool at identifier position |
✗ no LSP → error message |
rename |
rename:OLD:NEW:FILE |
Workspace-atomic rename across all files. cclsp's rename_symbol writes .bak backups |
✗ no LSP → error message |
workspace |
workspace:FILE |
Composite view: file content + Diagnostics + Symbols + Imports + References. Each section uses LSP when its tool is mapped, else heuristic | ✓ per-section |
Two-step ops: hover calls two MCP tools per invocation — first tools.resolve to find the symbol's line:col, then tools.hover at the identifier offset. Both mappings are required for hover.
Hard-fail ops: diag, hover, rename only make sense with a real LSP. Without a matching mcp.<server>.tools.<op> mapping, they return a clear error rather than falling back to grep.
Three minutes from clean repo to working LSP-backed resolve.
npm install -g intelephense cclspcclsp is a generic MCP↔LSP bridge. Free intelephense
covers go-to-def, references, hover, workspace symbols — that's enough.
Create .claude/cclsp.json (cclsp's own config — points it at the LSP):
{
"servers": [
{ "extensions": ["php"], "command": ["intelephense", "--stdio"], "rootDir": "." }
]
}Edit .supertool.json:
{
"presets": ["mcp"],
"mcp": {
"php-lsp": {
"cmd": "cclsp",
"match": "*.{php,class.php}",
"env": { "CCLSP_CONFIG_PATH": ".claude/cclsp.json" },
"tools": {
"resolve": "find_workspace_symbols",
"refs": "find_references",
"diag": "get_diagnostics",
"hover": "get_hover",
"rename": "rename_symbol"
},
"timeout": 60
}
}
}cmd— what the daemon spawns (an MCP server)match— glob; supertool routes ops on matching files through this serverenv— environment for the spawned MCP servertools— maps supertool op names to MCP tool names exposed by the server. Omit any op you don't want to use; that op falls back to the heuristic path (where one exists)timeout— request timeout in seconds (LSP cold-start can be slow; 60s is comfortable)infra_patterns— list of substrings that mark a tool result as an infrastructure condition (timeout/overload) rather than a real diagnostic. Some servers (cclsp) swallow their own internal timeout and hand it back as normal text content (e.g.orchestrator timeout after 3s) with the MCPisErrorflag unset — without this,diagwould count that text as a phantom+1diagnostic that reads like the edit caused a regression (#346). Matched results are returned prefixedop: …so adapters drop them. Defaults to["orchestrator timeout", "timed out after"]; the structuralisErrorflag is always honored regardlessstopOnNewFile—trueto SIGTERM this daemon when a mutating op (edit/paste/etc.) creates a brand-new file matchingmatch. The warm LSP holds a reflection cache that doesn't index new classes, so it reports phantom errors on a just-created file (#239); stopping it forces the next validator run to cold-start a daemon that sees the file. Cost: that one post-create validate pays the cold-reindex (~30-60s on a large repo). Leave unset for servers that index new files fine.
$ ./supertool 'resolve:My\Namespace\TargetClass:src/Caller.php'
My\Namespace\TargetClass → /abs/path/src/My/Namespace/TargetClass.phpFirst call spawns the daemon detached, waits for the socket, then the LSP indexes (cold, 5–60s on big repos). Subsequent calls hit a warm daemon: <1s.
| Op | Effect |
|---|---|
mcp_daemon:NAME |
Start daemon for NAME (blocking; append --detach to background) |
mcp_status |
List running daemons: name, hash, pid, status, uptime, idle, socket |
mcp_stop:NAME |
Graceful stop (SIGTERM, SIGKILL after 3s) |
mcp_stop_all |
Stop every supertool MCP daemon |
The daemon shuts down automatically after idle_timeout (default 600s = 10 minutes).
Configure per-server via "idle_timeout": N in the mcp block.
When MCPClient can't connect, it spawns the daemon detached via:
subprocess.Popen([sys.executable, daemon.py, NAME, "--detach"], start_new_session=True)and retries the connect for ~7.5s before giving up. For ops with a heuristic fallback
(resolve, refs, workspace), giveup is silent — supertool runs the heuristic and
the call succeeds (slower, less accurate). For LSP-only ops (diag, hover, rename),
giveup surfaces a clear error message so the caller knows the LSP is the bottleneck.
Whether the server is an LSP via cclsp, a custom MCP wrapper, or any third-party MCP binary, the wiring is the same.
-
Get the MCP server runnable — install it, verify
<binary> --help(or whatever the server's spawn invocation is) works on its own. For cclsp+LSP, this means installing both cclsp and the language server (intelephense, pylsp, etc.) and declaring the LSP in.claude/cclsp.json. -
Add an
mcpentry to.supertool.json— pick a name, pointcmdat the MCP server binary, setmatchto the file glob, declareenvif the server needs it, map supertool ops to MCP tool names intools:"mcp": { "<name>": { "cmd": "<mcp-server-binary> [args]", "match": "*.<ext>", "env": { ... }, "tools": { "resolve": "<MCP tool for symbol→file>", "refs": "<MCP tool for find references>", "diag": "<MCP tool for file diagnostics>", "hover": "<MCP tool for symbol hover (position-based)>", "rename": "<MCP tool for workspace rename>" }, "timeout": 60 } }
Keys explained:
cmd— whatsubprocess.Popencalls (shlex-split if string). The daemon owns this process.match— fnmatch glob; supertool routes ops on matchingfrom_filepaths through this server. Brace expansion (*.{php,class.php}) supported.env— extra env vars passed to the spawned MCP server. Merged ontoos.environ.tools— maps supertool op (resolve/refs/etc.) to the MCPtoolname the server exposes viatools/list. Without this, the op falls through to the heuristic path.timeout— request timeout in seconds.infra_patterns(optional) — substrings that mark a result as an infra condition (timeout/overload) not a real diagnostic; matched results are prefixedop: …so adapters drop them. Defaults to["orchestrator timeout", "timed out after"]. The structural MCPisErrorflag is always honored regardless (#346).idle_timeout(optional) — daemon shuts itself down after this many seconds idle (default 600).stopOnNewFile(optional) —trueto SIGTERM this daemon when a mutating op creates a brand-new file matchingmatch, so the next validator run cold-starts a daemon that has indexed it. Fixes phantom errors on new classes from the warm reflection cache (#239), at the cost of one cold reindex on that post-create run.
-
Discover the tool names — first time wiring a new MCP server, you don't know what tool names it exposes. Two quick options:
# Run the server directly + ask via the Python SDK (one-off probe) pip install mcp python3 -c " import asyncio, os from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def m(): p = StdioServerParameters(command='<binary>', args=[], env={**os.environ}) async with stdio_client(p) as (r, w): async with ClientSession(r, w) as s: await s.initialize() t = await s.list_tools() for tool in t.tools: print(tool.name, '—', tool.description[:80]) asyncio.run(m())"
Or — once the server is wired into
.supertool.jsonand the daemon is running — read the livetools/listvia the daemon's socket using the same SDK againstsocket_path. -
Run —
./supertool 'resolve:<SYMBOL>:<FILE>'. First call spawns the daemon detached; LSP indexes (cold start ~30s on big repos), then warm.mcp_statusconfirms the daemon's running.
pip install "python-lsp-server[all]"// .claude/cclsp.json — add to the servers array
{ "extensions": ["py", "pyi"], "command": ["pylsp"], "rootDir": "." }// .supertool.json mcp block
"python-lsp": {
"cmd": "cclsp",
"match": "*.{py,pyi}",
"env": { "CCLSP_CONFIG_PATH": ".claude/cclsp.json" },
"tools": {
"resolve": "find_workspace_symbols",
"refs": "find_references",
"diag": "get_diagnostics",
"hover": "get_hover",
"rename": "rename_symbol"
},
"timeout": 60
}npm install -g typescript typescript-language-server// .claude/cclsp.json
{ "extensions": ["ts", "tsx", "js", "jsx"],
"command": ["typescript-language-server", "--stdio"], "rootDir": "." }// .supertool.json mcp block
"ts-lsp": {
"cmd": "cclsp",
"match": "*.{ts,tsx,js,jsx}",
"env": { "CCLSP_CONFIG_PATH": ".claude/cclsp.json" },
"tools": {
"resolve": "find_workspace_symbols",
"refs": "find_references",
"diag": "get_diagnostics",
"hover": "get_hover",
"rename": "rename_symbol"
},
"timeout": 60
}If you have your own MCP server binary that exposes domain-specific tools (e.g. a
GraphQL schema walker, a custom code analyzer), point cmd straight at it:
"my-tool": {
"cmd": "/usr/local/bin/my-mcp-tool --stdio",
"match": "*.graphql",
"tools": { "resolve": "schema_lookup", "refs": "schema_references" },
"timeout": 30
}Same daemon owns it, same UDS protocol, same auto-spawn behavior.
- Tool name mismatch — if
tools.resolvepoints at a name the server doesn't expose, MCP returns an error → supertool catches it → falls through to heuristic. Silently slower, not wrong. Confirm names via the probe in step 3. - Tool semantic mismatch — names can match but behavior differs. cclsp's
find_definitionscans the given file for the symbol;find_workspace_symbolssearches the whole index. Use whichever fits the op's intent. For supertool'sresolve(FQN→file),find_workspace_symbolsis the right pick. - Slow first call — LSPs cold-index on first spawn. Free intelephense has no persistent disk index, so the daemon's warm-time-after-first-call is your savings. Don't kill the daemon between calls unless you want to pay the cold start again.
cclsp exposes these tools (full list via cclsp + tools/list):
| Tool | Args | Notes |
|---|---|---|
find_definition |
symbol_name, file_path |
Scans the file's symbols for the name — not a workspace-wide FQN search |
find_workspace_symbols |
query |
Workspace-wide name search via LSP workspace/symbol. Best fit for FQN→file resolution |
find_references |
symbol_name, file_path, include_declaration? |
LSP textDocument/references |
rename_symbol |
symbol_name, file_path, new_name, dry_run? |
Workspace rename |
get_diagnostics |
file_path |
Per-file LSP diagnostics |
get_hover |
symbol_name, file_path |
LSP hover info |
restart_server |
— | Restart the LSP backend |
Supertool's MCP call sends {symbol_name, file_path, query} together so the same call
works whether the configured tool needs symbol_name/file_path (cclsp find_definition,
find_references) or query (cclsp find_workspace_symbols). Tools ignore unknown args.
| Symptom | Cause | Fix |
|---|---|---|
not found instead of an LSP answer |
Daemon didn't start or LSP cold-start exceeded timeout | Increase timeout to 120; check mcp_status; check /tmp/supertool-mcp-<hash>.sock.stderr for cclsp errors |
| Daemon dies repeatedly | LSP server crashed | Check /tmp/supertool-mcp-<hash>.sock.stderr (cclsp + LSP stderr is captured here) |
resolve returns cclsp's "No symbols found in " text |
Using find_definition (file-scoped) instead of find_workspace_symbols |
Switch the tool mapping for resolve |
| First call slow (~30s+) | Intelephense cold-indexing the repo | Expected; subsequent calls hit a warm daemon |
AF_UNIX path too long in tests |
macOS UDS path limit (~104 chars) | Use /tmp/ paths, not pytest tmp_path |
- Wire format: NDJSON (
{json}\nper message). Same as MCP SDK over stdio. Don't use LSP-styleContent-Lengthframing — MCP doesn't use it. - Concurrency: daemon serves one client at a time. New client = new bridge. Cclsp itself is single-threaded behind one stdio pair, so multi-client multiplexing would need request-ID routing in the daemon (not done; not needed for current usage).
- Process lifecycle: daemon uses
start_new_session=True(not a manual double-fork) so it survives the spawning shell exiting. - Tests:
tests/fixtures/mock_mcp_server.pyis a UDS NDJSON mock used bytests/test_mcp_{client,routing,workspace}.py. Each test gets its own socket in/tmp/st-mock-<uuid>.sock.