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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ plus the jobs the view enqueues (`send_invoice_email.delay`, `rebuild_ledger.sen

## App

`loadpath serve --port 7345` opens a local desktop-style UI: icon rail, labeled toolbar, merge-box confidence, and an inspectable impact graph. Tokens stay on the machine in `~/.loadpath/settings.json`. AI is used **only** for residual uncertainty the graph cannot close. A dozen themes (Obsidian, Nord, Solarized, Paper, high-contrast, …) live in Settings and `localStorage`. Last repo, git range, and SCM slug are remembered the same way. Copy the markdown brief, or post **one** PR comment (updated in place) from the Review tab. Keyboard: `1`–`5` switches tabs. Outside Settings and Pull requests, `⌘`/`Ctrl`+`Enter` runs a review.
`loadpath serve --port 7345` opens a local desktop-style UI: icon rail, labeled toolbar, merge-box confidence, and an inspectable impact graph. The same process hosts MCP at `/mcp` (OAuth). Tokens stay on the machine in `~/.loadpath/settings.json`. AI is used **only** for residual uncertainty the graph cannot close. A dozen themes (Obsidian, Nord, Solarized, Paper, high-contrast, …) live in Settings and `localStorage`. Last repo, git range, and SCM slug are remembered the same way. Copy the markdown brief, or post **one** PR comment (updated in place) from the Review tab. Keyboard: `1`–`5` switches tabs. Outside Settings and Pull requests, `⌘`/`Ctrl`+`Enter` runs a review.

### Review

Expand Down Expand Up @@ -80,12 +80,42 @@ loadpath architecture /path/to/repo
loadpath review /path/to/repo --base HEAD~1 --head HEAD
loadpath review /path/to/repo --base origin/main --head HEAD --no-reindex

# Cross-platform app (API + visual graph + PR list)
# Cross-platform app (API + visual graph + PR list + MCP /mcp with OAuth)
loadpath serve --port 7345

# Local stdio MCP for Cursor / Claude Desktop (no OAuth)
loadpath mcp
```

**Flow:** `index` builds the architecture graph → `architecture` shows contexts and rule hits on the whole repo → `review` walks that same graph for a git range. The app mirrors this: Index registers a workspace, Architecture inspects it, Review traces a change through it.

## MCP (Cursor, Claude, ChatGPT, Gemini)

`loadpath serve` exposes Streamable HTTP MCP at `/mcp`, protected with OAuth 2.1 (PKCE, dynamic client registration, Client ID Metadata Documents). Cloud hosts need HTTPS; set `--public-url` to the public origin when tunneling. `--oauth-pin` adds a PIN on the consent page.

```bash
loadpath serve --host 0.0.0.0 --port 7345 --public-url https://your-tunnel.example --oauth-pin 123456
```

MCP URL: `https://your-tunnel.example/mcp` (or `http://127.0.0.1:7345/mcp` on the same machine).

**Cursor (stdio, local)** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`:

```json
{
"mcpServers": {
"loadpath": {
"command": "loadpath",
"args": ["mcp"]
}
}
}
```

**Cursor / Claude / ChatGPT / Gemini (HTTP + OAuth)** — add that MCP URL in the host’s connectors. The first connect opens a consent page on the Loadpath machine. Tokens stay in `~/.loadpath/oauth.json`.

Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `post_review_comment`. `review` returns the load-path brief (confidence, sinks, reviewers) — not hunk comments.

Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example) and [`fixtures/demo_monorepo/loadpath.yml`](fixtures/demo_monorepo/loadpath.yml)). The tool is opinionated about *your* architecture, not a generic module graph.

## Django support
Expand Down Expand Up @@ -183,6 +213,7 @@ cd ui && npm test
| `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage; reviewers `billing-team` |
| `tests/e2e/test_cli_review.py` | `loadpath index` / `architecture` / `review` markdown, JSON, HTML |
| `tests/e2e/test_api_flow.py` | health, index, architecture, review-from-index, graph, settings, GitHub + Bitbucket PR list |
| `tests/e2e/test_mcp_oauth.py` | OAuth metadata/DCR/PKCE, consent, CIMD, MCP `review` stays on the billing load path |
| `tests/e2e/test_index_architecture_flow.py` | index snapshot, review without index, review walking an existing graph |
| `tests/e2e/test_brokers_and_django.py` | Celery + Dramatiq sinks, actor-only PR, non-idempotent Dramatiq warning, destructive migration, cross-context blocker, boot overlay, management commands, beat/canvas |
| `tests/e2e/test_ui_screenshots.py` | Playwright: Architecture, Review, Impact graph, Pull requests, Settings → `docs/screenshots/` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"typer>=0.15.0",
"rich>=13.9.0",
"python-multipart>=0.0.12",
"mcp>=2.0.0",
]

[project.optional-dependencies]
Expand Down
24 changes: 22 additions & 2 deletions src/loadpath/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,31 @@ def serve(
host: str = typer.Option("127.0.0.1", "--host"),
port: int = typer.Option(7345, "--port"),
open_browser: bool = typer.Option(True, "--open/--no-open"),
public_url: Optional[str] = typer.Option(
None,
"--public-url",
help="Public base URL for MCP OAuth (https://… when tunneling). Default is http://<host>:<port>.",
),
oauth_pin: Optional[str] = typer.Option(
None,
"--oauth-pin",
help="Optional PIN on the OAuth consent screen (recommended when --public-url is set).",
),
) -> None:
"""Start the Loadpath app (API + UI)."""
"""Start the Loadpath app (API + UI + MCP /mcp with OAuth)."""
from loadpath.server.app import serve as run_server

run_server(host=host, port=port, open_browser=open_browser)
run_server(host=host, port=port, open_browser=open_browser, public_url=public_url, oauth_pin=oauth_pin)


@app.command("mcp")
def mcp_stdio() -> None:
"""Run Loadpath as a local stdio MCP server (Cursor / Claude Desktop). No OAuth."""
import asyncio

from loadpath.mcp.server import run_stdio

asyncio.run(run_stdio())


if __name__ == "__main__":
Expand Down
3 changes: 3 additions & 0 deletions src/loadpath/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from loadpath.mcp.server import create_mcp_server, run_stdio

__all__ = ["create_mcp_server", "run_stdio"]
44 changes: 44 additions & 0 deletions src/loadpath/mcp/compact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from typing import Any

from loadpath.review.render import render_markdown


def compact_architecture(report: dict[str, Any]) -> dict[str, Any]:
"""Architecture snapshot without the full node/edge dump (too large for MCP)."""
findings = [f for f in (report.get("findings") or []) if not f.get("waived")]
return {
"indexed": report.get("indexed"),
"stale": report.get("stale"),
"repo_root": report.get("repo_root"),
"indexed_at": report.get("indexed_at"),
"django_boot": report.get("django_boot") or "off",
"counts": report.get("counts") or {"nodes": 0, "edges": 0},
"type_counts": report.get("type_counts") or {},
"contexts": report.get("contexts") or {},
"rules": report.get("rules") or [],
"findings": findings[:24],
"residuals": (report.get("residuals") or [])[:20],
"has_config": report.get("has_config"),
}


def compact_review(review: dict[str, Any]) -> dict[str, Any]:
"""Load-path brief: confidence, sinks, reviewers. Not the full impact graph."""
findings = [f for f in (review.get("findings") or []) if not f.get("waived")]
return {
"markdown": review.get("markdown") or render_markdown(review),
"title": review.get("title"),
"headline": review.get("headline"),
"confidence": review.get("confidence"),
"change_kinds": review.get("change_kinds") or [],
"sinks": review.get("sinks") or [],
"suggested_reviewers": review.get("suggested_reviewers") or [],
"read_order": review.get("read_order") or [],
"findings": findings,
"residuals": (review.get("residuals") or [])[:12],
"low_risk": review.get("low_risk"),
"index": review.get("index"),
"workspace": review.get("workspace"),
}
Loading
Loading