diff --git a/.gitignore b/.gitignore index f9e6e73..6679462 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ +# macOS .DS_Store -todo.md + +# Cache __pycache__/ + +# Claude specific exclusions .claude/ -glean_code/__pycache__/ -.claude/settings.json + +# Miscellaneous git templates and cmds git/ # Never commit secrets (tokens may match GLEAN_* or glean_tok_* patterns) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2eb8c..84525d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,45 @@ For what Glean Code is and how to run it, see the [README](README.md). commands, status bar, mock/live switching, secure-token storage) into the editor sidebar. In progress. +## 2026-08-18 + +### Added + +- **`/mcp`** — inspect, configure, and run the bundled MCP server without leaving the REPL. + `/mcp status` reports the installed `mcp` version, whether it can actually run the server, + and any running instance's pid, URL, uptime, and mode. `/mcp config [client]` prints the + paste-ready JSON for Claude Code, Claude Desktop, or Cursor. `/mcp start` runs the server + detached over HTTP and `/mcp stop` terminates it. Documented in + [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/MCP.md](docs/MCP.md). +- **`--name` on `/mcp config`** — the emitted block keys the server as `glean` by default, + the same name Glean's own hosted MCP server would use. `--name glean-cli` keeps both + registered; the default form warns that pasting replaces an existing entry. +- **Transport flags on `glean_mcp.py`** — `--transport stdio|sse|streamable-http`, `--host`, + and `--port`. stdio remains the default and is what MCP clients spawn; the HTTP transports + exist so the server can run detached, since a stdio server started from the REPL would have + no client on the other end of its pipes. +- **`SUPPORT.md`** — best-effort support expectations, triage order, and what makes a bug + report actionable. Surfaced by GitHub in the new-issue chooser. + +### Changed + +- **The macOS app bundle is now `Glean Code.app`**, not `Glean.app` — the latter is Glean's + own desktop client (`com.glean.desktop`). The installer reads `CFBundleIdentifier` before + writing and refuses a bundle it did not create; `--uninstall` skips one for the same reason. + A pre-existing `Glean.app` that we own is replaced on the next install. +- **CI workflow renamed** from `tests.yml` to `release.yml`, and it now publishes the built + zipapp as a downloadable workflow artifact. + +### Fixed + +- **`pip install "mcp[cli]"` broke fresh installs.** The MCP SDK's 2.0.0 release renamed + `FastMCP` to `MCPServer` and removed the `mcp.server.fastmcp` module `glean_mcp.py` imports, + so an unpinned install resolved to 2.x and failed on import. Install instructions now pin + `mcp[cli]>=1,<2`, and the import guard distinguishes "not installed" from "installed but + incompatible" instead of advising a reinstall of the version that just broke. +- **`--uninstall` could delete a user's Glean Desktop installation** — it called `rmtree` on + the app path with no ownership check. + ## 2026-08-14 ### Added diff --git a/README.md b/README.md index 3b0f61a..c0d50f3 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ A native VS Code extension that brings the full Glean Code REPL — slash comman | Area | Commands | | --- | --- | -| Shell | `/help` `/status` `/doctor` `/auth` `/login` `/logout` `/open` `/ask` `/config` `/mode` `/history` `/clear` `/exit` | +| Shell | `/help` `/status` `/doctor` `/auth` `/login` `/logout` `/open` `/ask` `/config` `/mode` `/mcp` `/history` `/clear` `/exit` | | Chat and search | `/chat` `/search` `/autocomplete` `/recommendations` `/feedback` `/datasources.list` | | Indexing — read & debug | `/datasources.status` `/datasources.config` `/documents.status` `/documents.count` `/users.count` `/documents.access` `/debug.document` `/debug.documents` `/debug.user` `/indexing.rotate-token` | | Indexing — single write | `/index.document` `/index.permissions` `/index.user` `/index.group` `/index.membership` and their `/index.delete-*` partners | @@ -242,6 +242,10 @@ Requires Python 3.10+ and the **v1 line** of the `mcp` package — install it as server imports, so an unpinned install breaks it ([details](docs/MCP.md#mcp-sdk-v2)). The REPL itself remains Python 3.9+ and stdlib-only. +`/mcp` drives it from inside the REPL — `/mcp status` for version and health, `/mcp config +` for the paste-ready JSON, and `/mcp start` to run one detached over HTTP when you +want a server that isn't owned by a client. + Setup for all three clients, the tool table, and the mock-mode rationale: **[docs/MCP.md](docs/MCP.md)**. ## Project layout @@ -257,6 +261,7 @@ glean-code-cli/ client.py Glean REST wrapper + mock responses config.py config file load and save help_docs.py per-command documentation + mcp_control.py /mcp — MCP server diagnostics and process control mock_corpus.py the fake corpus every mock endpoint reads from _indexing_walk.py --path file walking for indexing commands completion.py readline tab completion @@ -265,7 +270,7 @@ glean-code-cli/ auth_commands.py /auth command handlers auth/ OAuth 2.1 + PKCE: oauth, pkce, callback_server, token_store, manager - tests/ 16 test modules, stdlib unittest only + tests/ 17 test modules, stdlib unittest only docs/ full reference set — see below ``` @@ -290,7 +295,7 @@ files, and they outrank the `Glean Code.app` launcher in `Cmd+Space`: export PYTHONPYCACHEPREFIX="$HOME/.cache/python" ``` -740 tests covering the client and every mock response, commands and dispatch, config, UI, auth, completion, help docs, the mock corpus, indexing-walk, scaffold, the installer, and the MCP server. Development notes: [docs/TESTING.md](docs/TESTING.md). +776 tests covering the client and every mock response, commands and dispatch, config, UI, auth, completion, help docs, the mock corpus, indexing-walk, scaffold, the installer, and the MCP server. Development notes: [docs/TESTING.md](docs/TESTING.md). ## Documentation diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index d8d1b7e..19437a5 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -194,6 +194,50 @@ Quickly switch the API mode without editing config. --- +#### /mcp + +Inspect, configure, and run the bundled MCP server without leaving the REPL. + +```text +/mcp [client] [--url] [--stdio] [--name ] [--port ] [--host ] [--transport ] [--mock] +``` + +| Subcommand | Description | +| --- | --- | +| `status` | Installed `mcp` version and whether it can run the server, plus the running server's pid, URL, uptime, and mode. The default when no subcommand is given. | +| `config` | Print the JSON block to paste into a client. Optional client name: `claude-code`, `claude-desktop`, `cursor`. | +| `start` | Run the server detached over HTTP and record where it landed. | +| `stop` | Terminate the server `start` launched. | + +| Flag | Description | +| --- | --- | +| `--url` | `config`: emit the URL form pointing at the running server. | +| `--stdio` | `config`: force the command form even while a server is running. | +| `--name` | `config`: key under `mcpServers`. Default `glean`. | +| `--port` | `start`: bind port. Default `8787`. | +| `--host` | `start`: bind address. Default `127.0.0.1`. | +| `--transport` | `start`: `streamable-http` (default) or `sse`. | +| `--mock` | `start`: serve the built-in corpus. Implied when the REPL is in mock mode. | + +```text +/mcp status +/mcp config claude-code +/mcp config --name glean-cli +/mcp start --port 9000 +/mcp config --url +/mcp stop +``` + +**Output** — `status` prints a key/value table; `config` prints JSON; `start` and `stop` confirm with the pid and URL. + +**`start` cannot use stdio, by design.** stdio is the transport an MCP client spawns for itself over a pipe pair — started from the REPL it would have no client on the other end. `/mcp start` therefore runs `streamable-http` (or `sse`), which a client attaches to by URL. For the ordinary setup, use `/mcp config` and let the client spawn the server. See [docs/MCP.md](MCP.md#starting-a-server-from-the-repl). + +**Name collisions.** The emitted block keys the server as `glean` under `mcpServers`. Glean's own hosted MCP server would naturally be registered under that name too, and pasting over it silently swaps the toolset with no error. Use `--name glean-cli` to run both side by side. + +**Endpoint** — `(local — spawns glean_mcp.py)` + +--- + #### /ask Translate a natural-language request into a sequence of Glean Code slash commands using Glean Assistant as the planner. Read [docs/NATURAL_LANGUAGE.md](NATURAL_LANGUAGE.md) for the full design. diff --git a/docs/MCP.md b/docs/MCP.md index 7236648..642b76c 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -125,6 +125,78 @@ flip an agent onto fake data the day a token expires. Requires Python 3.10+. The REPL itself remains Python 3.9+ and stdlib-only. +## Starting a server from the REPL + +The setups above are the normal ones: the client owns the server's lifecycle, spawning +`glean_mcp.py` over **stdio** whenever it needs it. Nothing to start by hand. + +When you do want a server running independently — to point several clients at one process, to +watch its log while you work, or to check the thing comes up at all — `/mcp` handles it from +inside `glean`: + +```text +/mcp status # mcp version, compatibility, and any running server +/mcp config claude-code # the JSON block to paste, per client +/mcp start --port 9000 # run it detached over HTTP +/mcp config --url # the JSON block pointing at that running server +/mcp stop +``` + +```text +── mcp ───────────────────────────────────────────────────────────── + mcp package 1.29.0 (v1 line, compatible) + server script glean_mcp.py + server running pid 73343, up 4m + endpoint http://127.0.0.1:8791/mcp + would serve mock [MOCK MODE banner active] + tools search, chat, list_agents, run_agent + log ~/.gleancode/mcp.log +──────────────────────────────────────────────────────────────────── +``` + +**`/mcp start` never uses stdio.** stdio is a pipe pair between a client and the server it +spawned; started from the REPL there would be no client on the other end, and the REPL already +owns its own stdin and stdout. `start` therefore runs `streamable-http` (default) or `sse`, +which a client attaches to by URL — `/mcp config --url` prints that form. This only helps if +your client supports URL-based servers; for a client that spawns commands, use the stdio form. + +Details worth knowing: + +- **Binds `127.0.0.1` by default.** A live server holds whatever credentials your config has. + `--host` can widen that; think before it does. +- **The server outlives the REPL.** It is started in its own session, so `/exit` leaves it + running. State lives in `~/.gleancode/mcp.json`, which is how a later `glean` session still + finds it, and stale entries are cleared automatically when the process is gone. +- **Mock mode is inherited.** Start it while the REPL is in mock mode (or pass `--mock`) and + the server serves the built-in corpus with the `[MOCK MODE]` banner on every response. +- **Logs go to `~/.gleancode/mcp.log`.** A server that dies on startup — a port already in + use, most often — is reported immediately, with the log path for the detail. + +### Sitting alongside Glean's own MCP server + +Glean ships its own hosted MCP server. It is a different thing from this one: it talks to your +tenant directly, while `glean_mcp.py` wraps the Client REST API and adds mock mode. `/mcp` only +manages this repo's server — it never detects, starts, or talks to Glean's. + +They collide in exactly one place. Both would naturally be registered under the key `glean`: + +```json +{ "mcpServers": { "glean": { ... } } } +``` + +Pasting one over the other **silently replaces it** — no error, you simply end up with a +different set of tools than you expected. Use a distinct key to keep both: + +```text +/mcp config --name glean-cli +``` + +```json +{ "mcpServers": { "glean-cli": { "command": "python3", "args": ["…/glean_mcp.py"] } } } +``` + +`/mcp config` warns about this whenever it emits the default name. + ## MCP SDK v2 `glean_mcp.py` targets the **v1 line** of the `mcp` SDK. Install it with an upper bound: diff --git a/docs/TESTING.md b/docs/TESTING.md index 4d5ec34..a889ee0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,7 +2,7 @@ Notes on the test suite added during development of glean-code-cli. See [Running tests](../README.md#running-tests) for the user-facing instructions on how to run the tests. -All 740 tests pass. Here's what was added across the development passes: +All 776 tests pass. Here's what was added across the development passes: `tests/test_commands_extended.py` (155 new tests) — covers all previously untested commands: @@ -61,3 +61,15 @@ All 740 tests pass. Here's what was added across the development passes: - `_build_client` — forces live mode whatever `mode` the config file carries (including `auto`), and switches to mock only when `GLEAN_MOCK` is set; truthy spellings (`1`, `true`, `yes`, `on`) accepted, everything else ignored - Labelling — all four tools (`search`, `chat`, `list_agents`, `run_agent`) prefix their response with the `[MOCK MODE]` banner when serving fake data, including empty-result responses, and never in live mode - Tool descriptions — every tool docstring names `GLEAN_MOCK`, so the warning reaches the agent before it calls anything + +`tests/test_mcp_control.py` (36 new tests) — covers `/mcp` server control: + +- Package diagnostics — reports the installed `mcp` version and whether it can actually run the server (v1 provides `mcp.server.fastmcp`; v2 does not) +- State file — roundtrip, unreadable-file tolerance, and clearing a stale entry when the recorded pid is dead or has been reused by another process +- `start` refusals — stdio (which needs a client on the other end), unknown transports, missing package, an incompatible v2 install named by version, and a second start while one is running +- `stop` — signals and clears state; a no-op when nothing is running +- Client config — the stdio command form and the URL form, both JSON-serialisable +- Defaults — loopback-only bind, non-stdio default transport, and tool names checked against `glean_mcp.py` itself +- Command dispatch — bare `/mcp` shows status, unknown subcommands and clients error, `--url` without a server errors, a non-numeric `--port` errors + +Every test redirects the state and log paths at a temp directory, so `~/.gleancode/` is never touched. diff --git a/glean_code/commands.py b/glean_code/commands.py index 0eccd52..36a7530 100644 --- a/glean_code/commands.py +++ b/glean_code/commands.py @@ -7,6 +7,7 @@ import csv import json +import os import re import shlex import socket @@ -20,6 +21,7 @@ from . import ui from . import _indexing_walk as _walk +from . import mcp_control as _mcp from .client import GleanClient, GleanError from .config import Config, SECURE_REFS, is_secure_ref, resolve_secure from .help_docs import DOCS, COMMAND_GROUPS @@ -413,6 +415,126 @@ def cmd_config(s: Session, pos, flags): ui.print_err("Usage: /config [get | set | list]") +def _mcp_status(s: Session) -> None: + version, compatible = _mcp.mcp_package() + if version is None: + pkg = ui.style("not installed", ui.C.RED) + elif compatible: + pkg = ui.style(f"{version} (v1 line, compatible)", ui.C.GREEN) + else: + pkg = ui.style(f"{version} (v2 — no mcp.server.fastmcp)", ui.C.RED) + + state = _mcp.running() + if state: + server = ui.style(f"running pid {state['pid']}, up {_mcp.uptime(state)}", ui.C.GREEN) + where = state["url"] + served = "mock [MOCK MODE banner active]" if state.get("mock") else "live" + else: + server = ui.style("not running", ui.C.GREY) + where = ui.style("(start one with /mcp start, or let your client spawn stdio)", ui.C.GREY) + # Not running: report what a client-spawned stdio server would do. + served = "live" if not os.environ.get("GLEAN_MOCK") else "mock" + + rows = [ + ("mcp package", pkg), + ("server script", _mcp.SERVER_SCRIPT.name), + ("server", server), + ("endpoint", where), + ("would serve", served), + ("tools", ", ".join(_mcp.tool_names())), + ] + if state: + rows.append(("log", state.get("log", str(_mcp.LOG_PATH)))) + print(ui.rule("mcp")) + print(ui.kv_table(rows)) + print(ui.rule()) + + if version is None or not compatible: + ui.print_err(f'Install the supported line: pip install "{_mcp.REQUIRED_MCP}"') + + +def _mcp_config(s: Session, pos, flags) -> None: + state = _mcp.running() + use_url = bool(flags.get("url")) or (state is not None and not flags.get("stdio")) + url = state["url"] if (use_url and state) else None + if flags.get("url") and not state: + ui.print_err("No server is running, so there's no URL to point at. " + "Start one with /mcp start, or omit --url for the stdio form.") + return + + client = (pos[0] if pos else "").lower() + if client and client not in _mcp.CLIENTS: + ui.print_err(f"Unknown client '{client}'. " + f"Choose one of: {', '.join(_mcp.CLIENTS)}") + return + + name = str(flags.get("name") or _mcp.DEFAULT_SERVER_NAME) + + print(ui.rule(f"mcp config{' · ' + client if client else ''}")) + if client: + ui.print_info(f"Add to {_mcp.CLIENTS[client]}") + print(_render_json(_mcp.client_config(url, name=name))) + print(ui.rule()) + if url: + ui.print_info("URL form — points at the server /mcp start is running. " + "It stops working when that server stops.") + else: + ui.print_info("Command form — the client spawns its own stdio server. " + "This is the durable setup.") + if name == _mcp.DEFAULT_SERVER_NAME: + ui.print_info(f"Pasting replaces any existing \"{name}\" entry — including " + "Glean's own hosted MCP server, if you have it registered under " + "that name. Use --name glean-cli to keep both.") + + +def _mcp_start(s: Session, flags) -> None: + try: + port = int(flags.get("port") or _mcp.DEFAULT_PORT) + except (TypeError, ValueError): + ui.print_err("--port must be an integer.") + return + transport = str(flags.get("transport") or _mcp.DEFAULT_TRANSPORT) + host = str(flags.get("host") or _mcp.DEFAULT_HOST) + mock = bool(flags.get("mock")) or s.config.effective_mode == "mock" + + try: + state = _mcp.start(host=host, port=port, transport=transport, mock=mock) + except RuntimeError as e: + ui.print_err(str(e)) + return + + ui.print_ok(f"started {state['url']} (pid {state['pid']})") + if state.get("mock"): + ui.print_info("Serving the built-in corpus — every tool response carries " + "a [MOCK MODE] banner.") + ui.print_info("Add it to a client as a URL server: /mcp config --url") + ui.print_info(f"Logs: {state.get('log')}") + + +def _mcp_stop(s: Session) -> None: + state = _mcp.stop() + if not state: + ui.print_info("No server was running.") + return + ui.print_ok(f"stopped pid {state['pid']} ({state['url']})") + + +@register("mcp") +def cmd_mcp(s: Session, pos, flags): + sub = (pos[0] if pos else "status").lower() + rest = pos[1:] + if sub == "status": + _mcp_status(s) + elif sub == "config": + _mcp_config(s, rest, flags) + elif sub == "start": + _mcp_start(s, flags) + elif sub == "stop": + _mcp_stop(s) + else: + ui.print_err("Usage: /mcp ") + + @register("mode") def cmd_mode(s: Session, pos, flags): if not pos or pos[0] not in ("live", "mock", "auto"): diff --git a/glean_code/completion.py b/glean_code/completion.py index cbcd192..f133e50 100644 --- a/glean_code/completion.py +++ b/glean_code/completion.py @@ -30,6 +30,9 @@ _CONFIG_SUBCMDS = ["get", "set", "list"] +_MCP_SUBCMDS = ["status", "config", "start", "stop"] +_MCP_CLIENTS = ["claude-code", "claude-desktop", "cursor"] + class _Completer: def __init__(self) -> None: @@ -107,6 +110,16 @@ def _complete(self, text: str) -> List[str]: partial = "" if ends_with_space else tokens[-1] return [v for v in _FLAG_VALUES["mode"] if v.startswith(partial)] + # ── /mcp subcommands, then client names for `config` ───────────── + if cmd == "mcp": + if len(tokens) == 1 or (len(tokens) == 2 and not ends_with_space): + partial = "" if ends_with_space else tokens[-1] + return [s for s in _MCP_SUBCMDS if s.startswith(partial)] + if tokens[1] == "config" and (len(tokens) == 2 + or (len(tokens) == 3 and not ends_with_space)): + partial = "" if ends_with_space else tokens[-1] + return [c for c in _MCP_CLIENTS if c.startswith(partial)] + # ── /help ────────────────────────────────────────── if cmd == "help": if ends_with_space or len(tokens) == 2: diff --git a/glean_code/help_docs.py b/glean_code/help_docs.py index de59820..8583636 100644 --- a/glean_code/help_docs.py +++ b/glean_code/help_docs.py @@ -123,6 +123,36 @@ "examples": ["/mode auto", "/mode mock", "/mode live"], "endpoint": "(local)", }, + "mcp": { + "summary": "Inspect, configure, and run the bundled MCP server.", + "usage": "/mcp [client] [--url] [--stdio] [--name ] " + "[--port ] [--host ] [--transport ] [--mock]", + "params": [ + ("status", "Package version, compatibility, whether a server is running, and where."), + ("config", "Print the JSON block to paste into a client. " + "Optional client: claude-code, claude-desktop, cursor."), + ("start", "Run the server detached over HTTP. stdio can't be started from " + "the REPL — that's the transport your MCP client spawns itself."), + ("stop", "Terminate the server started by /mcp start."), + ("--url", "config: emit the URL form pointing at the running server."), + ("--stdio", "config: force the command form even while a server is running."), + ("--name", "config: key under mcpServers. Default 'glean' — use a different one " + "(e.g. glean-cli) to sit alongside Glean's own hosted MCP server."), + ("--port", f"start: bind port. Default {8787}."), + ("--host", "start: bind address. Default 127.0.0.1 (loopback only)."), + ("--transport", "start: streamable-http (default) or sse."), + ("--mock", "start: serve the built-in corpus. Implied when the REPL is in mock mode."), + ], + "examples": [ + "/mcp status", + "/mcp config claude-code", + "/mcp config --name glean-cli", + "/mcp start --port 9000", + "/mcp config --url", + "/mcp stop", + ], + "endpoint": "(local — spawns glean_mcp.py)", + }, "doctor": { "summary": "Run a health check on your Glean Code setup. " "Inspects config, URL shape, DNS, TCP and runs a tiny auth probe.", @@ -993,7 +1023,7 @@ COMMAND_GROUPS: List[Tuple[str, List[str]]] = [ - ("Shell", ["help", "status", "doctor", "login", "auth", "logout", "open", "ask", "config", "mode", "history", "clear", "exit"]), + ("Shell", ["help", "status", "doctor", "login", "auth", "logout", "open", "ask", "config", "mode", "mcp", "history", "clear", "exit"]), ("Chat & Search", ["chat", "search", "datasources.list", "datasources.status", "autocomplete", "recommendations", "feedback"]), ("Agents & Tools", ["agents.list", "agents.run", "tools.list", "tools.call"]), ("Docs & People", ["docs.get", "docs.permissions", "entities.list", "people.get"]), diff --git a/glean_code/mcp_control.py b/glean_code/mcp_control.py new file mode 100644 index 0000000..a7bc64b --- /dev/null +++ b/glean_code/mcp_control.py @@ -0,0 +1,268 @@ +"""Process control and diagnostics for the bundled MCP server. + +`glean_mcp.py` normally speaks stdio and is spawned by the MCP client itself +(Claude Code, Claude Desktop, Cursor). That transport cannot be driven from +inside this REPL — the REPL already owns stdin and stdout — so `/mcp start` +runs the server over HTTP instead and records where it is listening. + +Everything here is stdlib-only. The `mcp` package is never imported: the REPL +stays dependency-free, and the server runs in its own process. +""" +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .config import CONFIG_DIR + +SERVER_SCRIPT = Path(__file__).resolve().parent.parent / "glean_mcp.py" +STATE_PATH = CONFIG_DIR / "mcp.json" +LOG_PATH = CONFIG_DIR / "mcp.log" + +DEFAULT_HOST = "127.0.0.1" # loopback only: the server may hold live credentials +DEFAULT_PORT = 8787 +DEFAULT_TRANSPORT = "streamable-http" +TRANSPORTS = ("stdio", "sse", "streamable-http") + +# glean_mcp.py imports mcp.server.fastmcp, removed in mcp 2.0.0. +REQUIRED_MCP = "mcp[cli]>=1,<2" + + +# -------------------- package diagnostics -------------------- + +def mcp_package() -> Tuple[Optional[str], bool]: + """Return (version, compatible) for the installed mcp package. + + Version is None when the package isn't importable at all. `compatible` + means this interpreter can actually run the server — the v1 line provides + mcp.server.fastmcp, v2 renamed it to MCPServer. + """ + try: + import mcp # noqa: F401 + except ImportError: + return None, False + try: + from importlib.metadata import version + found = version("mcp") + except Exception: + found = "unknown" + try: + import mcp.server.fastmcp # noqa: F401 + return found, True + except ImportError: + return found, False + + +# -------------------- state file -------------------- + +def read_state() -> Optional[Dict[str, Any]]: + try: + return json.loads(STATE_PATH.read_text()) + except (OSError, ValueError): + return None + + +def write_state(state: Dict[str, Any]) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + STATE_PATH.write_text(json.dumps(state, indent=2)) + + +def clear_state() -> None: + try: + STATE_PATH.unlink() + except OSError: + pass + + +# -------------------- process checks -------------------- + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True # alive, owned by someone else + except OSError: + return False + return True + + +def _pid_is_ours(pid: int) -> bool: + """Guard against PID reuse by confirming the process is our server.""" + try: + out = subprocess.run(["ps", "-p", str(pid), "-o", "command="], + capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError): + return True # can't check — trust the pid rather than lie about it + if out.returncode != 0: + return False + return "glean_mcp" in out.stdout + + +def running() -> Optional[Dict[str, Any]]: + """Return the live server's state, or None. Clears a stale state file.""" + state = read_state() + if not state: + return None + pid = state.get("pid") + if not isinstance(pid, int) or not _pid_alive(pid) or not _pid_is_ours(pid): + clear_state() + return None + return state + + +# -------------------- lifecycle -------------------- + +def start(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, + transport: str = DEFAULT_TRANSPORT, + mock: bool = False) -> Dict[str, Any]: + """Spawn the server detached and record where it landed. + + Raises RuntimeError with a user-facing message on any refusal. + """ + if transport not in TRANSPORTS: + raise RuntimeError(f"unknown transport '{transport}'. " + f"Choose one of: {', '.join(TRANSPORTS)}") + if transport == "stdio": + raise RuntimeError( + "stdio can't be started from the REPL — it is the transport the MCP " + "client spawns for itself. Use streamable-http or sse here, or wire " + "the stdio command into your client with /mcp config." + ) + existing = running() + if existing: + raise RuntimeError(f"already running on {existing.get('url')} " + f"(pid {existing.get('pid')}). Stop it first.") + if not SERVER_SCRIPT.exists(): + raise RuntimeError(f"server script not found: {SERVER_SCRIPT}") + + version, compatible = mcp_package() + if version is None: + raise RuntimeError(f'the mcp package is not installed. pip install "{REQUIRED_MCP}"') + if not compatible: + raise RuntimeError( + f"mcp {version} does not provide mcp.server.fastmcp (removed in 2.0.0). " + f'Pin the v1 line: pip install "{REQUIRED_MCP}"' + ) + + env = dict(os.environ) + if mock: + env["GLEAN_MOCK"] = "1" + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + log = open(LOG_PATH, "ab", buffering=0) + try: + proc = subprocess.Popen( + [sys.executable, str(SERVER_SCRIPT), + "--transport", transport, "--host", host, "--port", str(port)], + stdin=subprocess.DEVNULL, stdout=log, stderr=log, + start_new_session=True, env=env, + ) + except OSError as e: + log.close() + raise RuntimeError(f"could not start the server: {e}") from None + + path = "/mcp" if transport == "streamable-http" else "/sse" + state = { + "pid": proc.pid, + "transport": transport, + "host": host, + "port": port, + "url": f"http://{host}:{port}{path}", + "started_at": int(time.time()), + "mock": bool(mock), + "log": str(LOG_PATH), + } + + # Give it a moment to bind; a port clash dies immediately and silently. + time.sleep(0.6) + if proc.poll() is not None: + clear_state() + raise RuntimeError( + f"the server exited immediately (code {proc.returncode}). " + f"Port {port} may be in use — see {LOG_PATH}" + ) + write_state(state) + return state + + +def stop(timeout: float = 5.0) -> Optional[Dict[str, Any]]: + """Terminate the running server. Returns its state, or None if not running.""" + state = running() + if not state: + clear_state() + return None + pid = int(state["pid"]) + try: + os.kill(pid, signal.SIGTERM) + except OSError: + clear_state() + return state + + deadline = time.time() + timeout + while time.time() < deadline: + if not _pid_alive(pid): + break + time.sleep(0.1) + else: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + clear_state() + return state + + +def uptime(state: Dict[str, Any]) -> str: + started = state.get("started_at") + if not isinstance(started, int): + return "unknown" + secs = max(0, int(time.time()) - started) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m {secs % 60}s" + return f"{secs // 3600}h {(secs % 3600) // 60}m" + + +# -------------------- client configuration -------------------- + +CLIENTS = { + "claude-code": "`.claude/settings.json` in the project, or ~/.claude/settings.json", + "claude-desktop": "~/Library/Application Support/Claude/claude_desktop_config.json", + "cursor": "`.cursor/mcp.json` in the project", +} + + +DEFAULT_SERVER_NAME = "glean" + + +def client_config(url: Optional[str] = None, + name: str = DEFAULT_SERVER_NAME) -> Dict[str, Any]: + """The JSON block to paste into an MCP client. + + With a url, emits the URL form for a server already listening (what + /mcp start produces). Without one, emits the stdio command form the + client spawns for itself — the normal setup. + + `name` is the key under mcpServers. It matters: Glean's own hosted MCP + server is a different server that would naturally be registered under + "glean" too, and pasting over that entry silently swaps the toolset. + """ + if url: + entry: Dict[str, Any] = {"type": "http", "url": url} + else: + entry = {"command": sys.executable, "args": [str(SERVER_SCRIPT)]} + return {"mcpServers": {name: entry}} + + +def tool_names() -> List[str]: + """The tools the server exposes. Kept in sync with glean_mcp.py by test.""" + return ["search", "chat", "list_agents", "run_agent"] diff --git a/glean_mcp.py b/glean_mcp.py index bdb897b..34929f8 100644 --- a/glean_mcp.py +++ b/glean_mcp.py @@ -310,7 +310,35 @@ def run_agent(agent_id: str, input: str) -> str: # ── entry point ─────────────────────────────────────────────────────────────── +def _parse_args(argv: Optional[list] = None) -> "argparse.Namespace": + """Transport selection. stdio is the default and what MCP clients spawn. + + The HTTP transports exist so the server can be started detached — from the + glean REPL's `/mcp start`, or by hand — since a stdio server needs a client + on the other end of its pipes to be useful at all. + """ + import argparse + parser = argparse.ArgumentParser( + prog="glean_mcp.py", + description="Glean MCP server. Defaults to stdio, which is what MCP clients spawn.", + ) + parser.add_argument("--transport", default="stdio", + choices=["stdio", "sse", "streamable-http"], + help="wire transport (default: stdio)") + parser.add_argument("--host", default="127.0.0.1", + help="bind address for the HTTP transports (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8787, + help="bind port for the HTTP transports (default: 8787)") + return parser.parse_args(argv) + + if __name__ == "__main__": + _args = _parse_args() + if _args.transport != "stdio": + # FastMCP reads host/port off its settings object, not run(). + mcp.settings.host = _args.host + mcp.settings.port = _args.port + if _cfg.effective_mode == "mock": print( f"{MOCK_ENV_VAR} is set: serving the built-in fictional corpus. " @@ -325,4 +353,7 @@ def run_agent(agent_id: str, input: str) -> str: "or run /login inside glean-code first.", file=sys.stderr, ) - mcp.run(transport="stdio") + if _args.transport != "stdio": + print(f"serving MCP over {_args.transport} on " + f"http://{_args.host}:{_args.port}", file=sys.stderr) + mcp.run(transport=_args.transport) diff --git a/tests/test_mcp_control.py b/tests/test_mcp_control.py new file mode 100644 index 0000000..d721330 --- /dev/null +++ b/tests/test_mcp_control.py @@ -0,0 +1,285 @@ +"""Tests for /mcp — server control and diagnostics. + +Every test redirects the state file at a temporary directory, so the real +~/.gleancode/mcp.json is never touched. Nothing here spawns a server: the +refusal paths are what matter, and they're reachable without one. +""" +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from glean_code import mcp_control as mc +from glean_code.commands import HANDLERS, Session +from glean_code.config import Config + + +class _StateDir: + """Point the module's state/log paths at a temp dir for one test.""" + + def __enter__(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self._patches = [ + mock.patch.object(mc, "CONFIG_DIR", root), + mock.patch.object(mc, "STATE_PATH", root / "mcp.json"), + mock.patch.object(mc, "LOG_PATH", root / "mcp.log"), + ] + for p in self._patches: + p.start() + return root + + def __exit__(self, *exc): + mock.patch.stopall() + self.tmp.cleanup() + return False + + +class TestPackageDiagnostics(unittest.TestCase): + def test_returns_version_and_compat_flag(self): + version, compatible = mc.mcp_package() + self.assertTrue(version is None or isinstance(version, str)) + self.assertIsInstance(compatible, bool) + + def test_missing_package_is_not_compatible(self): + with mock.patch.dict(sys.modules, {"mcp": None}): + version, compatible = mc.mcp_package() + self.assertIsNone(version) + self.assertFalse(compatible) + + def test_required_pin_excludes_v2(self): + self.assertIn("<2", mc.REQUIRED_MCP) + + +class TestState(unittest.TestCase): + def test_roundtrip(self): + with _StateDir(): + mc.write_state({"pid": 1234, "url": "http://x"}) + self.assertEqual(mc.read_state()["pid"], 1234) + mc.clear_state() + self.assertIsNone(mc.read_state()) + + def test_unreadable_state_is_none(self): + with _StateDir() as root: + (root / "mcp.json").write_text("{not json") + self.assertIsNone(mc.read_state()) + + def test_running_clears_stale_state(self): + with _StateDir(): + # A pid that cannot be alive — reaped and never reassigned mid-test. + mc.write_state({"pid": 999999, "url": "http://x"}) + with mock.patch.object(mc, "_pid_alive", return_value=False): + self.assertIsNone(mc.running()) + self.assertIsNone(mc.read_state(), "stale state should be removed") + + def test_running_rejects_a_reused_pid(self): + with _StateDir(): + mc.write_state({"pid": os.getpid(), "url": "http://x"}) + with mock.patch.object(mc, "_pid_alive", return_value=True), \ + mock.patch.object(mc, "_pid_is_ours", return_value=False): + self.assertIsNone(mc.running()) + + def test_running_returns_live_state(self): + with _StateDir(): + mc.write_state({"pid": os.getpid(), "url": "http://x"}) + with mock.patch.object(mc, "_pid_alive", return_value=True), \ + mock.patch.object(mc, "_pid_is_ours", return_value=True): + self.assertEqual(mc.running()["url"], "http://x") + + +class TestStartRefusals(unittest.TestCase): + """start() must refuse clearly rather than spawn something useless.""" + + def _start(self, **kw): + with _StateDir(): + with mock.patch.object(mc, "mcp_package", return_value=("1.29.0", True)), \ + mock.patch.object(mc, "running", return_value=None): + return mc.start(**kw) + + def test_stdio_is_refused_with_an_explanation(self): + with self.assertRaises(RuntimeError) as cm: + self._start(transport="stdio") + self.assertIn("stdio", str(cm.exception)) + self.assertIn("client", str(cm.exception).lower()) + + def test_unknown_transport_is_refused(self): + with self.assertRaises(RuntimeError) as cm: + self._start(transport="carrier-pigeon") + self.assertIn("unknown transport", str(cm.exception)) + + def test_missing_package_is_refused(self): + with _StateDir(): + with mock.patch.object(mc, "mcp_package", return_value=(None, False)), \ + mock.patch.object(mc, "running", return_value=None): + with self.assertRaises(RuntimeError) as cm: + mc.start() + self.assertIn(mc.REQUIRED_MCP, str(cm.exception)) + + def test_incompatible_v2_is_refused_by_name(self): + with _StateDir(): + with mock.patch.object(mc, "mcp_package", return_value=("2.0.0", False)), \ + mock.patch.object(mc, "running", return_value=None): + with self.assertRaises(RuntimeError) as cm: + mc.start() + msg = str(cm.exception) + self.assertIn("2.0.0", msg) + self.assertIn("fastmcp", msg) + + def test_second_start_is_refused(self): + with _StateDir(): + live = {"pid": 4242, "url": "http://127.0.0.1:8787/mcp"} + with mock.patch.object(mc, "mcp_package", return_value=("1.29.0", True)), \ + mock.patch.object(mc, "running", return_value=live): + with self.assertRaises(RuntimeError) as cm: + mc.start() + self.assertIn("already running", str(cm.exception)) + + +class TestStop(unittest.TestCase): + def test_stop_without_a_server_returns_none(self): + with _StateDir(): + with mock.patch.object(mc, "running", return_value=None): + self.assertIsNone(mc.stop()) + + def test_stop_signals_and_clears(self): + with _StateDir(): + mc.write_state({"pid": 4242, "url": "http://x"}) + with mock.patch.object(mc, "running", return_value={"pid": 4242, "url": "http://x"}), \ + mock.patch.object(mc, "_pid_alive", return_value=False), \ + mock.patch("os.kill") as killer: + state = mc.stop() + killer.assert_called_once() + self.assertEqual(state["pid"], 4242) + self.assertIsNone(mc.read_state()) + + +class TestClientConfig(unittest.TestCase): + def test_stdio_form_is_a_spawnable_command(self): + cfg = mc.client_config()["mcpServers"]["glean"] + self.assertIn("command", cfg) + self.assertTrue(cfg["args"][0].endswith("glean_mcp.py")) + self.assertNotIn("url", cfg) + + def test_url_form_points_at_the_running_server(self): + cfg = mc.client_config("http://127.0.0.1:8787/mcp")["mcpServers"]["glean"] + self.assertEqual(cfg["type"], "http") + self.assertEqual(cfg["url"], "http://127.0.0.1:8787/mcp") + self.assertNotIn("command", cfg) + + def test_config_is_json_serialisable(self): + json.dumps(mc.client_config()) + json.dumps(mc.client_config("http://x")) + + def test_default_server_name_is_glean(self): + self.assertEqual(mc.DEFAULT_SERVER_NAME, "glean") + self.assertIn("glean", mc.client_config()["mcpServers"]) + + def test_name_override_changes_the_key(self): + """Glean's own hosted MCP server also wants the 'glean' key.""" + cfg = mc.client_config(name="glean-cli")["mcpServers"] + self.assertIn("glean-cli", cfg) + self.assertNotIn("glean", cfg) + + def test_name_override_applies_to_the_url_form_too(self): + cfg = mc.client_config("http://x", name="glean-cli")["mcpServers"] + self.assertEqual(list(cfg), ["glean-cli"]) + + def test_known_clients_have_a_config_location(self): + for name, where in mc.CLIENTS.items(): + self.assertTrue(where, name) + + +class TestUptime(unittest.TestCase): + def test_formats_by_magnitude(self): + import time + now = int(time.time()) + self.assertTrue(mc.uptime({"started_at": now}).endswith("s")) + self.assertIn("m", mc.uptime({"started_at": now - 300})) + self.assertIn("h", mc.uptime({"started_at": now - 7200})) + + def test_missing_timestamp_is_not_a_crash(self): + self.assertEqual(mc.uptime({}), "unknown") + + +class TestDefaults(unittest.TestCase): + def test_binds_loopback_only(self): + """A live server may hold real credentials — never default to 0.0.0.0.""" + self.assertEqual(mc.DEFAULT_HOST, "127.0.0.1") + + def test_default_transport_is_not_stdio(self): + self.assertNotEqual(mc.DEFAULT_TRANSPORT, "stdio") + self.assertIn(mc.DEFAULT_TRANSPORT, mc.TRANSPORTS) + + def test_tool_names_match_the_server(self): + source = (Path(__file__).parent.parent / "glean_mcp.py").read_text() + for name in mc.tool_names(): + self.assertIn(f"def {name}(", source) + + +class TestMcpCommand(unittest.TestCase): + def setUp(self): + self.session = Session(Config(mode="mock")) + + def _run(self, pos, flags=None): + buf = io.StringIO() + with redirect_stdout(buf): + HANDLERS["mcp"](self.session, pos, flags or {}) + return buf.getvalue() + + def test_bare_mcp_shows_status(self): + with _StateDir(): + out = self._run([]) + self.assertIn("mcp package", out) + self.assertIn("tools", out) + + def test_unknown_subcommand_errors(self): + out = self._run(["frobnicate"]) + self.assertIn("Usage: /mcp", out) + + def test_config_prints_the_stdio_block(self): + with _StateDir(): + out = self._run(["config"]) + self.assertIn("mcpServers", out) + self.assertIn("glean_mcp.py", out) + + def test_config_warns_about_overwriting_the_default_name(self): + with _StateDir(): + out = self._run(["config"]) + self.assertIn("--name glean-cli", out) + + def test_config_name_flag_is_honoured_and_drops_the_warning(self): + with _StateDir(): + out = self._run(["config"], {"name": "glean-cli"}) + self.assertIn('"glean-cli"', out) + self.assertNotIn("--name glean-cli", out) + + def test_config_rejects_an_unknown_client(self): + with _StateDir(): + out = self._run(["config", "emacs"]) + self.assertIn("Unknown client", out) + + def test_config_url_without_a_server_errors(self): + with _StateDir(): + out = self._run(["config"], {"url": True}) + self.assertIn("No server is running", out) + + def test_start_rejects_a_non_numeric_port(self): + with _StateDir(): + out = self._run(["start"], {"port": "eight-thousand"}) + self.assertIn("--port must be an integer", out) + + def test_stop_without_a_server_is_not_an_error(self): + with _StateDir(): + out = self._run(["stop"]) + self.assertIn("No server was running", out) + + +if __name__ == "__main__": + unittest.main()