Skip to content

Latest commit

 

History

History
381 lines (308 loc) · 17.3 KB

File metadata and controls

381 lines (308 loc) · 17.3 KB

MCP Integration

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.

Why it exists

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. cclsp wrapping intelephense)
  • 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.

Architecture

┌─────────────────────┐    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.

LSP-backed ops

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.

Quickstart — PHP via Intelephense

Three minutes from clean repo to working LSP-backed resolve.

1. Install LSP + bridge

npm install -g intelephense cclsp

cclsp is a generic MCP↔LSP bridge. Free intelephense covers go-to-def, references, hover, workspace symbols — that's enough.

2. Configure the bridge

Create .claude/cclsp.json (cclsp's own config — points it at the LSP):

{
  "servers": [
    { "extensions": ["php"], "command": ["intelephense", "--stdio"], "rootDir": "." }
  ]
}

3. Wire supertool

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 server
  • env — environment for the spawned MCP server
  • tools — 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 MCP isError flag unset — without this, diag would count that text as a phantom +1 diagnostic that reads like the edit caused a regression (#346). Matched results are returned prefixed op: … so adapters drop them. Defaults to ["orchestrator timeout", "timed out after"]; the structural isError flag is always honored regardless
  • stopOnNewFiletrue to SIGTERM this daemon when a mutating op (edit/paste/etc.) creates a brand-new file matching match. 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.

4. Use it

$ ./supertool 'resolve:My\Namespace\TargetClass:src/Caller.php'
My\Namespace\TargetClass → /abs/path/src/My/Namespace/TargetClass.php

First 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.

Daemon lifecycle

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.

Adding a new MCP server

Whether the server is an LSP via cclsp, a custom MCP wrapper, or any third-party MCP binary, the wiring is the same.

Recipe

  1. 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.

  2. Add an mcp entry to .supertool.json — pick a name, point cmd at the MCP server binary, set match to the file glob, declare env if the server needs it, map supertool ops to MCP tool names in tools:

    "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 — what subprocess.Popen calls (shlex-split if string). The daemon owns this process.
    • match — fnmatch glob; supertool routes ops on matching from_file paths through this server. Brace expansion (*.{php,class.php}) supported.
    • env — extra env vars passed to the spawned MCP server. Merged onto os.environ.
    • tools — maps supertool op (resolve/refs/etc.) to the MCP tool name the server exposes via tools/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 prefixed op: … so adapters drop them. Defaults to ["orchestrator timeout", "timed out after"]. The structural MCP isError flag is always honored regardless (#346).
    • idle_timeout (optional) — daemon shuts itself down after this many seconds idle (default 600).
    • stopOnNewFile (optional) — true to SIGTERM this daemon when a mutating op creates a brand-new file matching match, 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.
  3. 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.json and the daemon is running — read the live tools/list via the daemon's socket using the same SDK against socket_path.

  4. Run./supertool 'resolve:<SYMBOL>:<FILE>'. First call spawns the daemon detached; LSP indexes (cold start ~30s on big repos), then warm. mcp_status confirms the daemon's running.

Examples

Python (pylsp)

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
}

TypeScript

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
}

Custom MCP server (no LSP)

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.

Pitfalls

  • Tool name mismatch — if tools.resolve points 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_definition scans the given file for the symbol; find_workspace_symbols searches the whole index. Use whichever fits the op's intent. For supertool's resolve (FQN→file), find_workspace_symbols is 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.

Tool name reference (cclsp)

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.

Troubleshooting

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

Implementation notes

  • Wire format: NDJSON ({json}\n per message). Same as MCP SDK over stdio. Don't use LSP-style Content-Length framing — 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.py is a UDS NDJSON mock used by tests/test_mcp_{client,routing,workspace}.py. Each test gets its own socket in /tmp/st-mock-<uuid>.sock.