mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-26 11:49:36 +00:00
devserver: running dev servers as MCP nodes
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
This commit is contained in:
@@ -357,6 +357,34 @@ is not another identity's secret and which no catalog replaces.
|
||||
the retired alias mattered: a caller typing the old name was mapped off the live
|
||||
surface rather than onto it.
|
||||
|
||||
## devserver — running dev servers as MCP nodes
|
||||
|
||||
`pkg/hanzo-tools-devserver` registers `devserver` (`index`, `call`, `errors`, `help`).
|
||||
`hanzo_tools.devserver.discover()` is the seam: plain `DevServer` records (port, url,
|
||||
pid, framework, version, root, command, mcp, tools), one per listening port — the
|
||||
shape a ZAP node of kind devserver takes.
|
||||
|
||||
- **Identification**: argv basename (Next renames its server `next-server (vX)`), the
|
||||
nearest package.json naming a framework (most specific first, `vite` last), and
|
||||
psutil's listening ports for the process.
|
||||
- **MCP**: Next 16 serves streamable HTTP at `/_next/mcp`; the Vite family serves SSE at
|
||||
`/__mcp/sse` only with `vite-plugin-mcp` / `nuxt-mcp-dev`. A probe tries http, then
|
||||
https, and caches the scheme that answered per port.
|
||||
- **One client**: `mcp_proxy.MCPServerConfig(url=…)` gives `MCPServerConnection` an
|
||||
HTTP/SSE transport through the SDK (`http_session`); a loopback URL skips TLS
|
||||
verification.
|
||||
- **Next's `get_errors` answers for connected browser sessions only** (measured on
|
||||
16.3.7). At `load` the session is connected but has not reported the build error yet;
|
||||
at `networkidle` it has. So `errors` opens the app through the `browser` tool with
|
||||
`state=networkidle` before asking.
|
||||
- **Vite writes a transform error to its overlay, not the console.** `errors` reads the
|
||||
`vite-error-overlay` shadow root. A plain `.js` syntax error is no transform error in
|
||||
Vite 8; it reaches the browser and surfaces as a page error.
|
||||
- The browser keeps console messages and page errors across page loads, so `errors`
|
||||
counts them before navigating and returns only the new ones.
|
||||
- Tests run MCP servers in a subprocess: the SDK's streamable HTTP server leaves memory
|
||||
streams unclosed, and in-process the warnings-as-errors config blames the client.
|
||||
|
||||
## hanzo-kai — the Kai decisions client
|
||||
|
||||
`pkg/hanzo-kai` (`import hanzo_kai`) is the Python client for Kai: `POST /v1/decisions`
|
||||
@@ -397,8 +425,9 @@ against api.hanzo.ai and prints pass or fail per rule. Release: tag `hanzo-kai-v
|
||||
nothing else.
|
||||
- `pkg/hanzo/src/hanzo/cli.py` — the `hanzo` CLI command tree.
|
||||
- `pkg/hanzo-mcp/` — MCP server; tools via `[project.entry-points."hanzo.tools"]`.
|
||||
- `pkg/hanzo-tools-*/` — one concern each; 37 of the 38 register a `TOOLS` list under the
|
||||
`hanzo.tools` entry point (`hanzo-tools-core` is the shared base, so it registers none).
|
||||
- `pkg/hanzo-tools-*/` — one concern each; 30 of the 39 register a `TOOLS` list under the
|
||||
`hanzo.tools` entry point (`hanzo-tools-core` is the shared base, and the eight service
|
||||
packages withdrawn above register none).
|
||||
- `pkg/hanzo-{agents,agent,network,memory}/` — agent/compute/memory libraries.
|
||||
- `pkg/hanzo-kms/` — KMS client. The server is **luxfi/kms** (`kms.hanzo.ai`,
|
||||
`kms.lux.cloud`) and its whole surface is `/v1/kms/auth/login` plus
|
||||
|
||||
@@ -59,6 +59,7 @@ dependencies = [
|
||||
"hanzo-tools-auth>=0.1.0",
|
||||
"hanzo-tools-mpc>=0.1.0",
|
||||
"hanzo-tools-ui>=0.1.0",
|
||||
"hanzo-tools-devserver>=0.1.0", # running dev servers as MCP nodes: index, call, errors
|
||||
"hanzo-persona>=1.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# hanzo-tools-devserver
|
||||
|
||||
The `devserver` tool for hanzo-mcp: running dev servers as MCP nodes.
|
||||
|
||||
- `index` — find dev servers on this machine (Next, Vite, Nuxt, Remix, React Router,
|
||||
Astro, SvelteKit) from each process's argv, cwd and listening ports, and list the
|
||||
tools of the MCP each one serves (Next 16+ at `/_next/mcp`; Vite-based frameworks at
|
||||
`/__mcp/sse` with `vite-plugin-mcp` or `nuxt-mcp-dev`).
|
||||
- `call` — run one of those tools.
|
||||
- `errors` — current build/runtime errors: the server's `get_errors` where it has one,
|
||||
otherwise Vite's error overlay, console errors and page errors, read through the
|
||||
`browser` tool.
|
||||
|
||||
`hanzo_tools.devserver.discover()` returns the servers as plain `DevServer` records.
|
||||
|
||||
```bash
|
||||
pip install hanzo-tools-devserver
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
# Namespace package
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Dev server tools for Hanzo AI.
|
||||
|
||||
Tools:
|
||||
- devserver: find running dev servers (Next, Vite, Nuxt, Remix, React Router,
|
||||
Astro, SvelteKit), call their built-in MCP tools, read current errors.
|
||||
|
||||
``discover()`` returns the servers as plain ``DevServer`` records.
|
||||
"""
|
||||
|
||||
from .discovery import DevServer, discover
|
||||
from .devserver_tool import DevserverTool
|
||||
|
||||
TOOLS = [DevserverTool]
|
||||
|
||||
__all__ = ["TOOLS", "DevserverTool", "DevServer", "discover"]
|
||||
@@ -0,0 +1,234 @@
|
||||
"""The `devserver` tool: find running dev servers, call their MCP tools,
|
||||
and read their current build and runtime errors."""
|
||||
|
||||
import re
|
||||
import json
|
||||
from typing import Any, ClassVar
|
||||
from dataclasses import asdict
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool, ToolError, NotFoundError, InvalidParamsError
|
||||
from hanzo_tools.mcp_tools.mcp_proxy import MCPServerConfig, MCPServerConnection
|
||||
|
||||
from .discovery import DevServer, discover
|
||||
|
||||
# Reads Vite's error overlay, which every Vite-based framework shows for a
|
||||
# failed transform. With the overlay on, Vite writes the error nowhere else the
|
||||
# page can see — not the console — so the overlay is the source. It appears
|
||||
# once the HMR socket delivers the error, so poll for up to two seconds.
|
||||
OVERLAY = """(async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const root = document.querySelector('vite-error-overlay')?.shadowRoot;
|
||||
if (root) {
|
||||
const text = s => root.querySelector(s)?.textContent.trim() || undefined;
|
||||
return {plugin: text('.plugin'), message: text('.message'), file: text('.file'), frame: text('.frame')};
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
return null;
|
||||
})()"""
|
||||
|
||||
# Terminal colour codes dev servers leave in the errors they report, raw or
|
||||
# JSON-escaped; removing a whole escaped code leaves the JSON valid.
|
||||
ANSI = re.compile(r"(?:\x1b|\\u001[bB])\[[0-9;]*m")
|
||||
|
||||
PORT = {"type": "integer", "description": "Dev server port; required when more than one runs"}
|
||||
CWD = {"type": "string", "description": "Project directory; narrows to dev servers of projects around it"}
|
||||
|
||||
HELP = {
|
||||
"actions": {
|
||||
"index": "Dev servers on this machine: framework, version, url, pid, MCP endpoint and its tools. port= / cwd= narrow it.",
|
||||
"call": "Run a server's MCP tool: tool=<name from index> args={...}; port= only when two servers list it.",
|
||||
"errors": "Current build/runtime errors. Opens the app (path=, default /) in the browser tool, then asks the "
|
||||
"server's MCP get_errors, or reads Vite's error overlay, console errors and page errors.",
|
||||
},
|
||||
"workflow": "index -> call tool=<listed> -> edit code -> errors",
|
||||
"mcp": {
|
||||
"next": "16+ serves /_next/mcp by default",
|
||||
"vite, nuxt, remix, react-router, astro, sveltekit": "add vite-plugin-mcp (nuxt-mcp-dev for Nuxt) to serve /__mcp/sse",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def payload(result: dict) -> Any:
|
||||
"""What an MCP tool result says: structured content, else its text blocks,
|
||||
uncoloured and decoded when they are JSON. Other blocks pass through."""
|
||||
if "structuredContent" in result:
|
||||
return result["structuredContent"]
|
||||
out = []
|
||||
for block in result.get("content", []):
|
||||
if block.get("type") != "text":
|
||||
out.append(block)
|
||||
continue
|
||||
text = ANSI.sub("", block["text"])
|
||||
try:
|
||||
out.append(json.loads(text))
|
||||
except ValueError:
|
||||
out.append(text)
|
||||
return out[0] if len(out) == 1 else out
|
||||
|
||||
|
||||
def since(result: dict, key: str, before: int) -> list:
|
||||
"""Entries the browser counted after `before`: it keeps console messages and
|
||||
page errors across page loads, so older ones are not current."""
|
||||
items = result.get(key) or []
|
||||
fresh = result.get("count", len(items)) - before
|
||||
return items[-fresh:] if fresh > 0 else []
|
||||
|
||||
|
||||
def port_of(value: Any) -> int | None:
|
||||
"""A port from a caller, who may send it as a number or a string."""
|
||||
if value in (None, "", 0):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise InvalidParamsError(f"port must be a number, not {value!r}", param="port") from None
|
||||
|
||||
|
||||
def structured(out: Any) -> dict:
|
||||
"""A FastMCP tool's return as a dict: the structured half when the tool
|
||||
declares an output schema, else its JSON text, unwrapped from the
|
||||
unified {ok, data, error} envelope when it arrives in one."""
|
||||
if isinstance(out, tuple):
|
||||
data = out[1]
|
||||
else:
|
||||
text = next((b.text for b in out if getattr(b, "text", None) is not None), "{}")
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
return {"text": text}
|
||||
if isinstance(data, dict) and {"ok", "data", "error"} <= data.keys():
|
||||
return data["data"] if data["ok"] else {"error": data["error"]}
|
||||
return data
|
||||
|
||||
|
||||
class DevserverTool(BaseTool):
|
||||
"""Running dev servers as MCP nodes."""
|
||||
|
||||
name: ClassVar[str] = "devserver"
|
||||
VERSION: ClassVar[str] = "0.1.0"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# The MCP server this tool is registered on: `errors` reaches the
|
||||
# `browser` tool through its registry, never around it.
|
||||
self._server: FastMCP | None = None
|
||||
self._register_actions()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Running dev servers (Next, Vite, Nuxt, Remix, React Router, Astro, SvelteKit): find them, "
|
||||
"call their built-in MCP tools, read current build/runtime errors.\nStart with action=help."
|
||||
)
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
self._server = mcp_server
|
||||
super().register(mcp_server)
|
||||
|
||||
async def _browser(self, action: str, **args: Any) -> dict:
|
||||
if self._server is None:
|
||||
raise ToolError("INTERNAL_ERROR", "devserver is not registered on an MCP server; no browser tool to call")
|
||||
return structured(await self._server.call_tool("browser", {"action": action, **args}))
|
||||
|
||||
async def _pick(self, port: Any, cwd: str | None, tool: str | None = None) -> DevServer:
|
||||
"""The one dev server the caller means: narrowed by port, cwd and, for
|
||||
a call, by which servers list the tool."""
|
||||
servers = await discover(port=port_of(port), cwd=cwd)
|
||||
if tool:
|
||||
servers = [s for s in servers if any(t["name"] == tool for t in s.tools)]
|
||||
if len(servers) == 1:
|
||||
return servers[0]
|
||||
if not servers:
|
||||
listing = f" lists MCP tool {tool!r}" if tool else " matches"
|
||||
raise NotFoundError(f"no running dev server{listing}; index shows what runs")
|
||||
ports = ", ".join(f"{s.port} ({s.framework})" for s in servers)
|
||||
raise InvalidParamsError(f"{len(servers)} dev servers match: {ports}; pass port=", param="port")
|
||||
|
||||
async def _call(self, server: DevServer, tool: str, args: dict) -> dict:
|
||||
connection = MCPServerConnection(MCPServerConfig(name=f"{server.framework}:{server.port}", url=server.mcp))
|
||||
result = await connection.call_tool(tool, args)
|
||||
return {"is_error": result.get("isError", False), "result": payload(result)}
|
||||
|
||||
def _register_actions(self) -> None:
|
||||
@self.action("help", "What devserver does and the index -> call workflow")
|
||||
async def help_action(ctx: MCPContext) -> dict:
|
||||
return HELP
|
||||
|
||||
@self.action(
|
||||
"index",
|
||||
"List dev servers, their MCP endpoints and tools",
|
||||
schema={"type": "object", "properties": {"port": PORT, "cwd": CWD}},
|
||||
)
|
||||
async def index(ctx: MCPContext, port: Any = None, cwd: str | None = None) -> dict:
|
||||
servers = await discover(port=port_of(port), cwd=cwd)
|
||||
return {"count": len(servers), "servers": [asdict(s) for s in servers]}
|
||||
|
||||
@self.action(
|
||||
"call",
|
||||
"Invoke a dev server's MCP tool",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool": {"type": "string", "description": "MCP tool name, as index lists it"},
|
||||
"args": {"type": "object", "description": "The tool's arguments"},
|
||||
"port": PORT,
|
||||
"cwd": CWD,
|
||||
},
|
||||
"required": ["tool"],
|
||||
},
|
||||
)
|
||||
async def call(
|
||||
ctx: MCPContext, tool: str, args: dict | None = None, port: Any = None, cwd: str | None = None
|
||||
) -> dict:
|
||||
server = await self._pick(port, cwd, tool)
|
||||
return {"server": server.mcp, "tool": tool, **await self._call(server, tool, args or {})}
|
||||
|
||||
@self.action(
|
||||
"errors",
|
||||
"Current build/runtime errors of a dev server",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Route to open, default /"},
|
||||
"port": PORT,
|
||||
"cwd": CWD,
|
||||
},
|
||||
},
|
||||
)
|
||||
async def errors(ctx: MCPContext, path: str = "/", port: Any = None, cwd: str | None = None) -> dict:
|
||||
server = await self._pick(port, cwd)
|
||||
url = server.url + "/" + path.lstrip("/")
|
||||
if any(t["name"] == "get_errors" for t in server.tools):
|
||||
# Next reports the errors its browser sessions report, so open one
|
||||
# and let it settle: at `load` the session is connected but has
|
||||
# not yet reported a build error. The server's answer stands
|
||||
# without a browser.
|
||||
try:
|
||||
opened = await self._browser("navigate", url=url, state="networkidle")
|
||||
except Exception as e:
|
||||
opened = {"error": str(e)}
|
||||
report = {"server": server.url, "source": "mcp", **await self._call(server, "get_errors", {})}
|
||||
if opened.get("error"):
|
||||
report["browser"] = opened["error"]
|
||||
return report
|
||||
before = (
|
||||
(await self._browser("console", level="error")).get("count", 0),
|
||||
(await self._browser("errors")).get("count", 0),
|
||||
)
|
||||
opened = await self._browser("navigate", url=url, state="networkidle")
|
||||
if opened.get("error"):
|
||||
raise ToolError("INTERNAL_ERROR", f"browser could not open {url}: {opened['error']}")
|
||||
overlay = (await self._browser("evaluate", code=OVERLAY)).get("result")
|
||||
console = await self._browser("console", level="error")
|
||||
thrown = await self._browser("errors")
|
||||
return {
|
||||
"server": server.url,
|
||||
"source": "browser",
|
||||
"overlay": overlay,
|
||||
"console": since(console, "messages", before[0]),
|
||||
"errors": since(thrown, "errors", before[1]),
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"""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
|
||||
@@ -0,0 +1,37 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-devserver"
|
||||
version = "0.1.0"
|
||||
description = "Dev server tool for Hanzo AI: find running Next/Vite/Nuxt/Astro/SvelteKit/Remix servers, call their MCP tools, read their errors"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
|
||||
keywords = ["hanzo", "mcp", "tools", "devserver", "nextjs", "vite"]
|
||||
dependencies = [
|
||||
"hanzo-tools>=0.3.6",
|
||||
"hanzo-tools-mcp>=0.3.1", # 0.3.1 carries the HTTP/SSE transport
|
||||
"mcp>=1.25.0",
|
||||
"httpx>=0.27.0",
|
||||
"psutil>=6.1.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0"]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
devserver = "hanzo_tools.devserver:TOOLS"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/hanzoai/python-sdk"
|
||||
Repository = "https://github.com/hanzoai/python-sdk"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hanzo_tools"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""A Next-shaped MCP server for the suite: streamable HTTP at /_next/mcp, whose
|
||||
get_errors answers the way Next 16 does — a JSON text block with ANSI colour.
|
||||
|
||||
It runs in its own process: the SDK's streamable HTTP server leaves memory
|
||||
streams unclosed, and in-process that would fail warnings-as-errors on the
|
||||
server's account rather than the client's.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from hanzo_tools.devserver import discovery
|
||||
|
||||
SERVER = """
|
||||
import json, socket, uvicorn
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("next", stateless_http=True, streamable_http_path="/_next/mcp")
|
||||
|
||||
@mcp.tool()
|
||||
def get_errors():
|
||||
\"\"\"Current errors.\"\"\"
|
||||
return json.dumps({"sessionErrors": [{"url": "/", "buildError": "\\x1b[31m./app/page.jsx:3:1\\x1b[0m Expected '>'"}]})
|
||||
|
||||
@mcp.tool()
|
||||
def get_routes():
|
||||
\"\"\"Routes.\"\"\"
|
||||
return json.dumps({"appRouter": ["/"]})
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1], flush=True)
|
||||
uvicorn.Server(uvicorn.Config(mcp.streamable_http_app(), log_level="error")).run(sockets=[sock])
|
||||
"""
|
||||
|
||||
|
||||
def wait_listening(port: int) -> None:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
||||
return
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def next_mcp():
|
||||
"""Port of a running Next-shaped MCP server."""
|
||||
proc = subprocess.Popen([sys.executable, "-c", SERVER], stdout=subprocess.PIPE, text=True)
|
||||
try:
|
||||
port = int(proc.stdout.readline())
|
||||
wait_listening(port)
|
||||
yield port
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
proc.stdout.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_schemes():
|
||||
discovery._schemes.clear()
|
||||
yield
|
||||
discovery._schemes.clear()
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
@@ -0,0 +1,153 @@
|
||||
"""The devserver tool on a real FastMCP registry, beside a stand-in `browser` tool."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from hanzo_tools.devserver import DevServer, DevserverTool, discovery, devserver_tool
|
||||
from hanzo_tools.devserver.devserver_tool import since, payload, structured
|
||||
|
||||
|
||||
def test_description_is_three_lines_at_most():
|
||||
assert len(DevserverTool().description.splitlines()) <= 3
|
||||
|
||||
|
||||
async def test_help_lists_the_actions():
|
||||
envelope = await DevserverTool().call(None, action="help")
|
||||
assert set(envelope["data"]["actions"]) == {"index", "call", "errors"}
|
||||
assert "index" in envelope["data"]["workflow"]
|
||||
|
||||
|
||||
def test_payload():
|
||||
assert payload({"structuredContent": {"a": 1}, "content": []}) == {"a": 1}
|
||||
assert payload({"content": [{"type": "text", "text": '{"e":"\\u001b[31mred\\u001b[0m"}'}]}) == {"e": "red"}
|
||||
assert payload({"content": [{"type": "text", "text": "\x1b[1mplain\x1b[0m"}]}) == "plain"
|
||||
image = {"type": "image", "data": "AA==", "mimeType": "image/png"}
|
||||
assert payload({"content": [{"type": "text", "text": "[1]"}, image]}) == [[1], image]
|
||||
assert payload({"content": []}) == []
|
||||
|
||||
|
||||
def test_since_keeps_only_entries_counted_after_the_baseline():
|
||||
assert since({"messages": [1, 2, 3, 4], "count": 9}, "messages", 7) == [3, 4]
|
||||
assert since({"messages": [1, 2], "count": 9}, "messages", 2) == [1, 2]
|
||||
assert since({"messages": [1, 2], "count": 2}, "messages", 2) == []
|
||||
assert since({"errors": ["x"]}, "errors", 0) == ["x"]
|
||||
assert since({"error": "no browser"}, "errors", 0) == []
|
||||
|
||||
|
||||
def test_structured():
|
||||
class Text:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
assert structured(([], {"a": 1})) == {"a": 1}
|
||||
assert structured([Text('{"a": 1}')]) == {"a": 1}
|
||||
assert structured([Text("not json")]) == {"text": "not json"}
|
||||
assert structured([Text('{"ok": true, "data": {"a": 1}, "error": null}')]) == {"a": 1}
|
||||
assert structured([Text('{"ok": false, "data": null, "error": {"code": "X"}}')]) == {"error": {"code": "X"}}
|
||||
|
||||
|
||||
def registry(pages: list[dict]):
|
||||
"""A FastMCP with devserver and a `browser` that records calls and answers from `pages` in order."""
|
||||
server = FastMCP("test")
|
||||
calls = []
|
||||
|
||||
@server.tool(name="browser")
|
||||
async def browser(action: str, url: str = "", state: str = "", level: str = "", code: str = "") -> dict:
|
||||
calls.append({k: v for k, v in dict(action=action, url=url, state=state, level=level).items() if v})
|
||||
return pages.pop(0)
|
||||
|
||||
DevserverTool().register(server)
|
||||
return server, calls
|
||||
|
||||
|
||||
async def run(server: FastMCP, **args) -> dict:
|
||||
[block] = await server.call_tool("devserver", args)
|
||||
return json.loads(block.text)
|
||||
|
||||
|
||||
async def test_errors_opens_the_app_then_asks_the_servers_mcp(monkeypatch, next_mcp):
|
||||
monkeypatch.setattr(discovery, "scan", lambda: [])
|
||||
server, calls = registry([{"success": True}])
|
||||
out = await run(server, action="errors", port=next_mcp, path="dashboard")
|
||||
assert out["ok"], out
|
||||
assert calls == [{"action": "navigate", "url": f"http://localhost:{next_mcp}/dashboard", "state": "networkidle"}]
|
||||
data = out["data"]
|
||||
assert data["source"] == "mcp"
|
||||
assert data["result"] == {"sessionErrors": [{"url": "/", "buildError": "./app/page.jsx:3:1 Expected '>'"}]}
|
||||
assert "browser" not in data
|
||||
|
||||
|
||||
async def test_errors_from_the_mcp_stands_when_the_browser_fails(monkeypatch, next_mcp):
|
||||
monkeypatch.setattr(discovery, "scan", lambda: [])
|
||||
server, _ = registry([{"error": "no browser"}])
|
||||
data = (await run(server, action="errors", port=next_mcp))["data"]
|
||||
assert data["browser"] == "no browser"
|
||||
assert data["result"]["sessionErrors"]
|
||||
|
||||
|
||||
async def test_errors_reads_the_page_without_mcp(monkeypatch):
|
||||
vite = DevServer(port=5174, url="http://localhost:5174", framework="vite")
|
||||
|
||||
async def one(port=None, cwd=None):
|
||||
return [vite]
|
||||
|
||||
monkeypatch.setattr(devserver_tool, "discover", one)
|
||||
overlay = {"plugin": "[plugin:vite:oxc]", "message": "Transform failed", "file": "/app/src/label.ts"}
|
||||
server, calls = registry(
|
||||
[
|
||||
{"success": True, "messages": [{"text": "old"}], "count": 1},
|
||||
{"success": True, "errors": ["old"], "count": 1},
|
||||
{"success": True},
|
||||
{"success": True, "result": overlay},
|
||||
{"success": True, "messages": [{"text": "old"}, {"text": "500"}], "count": 2},
|
||||
{"success": True, "errors": ["old"], "count": 1},
|
||||
]
|
||||
)
|
||||
data = (await run(server, action="errors"))["data"]
|
||||
assert [c["action"] for c in calls] == ["console", "errors", "navigate", "evaluate", "console", "errors"]
|
||||
assert calls[2]["state"] == "networkidle"
|
||||
assert data == {
|
||||
"server": "http://localhost:5174",
|
||||
"source": "browser",
|
||||
"overlay": overlay,
|
||||
"console": [{"text": "500"}],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_call_runs_a_listed_tool(monkeypatch, next_mcp):
|
||||
monkeypatch.setattr(discovery, "scan", lambda: [])
|
||||
server, _ = registry([])
|
||||
data = (await run(server, action="call", port=next_mcp, tool="get_routes"))["data"]
|
||||
assert data["result"] == {"appRouter": ["/"]}
|
||||
assert data["is_error"] is False
|
||||
missing = await run(server, action="call", port=next_mcp, tool="get_nothing")
|
||||
assert missing["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
async def test_call_goes_to_the_one_server_listing_the_tool(monkeypatch, next_mcp):
|
||||
next_like = DevServer(port=next_mcp, framework="next", mcp=f"http://localhost:{next_mcp}/_next/mcp")
|
||||
next_like.tools = [{"name": "get_routes"}]
|
||||
vite = DevServer(port=5180, framework="vite", mcp="http://localhost:5180/__mcp/sse")
|
||||
vite.tools = [{"name": "get-vite-config"}]
|
||||
|
||||
async def both(port=None, cwd=None):
|
||||
return [vite, next_like]
|
||||
|
||||
monkeypatch.setattr(devserver_tool, "discover", both)
|
||||
server, _ = registry([])
|
||||
data = (await run(server, action="call", tool="get_routes"))["data"]
|
||||
assert data["server"] == next_like.mcp
|
||||
assert data["result"] == {"appRouter": ["/"]}
|
||||
|
||||
|
||||
async def test_ambiguous_servers_ask_for_a_port(monkeypatch):
|
||||
async def two(port=None, cwd=None):
|
||||
return [DevServer(port=3000, framework="next"), DevServer(port=5173, framework="vite")]
|
||||
|
||||
monkeypatch.setattr(devserver_tool, "discover", two)
|
||||
server, _ = registry([])
|
||||
out = await run(server, action="errors")
|
||||
assert out["error"]["code"] == "INVALID_PARAMS"
|
||||
assert "3000 (next), 5173 (vite)" in out["error"]["message"]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Discovery: argv, package.json and port parsing, and MCP protocol probing."""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
import pytest
|
||||
from conftest import free_port
|
||||
from hanzo_tools.devserver import discovery
|
||||
from hanzo_tools.devserver.discovery import (
|
||||
NEXT_MCP,
|
||||
VITE_MCP,
|
||||
scan,
|
||||
probe,
|
||||
binary,
|
||||
within,
|
||||
answers,
|
||||
discover,
|
||||
identify,
|
||||
mcp_paths,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv,framework",
|
||||
[
|
||||
(["next-server (v16.3.7)", "", ""], "next"),
|
||||
(["node", "/app/node_modules/next/dist/bin/next", "dev"], "next"),
|
||||
(["node", "./node_modules/.bin/../vite/bin/vite.js", "--config", "plain.config.js"], "vite"),
|
||||
(["node", "/app/node_modules/.pnpm/[email protected]/node_modules/vite/bin/vite.js"], "vite"),
|
||||
(["bun", "/app/node_modules/.bin/vite"], "vite"),
|
||||
(["node", "/app/node_modules/nuxi/bin/nuxi.mjs", "dev"], "nuxt"),
|
||||
(["node", "/app/node_modules/.bin/react-router", "dev"], "react-router"),
|
||||
(["node", "/app/node_modules/.bin/remix", "vite:dev"], "remix"),
|
||||
(["node", "/app/node_modules/astro/astro.js", "dev"], "astro"),
|
||||
(["node", "scripts/serve.mjs", "out", "5173"], None),
|
||||
(["node", "/home/z/next/server.js"], None),
|
||||
(["python3", "-m", "http.server"], None),
|
||||
([], None),
|
||||
],
|
||||
)
|
||||
def test_binary(argv, framework):
|
||||
assert binary(argv) == framework
|
||||
|
||||
|
||||
def write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
|
||||
def test_identify_prefers_the_framework_over_vite_and_reads_the_hoisted_version(tmp_path):
|
||||
app = tmp_path / "apps" / "web"
|
||||
write(app / "package.json", {"devDependencies": {"vite": "^8.0.0", "@sveltejs/kit": "^2.0.0"}})
|
||||
write(tmp_path / "node_modules" / "@sveltejs" / "kit" / "package.json", {"version": "2.5.1"})
|
||||
(app / "src").mkdir()
|
||||
assert identify(str(app / "src")) == ("sveltekit", "2.5.1", str(app))
|
||||
|
||||
|
||||
def test_identify_skips_manifests_without_a_framework(tmp_path):
|
||||
write(tmp_path / "package.json", {"dependencies": {"next": "16.3.7"}})
|
||||
write(tmp_path / "packages" / "ui" / "package.json", {"dependencies": {"react": "19"}})
|
||||
assert identify(str(tmp_path / "packages" / "ui")) == ("next", "16.3.7", str(tmp_path))
|
||||
|
||||
|
||||
def test_identify_none_outside_a_project(tmp_path):
|
||||
assert identify(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_mcp_paths():
|
||||
assert mcp_paths("next") == (NEXT_MCP,)
|
||||
assert mcp_paths("astro") == (VITE_MCP,)
|
||||
assert mcp_paths(None) == (NEXT_MCP, VITE_MCP)
|
||||
|
||||
|
||||
def test_within(tmp_path):
|
||||
assert within(str(tmp_path / "app"), str(tmp_path))
|
||||
assert within(str(tmp_path), str(tmp_path / "app" / "src"))
|
||||
assert not within(str(tmp_path / "a"), str(tmp_path / "b"))
|
||||
assert not within(None, str(tmp_path))
|
||||
|
||||
|
||||
LISTENER = """
|
||||
import socket
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen()
|
||||
print(sock.getsockname()[1], flush=True)
|
||||
input()
|
||||
"""
|
||||
|
||||
|
||||
def test_scan_reads_argv_cwd_and_listening_ports(tmp_path):
|
||||
write(tmp_path / "package.json", {"devDependencies": {"vite": "^8.0.0"}})
|
||||
write(tmp_path / "node_modules" / "vite" / "package.json", {"version": "8.3.1"})
|
||||
bin = tmp_path / "node_modules" / ".bin" / "vite"
|
||||
bin.parent.mkdir()
|
||||
bin.write_text(LISTENER)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(bin)], cwd=tmp_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
|
||||
)
|
||||
try:
|
||||
port = int(proc.stdout.readline())
|
||||
[server] = [s for s in scan() if s.pid == proc.pid]
|
||||
finally:
|
||||
proc.communicate("")
|
||||
assert (server.port, server.framework, server.version, server.root) == (port, "vite", "8.3.1", str(tmp_path))
|
||||
assert server.url == f"http://localhost:{port}"
|
||||
|
||||
|
||||
class Endpoints(BaseHTTPRequestHandler):
|
||||
"""Answers like the servers a probe meets."""
|
||||
|
||||
ROUTES = {
|
||||
("POST", "/_next/mcp"): (200, "text/event-stream"), # Next 16
|
||||
("POST", "/stateful"): (400, "application/json"), # needs a session: still MCP
|
||||
("GET", "/__mcp/sse"): (200, "text/event-stream"), # vite-plugin-mcp
|
||||
("GET", "/spa/sse"): (200, "text/html"), # an SPA fallback page
|
||||
("POST", "/spa"): (200, "text/html"),
|
||||
("POST", "/missing"): (404, "application/json"),
|
||||
}
|
||||
|
||||
def answer(self, method):
|
||||
status, kind = self.ROUTES.get((method, self.path), (404, "text/html"))
|
||||
body = b"event: endpoint\ndata: /x\n\n" if kind == "text/event-stream" else b"{}"
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", kind)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self.answer("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
self.answer("POST")
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoints():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Endpoints)
|
||||
server.block_on_close = False
|
||||
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
thread.start()
|
||||
yield server.server_address[1]
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,speaks",
|
||||
[
|
||||
("/_next/mcp", True),
|
||||
("/stateful", True),
|
||||
("/__mcp/sse", True),
|
||||
("/spa/sse", False),
|
||||
("/spa", False),
|
||||
("/missing", False),
|
||||
],
|
||||
)
|
||||
async def test_answers(endpoints, path, speaks):
|
||||
assert await answers(f"http://localhost:{endpoints}{path}") is speaks
|
||||
|
||||
|
||||
async def test_answers_false_when_nothing_listens():
|
||||
assert await answers(f"http://localhost:{free_port()}/_next/mcp") is False
|
||||
|
||||
|
||||
async def test_probe_finds_http_and_caches_it(endpoints):
|
||||
assert await probe(endpoints, NEXT_MCP) == f"http://localhost:{endpoints}{NEXT_MCP}"
|
||||
assert discovery._schemes[endpoints] == "http"
|
||||
assert await probe(endpoints, "/spa") is None
|
||||
|
||||
|
||||
async def test_probe_tries_the_cached_scheme_first(monkeypatch):
|
||||
tried = []
|
||||
|
||||
async def only_https(url):
|
||||
tried.append(url.split(":")[0])
|
||||
return url.startswith("https")
|
||||
|
||||
monkeypatch.setattr(discovery, "answers", only_https)
|
||||
assert await probe(3000, NEXT_MCP) == f"https://localhost:3000{NEXT_MCP}"
|
||||
assert tried == ["http", "https"]
|
||||
tried.clear()
|
||||
await probe(3000, NEXT_MCP)
|
||||
assert tried == ["https"]
|
||||
|
||||
|
||||
async def test_discover_probes_a_port_no_process_claims(monkeypatch, next_mcp):
|
||||
monkeypatch.setattr(discovery, "scan", lambda: [])
|
||||
[server] = await discover(port=next_mcp)
|
||||
assert server.mcp == f"http://localhost:{next_mcp}{NEXT_MCP}"
|
||||
assert server.url == f"http://localhost:{next_mcp}"
|
||||
assert [t["name"] for t in server.tools] == ["get_errors", "get_routes"]
|
||||
assert await discover(port=free_port()) == []
|
||||
Reference in New Issue
Block a user