Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6efb4ab
Merge pull request #396 from quantamixsol/CR-INFRA-02-pr-a-http-trans…
quantamixsol Oct 14, 2026
a887423
Merge pull request #397 from quantamixsol/CR-INFRA-02-pr-b-per-sessio…
quantamixsol Oct 15, 2026
e1c1859
Merge pull request #404 from quantamixsol/cr-infra-02-pr-c-relay
quantamixsol Oct 17, 2026
d9acca7
Merge pull request #407 from quantamixsol/cr-infra-02-pr-d-multi-proj…
quantamixsol Oct 17, 2026
83ba679
Merge pull request #411 from quantamixsol/CR-INFRA-02e-session-worksp…
quantamixsol Oct 18, 2026
f324f0d
Merge pull request #418 from quantamixsol/sec-01-crawlq-defects
quantamixsol Oct 18, 2026
fd400f2
REL-HUB-01: 0.86.0 --projects edition gate, version, CHANGELOG, docs …
quantamixsol Oct 18, 2026
ca77030
REL-HUB-01: confine tool-call path arguments to the project root in h…
quantamixsol Oct 20, 2026
d2a916f
REL-HUB-01: fix hub tests (unlock session for in-root read, unwrap cl…
quantamixsol Oct 20, 2026
91984cb
CR-LIC-01: GRAQLE_ENFORCE_CAPS honoured only with verified paid licen…
quantamixsol Oct 20, 2026
7560d94
CR-LIC-01: drop an ADR id from a comment to restore the internal-stri…
quantamixsol Oct 20, 2026
b0a027b
TEST-01: isolate licensing tests from ambient state; tracked known-fa…
quantamixsol Oct 21, 2026
f0b202b
TEST-01: known-failures loader fails closed on unreadable or wrong-sh…
quantamixsol Oct 21, 2026
362ae49
TEST-01 port: enable the pytester plugin used by test_known_failures_…
harish77kumar4270 Oct 21, 2026
dd8c25f
Merge pull request #421 from quantamixsol/infra-02f-hybrid-relay
quantamixsol Oct 19, 2026
6ff324a
REL-HUB-01: CHANGELOG 0.86.0 - release date, hybrid relay, path confi…
harish77kumar4270 Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "graqle",
"source": "./plugins/codex/graqle",
"description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills.",
"version": "0.85.1",
"version": "0.86.0",
"author": {
"name": "Quantamix Solutions",
"url": "https://graqle.com"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "graqle",
"source": "./plugins/claude-code/graqle",
"description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills and an optional governance gate hook.",
"version": "0.85.1",
"version": "0.86.0",
"author": {
"name": "Quantamix Solutions",
"url": "https://graqle.com"
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@ All notable changes to GraQle are documented in this file.

---

## 0.86.0 — 2026-10-21 (Shared server and relay)

### Added

- **One shared GraQle server for many agents.** `graq mcp serve --transport http` loads the graph once and serves it
over HTTP; `graq mcp relay` is a thin stdio process that forwards a client's requests to it, so an existing
`.mcp.json` only changes its command. Each session has its own gate state and its own workspace root
(`--allowed-roots`, `--workspace-root`). Non-loopback binds refuse to start without `GRAQLE_MCP_HTTP_TOKEN`.
Default `graq mcp serve` (stdio) is unchanged.
- **Hybrid relay.** File, git, shell and test tools run on your own machine, in your own workspace, while the
graph stays on the shared server; the server checks the gates for every change. So a teammate on a remote
server edits their own files, not the server's.
- **Multi-project hub (Team / Enterprise).** `graq mcp serve --projects projects.yaml` serves several separate
project graphs from one server with lazy load, idle unload and a memory-budget LRU. Without a Team or Enterprise
licence it refuses to start and names the edition.
- Docs: [Run many agents on one graph](./docs/run-many-agents-on-one-graph.md). Measured on a 20,000-node fixture:
3 stdio servers 687.0 MB total against 492.8 MB for one shared server plus 3 relays (committed in
`scripts/cr_infra_02c_measure_rss.output.txt`).

### Security

- Studio cache entries are keyed by owner and project, so one tenant's cached data is not served to another.
- Conditional S3 push (ETag) so a concurrent push no longer silently overwrites; push fails closed without an API key.
- Server authentication fails closed instead of open when no valid credential is configured.
- In multi-project mode, file paths in tool calls are confined to the project's own root.

### Changed

- Free-tier limits are enforced consistently.

---

## 0.85.1 — 2026-10-10 (Community edition message)

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ If you operate in a regulated environment, the same substrate produces complianc

---

## Run many agents on one graph

Start one shared server with `graq mcp serve --transport http` and point each MCP client at it with `graq mcp relay`, instead of loading the graph once per client. Several projects in one server (`--projects`) is a Team / Enterprise feature.

→ [Run many agents on one graph](./docs/run-many-agents-on-one-graph.md) — setup, SSH tunnel for teammates, token and HTTPS, editions, measured memory.

---

## Pricing

| Tier | What you get |
Expand Down
104 changes: 104 additions & 0 deletions docs/run-many-agents-on-one-graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Run many agents on one graph

By default every MCP client starts its own `graq mcp serve` process, and each process loads its own copy of the graph.
With several agents on one machine, that copy is paid for several times. From 0.86.0 you can run **one shared server**
and point every client at it through a very small relay process.

## Which pieces do what

| Piece | Command | What it does |
|---|---|---|
| Shared server | `graq mcp serve --transport http` | Loads the graph once and serves it over HTTP. |
| Relay | `graq mcp relay --url ...` | A stdio MCP process that holds no graph; it forwards each request to the shared server. |
| Per-session gates | built in | Each client session has its own governance gate state; one agent's open gate does not open another's. |
| Workspace roots | `--allowed-roots`, `--workspace-root` | Each session is confined to a directory the server allows. |
| Multi-project hub | `graq mcp serve --projects projects.yaml` | Several separate project graphs in one server (Team / Enterprise). |

## Same machine

Start the server once. `--allowed-roots` is required: it lists the directories a session may use as its workspace.

```bash
graq mcp serve --transport http --port 8765 --allowed-roots /path/to/your/repos
```

It binds to `127.0.0.1` by default. In each project, change the command in `.mcp.json` from `serve` to `relay`:

```json
{ "mcpServers": { "graq": { "command": "graq", "args": ["mcp", "relay", "--url", "http://127.0.0.1:8765"] } } }
```

The relay sends the current directory as the session's workspace root (override with `--workspace-root`). If the shared
server is unreachable the relay returns an explicit error; it never falls back to loading a graph itself.

## Teammates over an SSH tunnel

Keep the server bound to localhost and let each teammate forward the port:

```bash
ssh -N -L 8765:127.0.0.1:8765 you@shared-host
```

Teammates then use the same relay URL (`http://127.0.0.1:8765`) on their own machine. Nothing is exposed beyond
localhost on the server.

## Token and HTTPS (only beyond localhost)

If you bind to anything other than a loopback address, the server refuses to start unless an auth token is set in
`GRAQLE_MCP_HTTP_TOKEN`; the relay reads the same variable and sends it with each request. Put the server behind HTTPS
(for example a reverse proxy) before exposing it beyond localhost, and give the relay an `https://` URL. On loopback,
and through an SSH tunnel, no token or HTTPS is needed.

## What the server can touch

File tools (read, edit, write, git) act on the **server's disk**, inside the allowed roots. A remote teammate should use
the graph tools (context, inspect, reason, impact, lessons, preflight); their own file edits stay on their machine.

## Several projects in one server (Team / Enterprise)

`--projects` serves several separate project graphs from one process. Graphs are never merged: each project has its own
config, graph, caches and cost counters, and a session is bound to one project when it connects.

```yaml
# projects.yaml
idle_seconds: 900 # optional: unload a graph unused for this long
memory_budget_mb: 4096 # optional: drop least-recently-used idle graphs above this (needs psutil)
projects:
alpha:
root: /path/to/alpha
beta:
root: /path/to/beta
config: graqle.yaml # optional, default graqle.yaml
```

```bash
graq mcp serve --transport http --projects projects.yaml
graq mcp relay --url http://127.0.0.1:8765 --project alpha
```

Graphs load lazily on the first tool call for a project. Requests across projects are serialised, because the server
changes its working directory around each call.

Without a Team or Enterprise licence, `--projects` refuses to start and the message names the edition. Run
`graq doctor` to see which edition you have.

## Editions

| | Community (free) | Team / Enterprise |
|---|---|---|
| Shared HTTP server with one graph | yes | yes |
| `graq mcp relay` | yes | yes |
| Per-session gates, workspace roots, token auth | yes | yes |
| `--projects` multi-project hub | no | yes |

## Measured memory

Measured on a 20,000-node fixture (`scripts/cr_infra_02c_measure_rss.py`, output committed in
`scripts/cr_infra_02c_measure_rss.output.txt`):

| Setup | Total resident memory |
|---|---|
| 3 separate stdio servers | 687.0 MB (229.0 MB each) |
| 1 shared server + 3 relays | 492.8 MB (server 259.2 MB; relays about 77.9 MB each) |

These figures are for that fixture only; your numbers depend on your graph.
2 changes: 1 addition & 1 deletion graqle/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
# constraints: none
# ── /graqle:intelligence ──

__version__ = "0.85.1"
__version__ = "0.86.0"
15 changes: 15 additions & 0 deletions graqle/cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@
CheckResult = tuple[str, str, str] # (status, label, detail)


def _check_caps() -> CheckResult:
"""Report cap enforcement state (CR-LIC-01): state only, never limit values.

Never "off" on a free tier. Fails closed: any error reports "enforced".
"""
try:
from graqle.licensing.caps import caps_state

state = caps_state()
except Exception: # noqa: BLE001
state = "enforced"
return (PASS, "Caps", f"caps: {state}")


def _check_python_version() -> CheckResult:
v = sys.version_info
ver_str = f"{v.major}.{v.minor}.{v.micro}"
Expand Down Expand Up @@ -1025,6 +1039,7 @@ def doctor_command(
all_results: list[CheckResult] = []

# Run all checks
all_results.append(_check_caps())
all_results.append(_check_python_version())
all_results.append(_check_graq_on_path())
all_results.extend(_check_core_deps())
Expand Down
114 changes: 113 additions & 1 deletion graqle/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,23 @@ def mcp_serve(
read_only: bool = typer.Option(
False, "--read-only", help="Read-only mode: blocks mutation tools (graq_learn, graq_reload)"
),
transport: str = typer.Option(
"stdio", "--transport", help="Transport: stdio (default) or http (shared server, CR-INFRA-02)"
),
host: str = typer.Option(
"127.0.0.1", "--host", help="HTTP bind host (non-loopback requires GRAQLE_MCP_HTTP_TOKEN)"
),
port: int = typer.Option(8765, "--port", help="HTTP bind port"),
projects: str = typer.Option(
None, "--projects", help="projects.yaml: serve several separate project graphs (http only)"
),
allowed_roots: str = typer.Option(
None, "--allowed-roots",
help="http only: comma-separated directories a session's workspace root must lie in "
"(required for a single shared server)",
),
) -> None:
"""Start the GraQle MCP development server (stdio transport).
"""Start the GraQle MCP development server (stdio transport by default).

Exposes development intelligence tools over JSON-RPC stdio:
graq_context, graq_inspect, graq_reason, graq_preflight,
Expand All @@ -293,6 +308,68 @@ def mcp_serve(
from graqle.__version__ import __version__
from graqle.plugins.mcp_dev_server import KogniDevServer

if transport not in ("stdio", "http"):
console.print(f"[red]Unknown --transport {transport!r}; use stdio or http.[/red]")
raise typer.Exit(2)
if projects is not None and transport != "http":
console.print("[red]--projects requires --transport http.[/red]")
raise typer.Exit(2)
if transport == "http":
# Validate bind/auth BEFORE loading the graph; refuse unsafe config (fail closed).
from graqle.plugins.mcp_http_transport import (
HttpTransportConfigError,
resolve_auth_token,
run_http,
)

try:
resolve_auth_token(host)
except HttpTransportConfigError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(2)
if projects is not None:
from graqle.licensing.manager import has_feature

if not has_feature("multi_project_hub"):
console.print(
"[red]--projects (several project graphs in one server) needs the Team or "
"Enterprise edition. Community edition serves one graph: use "
"--transport http without --projects. Run 'graq doctor' to see your edition.[/red]"
)
raise typer.Exit(2)
from graqle.plugins.mcp_project_hub import (
HubConfigError,
ProjectHub,
load_projects_file,
)

try:
hub = ProjectHub(
load_projects_file(projects),
lambda spec: KogniDevServer(config_path=spec.config, read_only=read_only),
)
except HubConfigError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(2)
run_http(host=host, port=port, hub=hub)
return
from graqle.plugins.mcp_workspace import WorkspaceError, parse_allowed_roots

if not allowed_roots:
console.print("[red]--transport http needs --allowed-roots PATH[,PATH...] (CR-INFRA-02e).[/red]")
raise typer.Exit(2)
try:
roots = parse_allowed_roots(allowed_roots)
except WorkspaceError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(2)
os.environ.setdefault("GRAQLE_SERVE_CWD", str(Path.cwd()))
run_http(
KogniDevServer(config_path=config, read_only=read_only),
host=host, port=port, allowed_roots=roots,
)
return

# Anchor CWD so _graph_file resolves against project root, not IDE spawn dir.
# serve() already does this via GRAQLE_SERVE_CWD — mcp_serve must match.
# Use setdefault to preserve user/container overrides.
Expand Down Expand Up @@ -342,6 +419,40 @@ def _cleanup_pid():
raise SystemExit(1)


@mcp_app.command("relay")
def mcp_relay(
url: str = typer.Option(
..., "--url", help="Shared GraQle MCP HTTP server, e.g. http://127.0.0.1:8765 (CR-INFRA-02)"
),
project: str = typer.Option(
None, "--project", help="Project name on a multi-project hub (graq mcp serve --projects)"
),
workspace_root: str = typer.Option(
None, "--workspace-root",
help="Workspace root the server confines this session to (default: current directory)",
),
local_tools: bool = typer.Option(
None, "--local-tools/--no-local-tools",
help="Run file/git/bash/test tools on THIS machine's workspace (CR-INFRA-02f); "
"default: on when the server is not on this machine's loopback",
),
) -> None:
"""Thin stdio MCP relay to a shared server (no graph is loaded here).

Replace the command in .mcp.json:
{ "mcpServers": { "graq": { "command": "graq", "args": ["mcp", "relay", "--url", "http://127.0.0.1:8765"] } } }
"""
from graqle.plugins.mcp_relay import RelayConfigError, run_relay

try:
run_relay(url, project=project, workspace_root=workspace_root, local_tools=local_tools)
except RelayConfigError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(2)
except KeyboardInterrupt:
pass


@mcp_app.command("restart")
def mcp_restart() -> None:
"""Restart the MCP server (kills running instance, starts new one).
Expand Down Expand Up @@ -853,6 +964,7 @@ def serve(
import pathlib
os.environ["GRAQLE_CONFIG_PATH"] = str(pathlib.Path(config).resolve())
os.environ["GRAQLE_SERVE_CWD"] = str(pathlib.Path.cwd())
os.environ["GRAQLE_SERVE_HOST"] = host
if read_only:
os.environ["GRAQLE_READ_ONLY"] = "1"

Expand Down
Loading
Loading