mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
An agent editing a web app needs the dev server's own view of it: which routes exist, and whether the last edit broke the build. Next 16 serves that over MCP at /_next/mcp, and Vite-based frameworks can with vite-plugin-mcp, but nothing here found those endpoints. index finds Next, Vite, Nuxt, Remix, React Router, Astro and SvelteKit dev servers from each process's argv, cwd and listening ports, and lists the tools of the MCP each serves. call runs one. errors returns current build and runtime errors: Next's get_errors, after opening the app in the browser tool, since Next reports only what a connected session reported; otherwise Vite's error overlay, console errors and page errors, read through the same browser tool. discover() returns the servers as plain records, the shape a devserver node will take. hanzo-mcp depends on the package so the tool ships. Claude-Session: https://claude.ai/code/session_01Dc8DXc3fEQCRHUa6XWK4Ku
223 lines
7.9 KiB
Python
223 lines
7.9 KiB
Python
"""Dev servers running on this machine, as plain records.
|
|
|
|
``discover()`` is the seam: each ``DevServer`` is one addressable node — a
|
|
framework's dev server and, where it serves one, its MCP endpoint and tools.
|
|
Identification reads three facts per process: its argv (which binary runs),
|
|
its cwd (the nearest package.json names the framework), and its listening
|
|
ports. Each port is then probed at the framework's MCP path.
|
|
"""
|
|
|
|
import json
|
|
import asyncio
|
|
from pathlib import Path
|
|
from dataclasses import field, dataclass
|
|
|
|
import httpx
|
|
import psutil
|
|
|
|
from hanzo_tools.mcp_tools.mcp_proxy import MCPServerConfig, MCPServerConnection
|
|
|
|
# The npm package that names each framework, most specific first: all but Next
|
|
# run on Vite, so a bare `vite` dependency is the last word, not the first.
|
|
FRAMEWORKS = (
|
|
("next", "next"),
|
|
("nuxt", "nuxt"),
|
|
("react-router", "@react-router/dev"),
|
|
("remix", "@remix-run/dev"),
|
|
("astro", "astro"),
|
|
("sveltekit", "@sveltejs/kit"),
|
|
("vite", "vite"),
|
|
)
|
|
|
|
# Binary a dev server runs as -> framework. Next renames its server process
|
|
# to "next-server (vX.Y.Z)"; the rest are the basename of a node_modules bin.
|
|
BINARIES = {
|
|
"next": "next",
|
|
"next-server": "next",
|
|
"vite": "vite",
|
|
"nuxi": "nuxt",
|
|
"nuxt": "nuxt",
|
|
"remix": "remix",
|
|
"react-router": "react-router",
|
|
"astro": "astro",
|
|
}
|
|
|
|
# Next 16 serves streamable HTTP MCP at /_next/mcp by default. The Vite family
|
|
# has none built in; vite-plugin-mcp (and nuxt-mcp-dev, which wraps it) serves
|
|
# SSE at /__mcp/sse.
|
|
NEXT_MCP = "/_next/mcp"
|
|
VITE_MCP = "/__mcp/sse"
|
|
|
|
PROBE_TIMEOUT = 1.0
|
|
|
|
# The scheme each port last answered on, tried first on the next probe.
|
|
_schemes: dict[int, str] = {}
|
|
|
|
|
|
@dataclass
|
|
class DevServer:
|
|
"""One dev server: where it listens, what it is, and the MCP it serves."""
|
|
|
|
port: int
|
|
url: str = ""
|
|
pid: int | None = None
|
|
framework: str | None = None
|
|
version: str | None = None
|
|
root: str | None = None
|
|
command: str | None = None
|
|
mcp: str | None = None
|
|
tools: list[dict] = field(default_factory=list)
|
|
error: str | None = None
|
|
|
|
|
|
def binary(argv: list[str]) -> str | None:
|
|
"""The framework whose dev binary this argv runs, or None."""
|
|
for arg in argv[:3]:
|
|
name = arg.replace("\\", "/")
|
|
if name.startswith("next-server"):
|
|
return "next"
|
|
base = name.rsplit("/", 1)[-1].split(".")[0]
|
|
if base in BINARIES:
|
|
return BINARIES[base]
|
|
return None
|
|
|
|
|
|
def installed(root: Path, package: str) -> str | None:
|
|
"""The version of `package` that node resolves from `root`."""
|
|
for d in (root, *root.parents):
|
|
try:
|
|
return json.loads((d / "node_modules" / package / "package.json").read_text())["version"]
|
|
except (OSError, ValueError, KeyError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def identify(cwd: str) -> tuple[str, str, str] | None:
|
|
"""(framework, version, root) from the nearest package.json that depends on one."""
|
|
start = Path(cwd)
|
|
for d in (start, *start.parents):
|
|
try:
|
|
manifest = json.loads((d / "package.json").read_text())
|
|
except (OSError, ValueError):
|
|
continue
|
|
deps = {**manifest.get("devDependencies", {}), **manifest.get("dependencies", {})}
|
|
for framework, package in FRAMEWORKS:
|
|
if package in deps:
|
|
return framework, installed(d, package) or deps[package], str(d)
|
|
return None
|
|
|
|
|
|
def scan() -> list[DevServer]:
|
|
"""Listening processes that run a dev-server binary, one record per port."""
|
|
found = []
|
|
for proc in psutil.process_iter(["pid", "cmdline", "cwd"]):
|
|
argv = proc.info["cmdline"] or []
|
|
framework = binary(argv)
|
|
if framework is None:
|
|
continue
|
|
try:
|
|
ports = sorted({c.laddr.port for c in proc.net_connections("tcp") if c.status == psutil.CONN_LISTEN})
|
|
except psutil.Error:
|
|
continue
|
|
version = root = None
|
|
project = identify(proc.info["cwd"]) if proc.info["cwd"] else None
|
|
if project:
|
|
framework, version, root = project
|
|
command = " ".join(argv).strip()
|
|
for port in ports:
|
|
found.append(
|
|
DevServer(
|
|
port=port,
|
|
url=f"http://localhost:{port}",
|
|
pid=proc.pid,
|
|
framework=framework,
|
|
version=version,
|
|
root=root,
|
|
command=command,
|
|
)
|
|
)
|
|
return found
|
|
|
|
|
|
async def answers(url: str) -> bool:
|
|
"""Whether `url` speaks MCP. SSE answers a GET with an event stream;
|
|
streamable HTTP answers a POSTed tools/list with JSON or an event stream,
|
|
even a stateful server's 400 for the missing session."""
|
|
# A probe only ever addresses localhost, where a dev server's certificate is self-signed.
|
|
async with httpx.AsyncClient(verify=False, timeout=PROBE_TIMEOUT) as client: # noqa: S501
|
|
if url.endswith("/sse"):
|
|
request = client.build_request("GET", url, headers={"Accept": "text/event-stream"})
|
|
else:
|
|
request = client.build_request(
|
|
"POST",
|
|
url,
|
|
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
|
headers={"Accept": "application/json, text/event-stream"},
|
|
)
|
|
try:
|
|
response = await client.send(request, stream=True)
|
|
except httpx.HTTPError:
|
|
return False
|
|
await response.aclose()
|
|
kind = response.headers.get("content-type", "")
|
|
return response.status_code != 404 and kind.startswith(("text/event-stream", "application/json"))
|
|
|
|
|
|
async def probe(port: int, path: str) -> str | None:
|
|
"""The MCP endpoint at localhost:`port``path`, over http or https, or None."""
|
|
first = _schemes.get(port, "http")
|
|
for scheme in (first, "https" if first == "http" else "http"):
|
|
url = f"{scheme}://localhost:{port}{path}"
|
|
if await answers(url):
|
|
_schemes[port] = scheme
|
|
return url
|
|
return None
|
|
|
|
|
|
def mcp_paths(framework: str | None) -> tuple[str, ...]:
|
|
"""Where a framework's dev server would serve MCP; both when it is unknown."""
|
|
if framework == "next":
|
|
return (NEXT_MCP,)
|
|
if framework is None:
|
|
return (NEXT_MCP, VITE_MCP)
|
|
return (VITE_MCP,)
|
|
|
|
|
|
async def attach(server: DevServer) -> None:
|
|
"""Find the server's MCP endpoint and list its tools."""
|
|
for path in mcp_paths(server.framework):
|
|
url = await probe(server.port, path)
|
|
if url is None:
|
|
continue
|
|
server.url = url.removesuffix(path)
|
|
server.mcp = url
|
|
connection = MCPServerConnection(MCPServerConfig(name=f"{server.framework}:{server.port}", url=url))
|
|
if await connection.connect():
|
|
server.tools = [
|
|
{"name": t.name, "description": t.description, "input_schema": t.input_schema} for t in connection.tools
|
|
]
|
|
else:
|
|
server.error = connection.error
|
|
return
|
|
|
|
|
|
def within(root: str | None, cwd: str) -> bool:
|
|
"""Whether a project root and a working directory contain one another."""
|
|
if root is None:
|
|
return False
|
|
a, b = Path(root).resolve(), Path(cwd).expanduser().resolve()
|
|
return a.is_relative_to(b) or b.is_relative_to(a)
|
|
|
|
|
|
async def discover(port: int | None = None, cwd: str | None = None) -> list[DevServer]:
|
|
"""Dev servers on this machine, narrowed to `port` and to projects around `cwd`,
|
|
each probed for MCP. A `port` no process scan claims is probed as given and
|
|
kept when an MCP endpoint answers there."""
|
|
servers = [s for s in scan() if (port is None or s.port == port) and (cwd is None or within(s.root, cwd))]
|
|
if port is not None and not servers:
|
|
bare = DevServer(port=port, url=f"http://localhost:{port}")
|
|
await attach(bare)
|
|
return [bare] if bare.mcp else []
|
|
await asyncio.gather(*(attach(s) for s in servers))
|
|
return servers
|