Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions presets/mcp/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
python3 daemon.py SERVER_NAME --detach # double-fork detach

Reads .supertool.json from cwd, looks up mcp[SERVER_NAME] = {cmd, env, timeout, ...}.
Socket path: /tmp/supertool-mcp-<sha1(cwd+name)[:12]>.sock
Pid file: /tmp/supertool-mcp-<sha1(cwd+name)[:12]>.pid
Socket/pid paths: per-user runtime dir (#148), via _paths.socket_pid_paths —
hashed sha1(cwd+name)[:12], NOT /tmp.
"""
from __future__ import annotations

Expand Down
3 changes: 2 additions & 1 deletion presets/mcp/status.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""List running supertool MCP daemons.

Inspects /tmp/supertool-mcp-*.{sock,pid} pairs and reports name, pid, uptime, and last
Inspects supertool-mcp-*.{sock,pid} pairs in the per-user runtime dir (#148) and
reports name, pid, uptime, and last
activity. Discovers names by reading .supertool.json mcp block + hashing cwd+name to
match the socket; otherwise prints the hash-only entry for orphans.
"""
Expand Down
33 changes: 29 additions & 4 deletions supertool.py
Original file line number Diff line number Diff line change
Expand Up @@ -11131,9 +11131,34 @@ def _mcp_route(path: str, op: str) -> Optional[Tuple[str, str]]:
return None


_MCP_DAEMON_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "presets", "mcp", "daemon.py")
_MCP_DAEMON_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "presets", "mcp", "daemon.py")
_MCP_STOP_SCRIPT = os.path.join(os.path.dirname(_MCP_DAEMON_SCRIPT), "stop.py")

# #148: socket/pid paths live under the per-user runtime dir, NOT /tmp. The
# daemon/status/stop helpers all compute them via _paths.socket_pid_paths — the
# client MUST use the same helper or it polls a path the daemon never binds.
_MCP_SOCKET_PID_PATHS_FN = None


def _mcp_socket_pid_paths(cwd: str, name: str) -> Tuple[str, str]:
"""Compute (sock_path, pid_path) via presets/mcp/_paths.py — the single source
of truth the daemon binds with (#148).

Loaded lazily by absolute file path under a unique module name: avoids
prepending presets/mcp to the process-wide sys.path (where the generic name
`_paths` could shadow other imports), and a missing/broken _paths.py only
fails MCP ops instead of crashing the whole tool at import time.
"""
global _MCP_SOCKET_PID_PATHS_FN
if _MCP_SOCKET_PID_PATHS_FN is None:
import importlib.util
paths_file = os.path.join(os.path.dirname(_MCP_DAEMON_SCRIPT), "_paths.py")
spec = importlib.util.spec_from_file_location("_supertool_mcp_paths", paths_file)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_MCP_SOCKET_PID_PATHS_FN = mod.socket_pid_paths
return _MCP_SOCKET_PID_PATHS_FN(cwd, name)


def _mcp_stop_server(name: str) -> None:
"""Best-effort SIGTERM the warm daemon for `name` via stop.py.
Expand Down Expand Up @@ -11181,7 +11206,8 @@ class MCPClient:
Wire format: each JSON-RPC message is a single line terminated by `\n` (NDJSON).
Matches what the official MCP Python SDK speaks over stdio.

socket_path: optional override. Default = /tmp/supertool-mcp-<sha1(cwd+name)[:12]>.sock.
socket_path: optional override. Default = _paths.socket_pid_paths(cwd, name)[0]
(per-user runtime dir, #148) — the same helper the daemon binds with.
Tests pass an explicit path to talk to a pre-spawned mock server.
"""

Expand All @@ -11199,8 +11225,7 @@ def __init__(self, name: str, timeout: int = 30, socket_path: Optional[str] = No
self._auto_spawn = False
else:
cwd = os.path.abspath(os.getcwd())
h = hashlib.sha1(f"{cwd}::{name}".encode()).hexdigest()[:12]
self._sock_path = f"/tmp/supertool-mcp-{h}.sock"
self._sock_path, _ = _mcp_socket_pid_paths(cwd, name)
self._auto_spawn = True

# Auto-spawn connect-retry budget. Cold-starting cclsp+intelephense on a
Expand Down
50 changes: 50 additions & 0 deletions tests/test_security_mcp_daemon_148.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import os
import stat
import subprocess
import sys
from pathlib import Path

Expand Down Expand Up @@ -117,6 +118,55 @@ def test_invalid_names_rejected(self, name):
mcp_daemon._validate_name(name)


class TestClientDaemonPathAgreement:
"""#148 follow-up: the CLIENT must look for the socket where the daemon binds it.

The daemon, status, and stop helpers were all migrated to _paths.socket_pid_paths
(runtime dir). MCPClient was not — it hardcoded /tmp/, so it polled a path the
daemon never bound, timed out, and reported the server 'unavailable' on every call.
"""

def test_client_sock_path_matches_paths_helper(self, tmp_runtime, monkeypatch):
import supertool
cwd = os.path.abspath(os.getcwd())
expected_sock, _ = _paths.socket_pid_paths(cwd, "lsp")
client = supertool.MCPClient("lsp")
assert client._sock_path == expected_sock, (
"client/daemon socket path mismatch: client polls "
f"{client._sock_path}, daemon binds {expected_sock}"
)

def test_client_sock_not_in_bare_tmp(self, monkeypatch):
import supertool
monkeypatch.delenv("SUPERTOOL_RUNTIME_DIR", raising=False)
monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
client = supertool.MCPClient("lsp")
assert not client._sock_path.startswith("/tmp/supertool-mcp-"), \
f"#148 regression: client socket back in bare /tmp/: {client._sock_path}"


class TestSymlinkInvocation:
"""supertool is installed as a symlink (e.g. dvsi/supertool -> claude-supertool/supertool.py).

Package-relative paths (_MCP_DAEMON_SCRIPT, the _paths import) must resolve from the
REAL file location, not the symlink's dir — else `from _paths import ...` crashes the
whole tool on import, and the daemon script path points at a nonexistent file so the
warm daemon never spawns. abspath(__file__) doesn't follow symlinks; realpath does.
"""

def test_runs_clean_through_symlink(self, tmp_path):
real = Path(__file__).parent.parent / "supertool.py"
link = tmp_path / "supertool"
link.symlink_to(real)
r = subprocess.run(
[sys.executable, str(link), "version"],
capture_output=True, text=True, cwd=str(tmp_path), timeout=30,
)
assert r.returncode == 0, f"symlinked invocation crashed:\n{r.stderr}"
assert "ModuleNotFoundError" not in r.stderr, \
f"package-relative import failed under symlink:\n{r.stderr}"


class TestListPidfiles:
def test_empty_when_no_daemons(self, tmp_runtime):
assert _paths.list_pidfiles() == []
Expand Down
Loading