# ghud v0.2.0 — MCP Server + XDG Config Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add an MCP server (fastmcp/stdio) exposing dashboard data as tools, and move config discovery to XDG-compliant paths.

**Architecture:** Extract shared data-fetching logic into `data.py`, add markdown rendering to `dashboard.py`, create `mcp.py` with fastmcp tools that reuse the data layer. Update `config.py` for XDG paths. Wire `ghud serve` subcommand.

**Tech Stack:** Python 3.12, fastmcp>=2.0, rich 14.3.3, ruamel.yaml, gh CLI

**Spec:** `docs/specs/2026-03-22-v020-mcp-xdg-design.md`

---

## Pre-requisites

- [ ] Install fastmcp: `pip install "fastmcp>=2.0"`

---

### Task 1: XDG Config Path

**Files:**
- Modify: `src/ghud/config.py`
- Modify: `tests/test_config.py`

Add XDG_CONFIG_HOME support to path resolution. The XDG path is checked first, then legacy paths.

- [ ] **Step 1: Write tests for XDG path resolution**

Add to `tests/test_config.py`:

```python
def test_find_yaml_path_checks_xdg_first(tmp_path, monkeypatch):
    xdg_dir = tmp_path / "xdg" / "ghud"
    xdg_dir.mkdir(parents=True)
    xdg_file = xdg_dir / "projects.yaml"
    xdg_file.write_text("site: {}")

    legacy_dir = tmp_path / "legacy"
    legacy_dir.mkdir()
    legacy_file = legacy_dir / "projects.yaml"
    legacy_file.write_text("site: {}")

    monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
    # Must reimport to pick up env change
    from ghud.config import _build_yaml_candidates
    candidates = _build_yaml_candidates()
    assert candidates[0] == str(xdg_file)


def test_find_yaml_path_xdg_default_when_unset(tmp_path, monkeypatch):
    monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
    monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path) if p == "~" else p)
    from ghud.config import _build_yaml_candidates
    candidates = _build_yaml_candidates()
    assert candidates[0].startswith(str(tmp_path))
    assert "ghud" in candidates[0]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd ~/Projects/ghud && pytest tests/test_config.py -v -k xdg`
Expected: FAIL — `_build_yaml_candidates` not found

- [ ] **Step 3: Implement XDG path support**

Update `src/ghud/config.py`:

```python
"""Load projects.yaml and extract portfolio repo identifiers."""

import os
from typing import Optional

from ruamel.yaml import YAML

# Legacy candidate paths for projects.yaml
_LEGACY_YAML_CANDIDATES = [
    os.path.expanduser("~/Projects/pages/src/_data/projects.yaml"),
    "/mnt/aux-data/teague/Projects/pages/src/_data/projects.yaml",
]


def _build_yaml_candidates() -> list[str]:
    """Build ordered list of candidate paths, XDG first."""
    xdg_config = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
    xdg_path = os.path.join(xdg_config, "ghud", "projects.yaml")
    return [xdg_path] + _LEGACY_YAML_CANDIDATES


def find_yaml_path(candidates: Optional[list[str]] = None) -> Optional[str]:
    """Return the first existing candidate path, or None."""
    for path in (candidates or _build_yaml_candidates()):
        if os.path.isfile(path):
            return path
    return None
```

Keep `load_repos_from_yaml` unchanged.

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd ~/Projects/ghud && pytest tests/test_config.py -v`
Expected: All tests PASS (existing + new)

- [ ] **Step 5: Commit**

```bash
cd ~/Projects/ghud
git add src/ghud/config.py tests/test_config.py
git commit -m "feat: add XDG config path support"
```

---

### Task 2: Data Layer Extraction

**Files:**
- Create: `src/ghud/data.py`
- Create: `tests/test_data.py`
- Modify: `src/ghud/cli.py`
- Modify: `src/ghud/dashboard.py`

Extract `_collect_other_activity` and the concurrent fetch logic from `cli.py` into `data.py`. Move `IMPORTANT_REASONS` from `dashboard.py` to `data.py`.

- [ ] **Step 1: Write tests for data module**

```python
# tests/test_data.py
from ghud.data import collect_other_activity, filter_important_notifications, IMPORTANT_REASONS


def test_collect_other_activity_splits_by_portfolio():
    notifications = [
        {"repository": {"full_name": "org/tracked"}, "reason": "mention"},
        {"repository": {"full_name": "org/other"}, "reason": "subscribed"},
        {"repository": {"full_name": "org/other"}, "reason": "mention"},
    ]
    portfolio, other = collect_other_activity(notifications, {"org/tracked"})
    assert len(portfolio) == 1
    assert portfolio[0]["repository"]["full_name"] == "org/tracked"
    assert len(other) == 1
    assert other[0]["repo"] == "org/other"
    assert other[0]["count"] == 2


def test_filter_important_notifications():
    notifications = [
        {"reason": "review_requested", "subject": {"title": "A"}},
        {"reason": "subscribed", "subject": {"title": "B"}},
        {"reason": "mention", "subject": {"title": "C"}},
    ]
    important = filter_important_notifications(notifications)
    assert len(important) == 2
    titles = {n["subject"]["title"] for n in important}
    assert titles == {"A", "C"}


def test_filter_important_notifications_returns_all_when_flag_false():
    notifications = [
        {"reason": "subscribed", "subject": {"title": "B"}},
    ]
    result = filter_important_notifications(notifications, important_only=False)
    assert len(result) == 1


def test_important_reasons_contains_expected():
    assert "review_requested" in IMPORTANT_REASONS
    assert "security_alert" in IMPORTANT_REASONS
    assert "subscribed" not in IMPORTANT_REASONS
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd ~/Projects/ghud && pytest tests/test_data.py -v`
Expected: FAIL

- [ ] **Step 3: Implement data module**

```python
# src/ghud/data.py
"""Shared data-fetching and filtering logic."""

from concurrent.futures import ThreadPoolExecutor

from ghud.github import (
    get_notifications,
    get_open_prs,
    get_merged_prs,
    get_issues_for_repos_batch,
)

IMPORTANT_REASONS = {"review_requested", "mention", "assign", "team_mention", "security_alert"}


def collect_other_activity(
    notifications: list[dict], portfolio_repos: set[str]
) -> tuple[list[dict], list[dict]]:
    """Split notifications into portfolio and other activity.

    Returns (portfolio_notifications, other_activity_summary).
    """
    portfolio = []
    other: dict[str, dict] = {}

    for n in notifications:
        repo = n.get("repository", {}).get("full_name", "")
        if repo in portfolio_repos:
            portfolio.append(n)
        else:
            if repo not in other:
                other[repo] = {"repo": repo, "count": 0, "reason_set": set()}
            other[repo]["count"] += 1
            other[repo]["reason_set"].add(n.get("reason", "unknown"))

    other_summary = [
        {"repo": v["repo"], "count": v["count"], "reasons": ", ".join(sorted(v["reason_set"]))}
        for v in sorted(other.values(), key=lambda x: x["count"], reverse=True)
    ]
    return portfolio, other_summary


def filter_important_notifications(
    notifications: list[dict], important_only: bool = True
) -> list[dict]:
    """Filter notifications to important ones only."""
    if not important_only:
        return notifications
    return [n for n in notifications if n.get("reason") in IMPORTANT_REASONS]


def fetch_dashboard_data(
    repos: list[str], username: str, days: int = 7
) -> dict:
    """Fetch all dashboard data concurrently. Returns data dict."""
    with ThreadPoolExecutor(max_workers=4) as pool:
        notif_future = pool.submit(get_notifications)
        prs_future = pool.submit(get_open_prs, username)
        merged_future = pool.submit(get_merged_prs, username, days)
        issues_future = pool.submit(get_issues_for_repos_batch, repos, username)

        all_notifications = notif_future.result()
        open_prs = prs_future.result()
        merged_prs = merged_future.result()
        other_issues = issues_future.result()
        other_issues.sort(key=lambda x: x.get("createdAt", ""), reverse=True)

    portfolio_notifications, other_activity = collect_other_activity(
        all_notifications, set(repos)
    )

    return {
        "notifications": portfolio_notifications,
        "open_prs": open_prs,
        "merged_prs": merged_prs,
        "other_issues": other_issues,
        "other_activity": other_activity,
    }
```

- [ ] **Step 4: Run data module tests**

Run: `cd ~/Projects/ghud && pytest tests/test_data.py -v`
Expected: All 4 tests PASS

- [ ] **Step 5: Update dashboard.py to import from data.py**

In `src/ghud/dashboard.py`, replace:
```python
IMPORTANT_REASONS = {"review_requested", "mention", "assign", "team_mention", "security_alert"}
```
with:
```python
from ghud.data import IMPORTANT_REASONS
```

- [ ] **Step 6: Update cli.py to use data.py**

Replace `src/ghud/cli.py` `run_dashboard()` and remove `_collect_other_activity`:

```python
"""CLI argument parsing and command dispatch."""

import argparse
import sys

from ghud.config import find_yaml_path, load_repos_from_yaml
from ghud.github import get_username
from ghud.data import fetch_dashboard_data
from ghud.dashboard import render_dashboard


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="ghud",
        description="GitHub Heads-Up Display — terminal dashboard for your portfolio repos",
    )
    parser.add_argument("--all", action="store_true", help="Include all notifications")
    parser.add_argument("--days", type=int, default=7, help="Lookback days for merged PRs")

    subparsers = parser.add_subparsers(dest="command")

    discover_parser = subparsers.add_parser("discover", help="Discover new repos")
    discover_parser.add_argument("--dry-run", action="store_true", help="Show changes without writing")

    return parser.parse_args(argv)


def run_dashboard(args: argparse.Namespace) -> None:
    yaml_path = find_yaml_path()
    if not yaml_path:
        print("Error: Could not find projects.yaml", file=sys.stderr)
        sys.exit(1)

    repos = load_repos_from_yaml(yaml_path)
    username = get_username()

    if not username:
        print("Error: Could not determine GitHub username. Run 'gh auth login'.", file=sys.stderr)
        sys.exit(1)

    data = fetch_dashboard_data(repos, username, days=args.days)
    render_dashboard(data, show_all=args.all)


def main(argv: list[str] | None = None) -> None:
    args = parse_args(argv)

    if args.command == "discover":
        from ghud.discover import run_discover
        run_discover(args)
    else:
        run_dashboard(args)
```

- [ ] **Step 7: Run all tests**

Run: `cd ~/Projects/ghud && pytest -v`
Expected: All tests PASS

- [ ] **Step 8: Commit**

```bash
cd ~/Projects/ghud
git add src/ghud/data.py src/ghud/cli.py src/ghud/dashboard.py tests/test_data.py
git commit -m "refactor: extract data layer into data.py"
```

---

### Task 3: Markdown Dashboard Rendering

**Files:**
- Modify: `src/ghud/dashboard.py`
- Modify: `tests/test_dashboard.py`

Add `render_dashboard_markdown()` that returns a markdown string with the same sections as the terminal renderer.

- [ ] **Step 1: Write tests for markdown rendering**

Add to `tests/test_dashboard.py`:

```python
from ghud.dashboard import render_dashboard_markdown


def test_markdown_render_with_notifications():
    data = {
        "notifications": [
            {"reason": "review_requested",
             "subject": {"title": "Fix bug", "type": "PullRequest"},
             "repository": {"full_name": "org/repo"}},
        ],
        "open_prs": [],
        "merged_prs": [],
        "other_issues": [],
        "other_activity": [],
    }
    md = render_dashboard_markdown(data, show_all=False)
    assert "## Notifications" in md
    assert "Fix bug" in md
    assert "review_requested" in md


def test_markdown_render_hides_empty_sections():
    data = {
        "notifications": [],
        "open_prs": [],
        "merged_prs": [],
        "other_issues": [],
        "other_activity": [],
    }
    md = render_dashboard_markdown(data, show_all=False)
    assert md.strip() == "No activity to show."


def test_markdown_render_filters_subscribed():
    data = {
        "notifications": [
            {"reason": "subscribed",
             "subject": {"title": "Noise", "type": "Issue"},
             "repository": {"full_name": "org/repo"}},
        ],
        "open_prs": [],
        "merged_prs": [],
        "other_issues": [],
        "other_activity": [],
    }
    md = render_dashboard_markdown(data, show_all=False)
    assert "Noise" not in md

    md_all = render_dashboard_markdown(data, show_all=True)
    assert "Noise" in md_all


def test_markdown_render_all_sections():
    data = {
        "notifications": [
            {"reason": "mention", "subject": {"title": "Q"}, "repository": {"full_name": "org/repo"}},
        ],
        "open_prs": [
            {"title": "My PR", "repository": {"nameWithOwner": "org/repo"},
             "createdAt": "2026-03-20T00:00:00Z", "commentsCount": 2},
        ],
        "merged_prs": [
            {"title": "Done", "repository": {"nameWithOwner": "org/repo"},
             "closedAt": "2026-03-22T00:00:00Z"},
        ],
        "other_issues": [
            {"title": "Bug", "repo": "org/repo", "createdAt": "2026-03-21T00:00:00Z"},
        ],
        "other_activity": [
            {"repo": "ext/repo", "count": 3, "reasons": "subscribed"},
        ],
    }
    md = render_dashboard_markdown(data, show_all=False)
    assert "## Notifications" in md
    assert "## Your Open PRs" in md
    assert "## Recently Merged" in md
    assert "## New Issues From Others" in md
    assert "## Other Activity" in md
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd ~/Projects/ghud && pytest tests/test_dashboard.py -v -k markdown`
Expected: FAIL

- [ ] **Step 3: Implement markdown rendering**

Add to `src/ghud/dashboard.py`:

```python
def render_dashboard_markdown(data: dict, show_all: bool = False) -> str:
    """Render the dashboard as a markdown string."""
    sections = []

    # Notifications
    notifications = data.get("notifications", [])
    if not show_all:
        notifications = [n for n in notifications if n.get("reason") in IMPORTANT_REASONS]
    if notifications:
        label = "important" if not show_all else ""
        count_label = f"{len(notifications)} {label}".strip()
        lines = [f"## Notifications ({count_label})", ""]
        lines.append("| Reason | Repo | Title |")
        lines.append("|--------|------|-------|")
        for n in notifications:
            reason = n.get("reason", "unknown")
            repo = _short_repo(n.get("repository", {}).get("full_name", ""))
            title = n.get("subject", {}).get("title", "")
            lines.append(f"| {reason} | {repo} | {title} |")
        sections.append("\n".join(lines))

    # Issues from others
    issues = data.get("other_issues", [])
    if issues:
        lines = [f"## New Issues From Others ({len(issues)})", ""]
        lines.append("| Repo | Title | Age |")
        lines.append("|------|-------|-----|")
        for issue in issues:
            repo = _short_repo(issue.get("repo", ""))
            title = issue.get("title", "")
            days = _days_ago(issue.get("createdAt", ""))
            lines.append(f"| {repo} | {title} | {days}d ago |")
        sections.append("\n".join(lines))

    # Open PRs
    prs = data.get("open_prs", [])
    if prs:
        lines = [f"## Your Open PRs ({len(prs)})", ""]
        lines.append("| Repo | Title | Age | Comments |")
        lines.append("|------|-------|-----|----------|")
        for pr in prs:
            repo = _short_repo(pr.get("repository", {}).get("nameWithOwner", ""))
            title = pr.get("title", "")
            days = _days_ago(pr.get("createdAt", ""))
            comments = pr.get("commentsCount", 0)
            lines.append(f"| {repo} | {title} | {days}d | {comments} |")
        sections.append("\n".join(lines))

    # Merged PRs
    merged = data.get("merged_prs", [])
    if merged:
        lines = [f"## Recently Merged ({len(merged)})", ""]
        lines.append("| Repo | Title | When |")
        lines.append("|------|-------|------|")
        for pr in merged:
            repo = _short_repo(pr.get("repository", {}).get("nameWithOwner", ""))
            title = pr.get("title", "")
            days = _days_ago(pr.get("closedAt", ""))
            when = "today" if days == 0 else f"{days}d ago"
            lines.append(f"| {repo} | {title} | {when} |")
        sections.append("\n".join(lines))

    # Other activity
    other = data.get("other_activity", [])
    if other:
        lines = ["## Other Activity", ""]
        for item in other:
            repo = item.get("repo", "")
            count = item.get("count", 0)
            reasons = item.get("reasons", "")
            lines.append(f"- **{repo}**: {count} notification{'s' if count != 1 else ''} ({reasons})")
        sections.append("\n".join(lines))

    if not sections:
        return "No activity to show."

    return "\n\n".join(sections) + "\n"
```

- [ ] **Step 4: Run tests**

Run: `cd ~/Projects/ghud && pytest tests/test_dashboard.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
cd ~/Projects/ghud
git add src/ghud/dashboard.py tests/test_dashboard.py
git commit -m "feat: add markdown dashboard rendering"
```

---

### Task 4: MCP Server

**Files:**
- Create: `src/ghud/mcp.py`
- Create: `tests/test_mcp.py`
- Modify: `pyproject.toml`

Create the MCP server with 7 tools using fastmcp. Config and username are resolved once at module level.

- [ ] **Step 1: Update pyproject.toml**

Add fastmcp dependency and entry point:

In `[project]` dependencies, add `"fastmcp>=2.0"`.

In `[project.scripts]`, add: `ghud-mcp = "ghud.mcp:main"`

Bump version to `"0.2.0"`.

Update `src/ghud/__init__.py` version to `"0.2.0"`.

- [ ] **Step 2: Write tests for MCP tools**

```python
# tests/test_mcp.py
import json
import subprocess
import pytest
from unittest.mock import patch


def _mock_run(stdout_data, returncode=0):
    def mock(cmd, **kwargs):
        result = subprocess.CompletedProcess(cmd, returncode)
        result.stdout = stdout_data if isinstance(stdout_data, str) else json.dumps(stdout_data)
        result.stderr = ""
        return result
    return mock


@pytest.fixture
def mock_config():
    """Patch lazy state so no real GitHub calls are made."""
    import ghud.mcp as mcp_mod
    mcp_mod._state = {"repos": ["testuser/tool_a"], "username": "testuser"}
    yield
    mcp_mod._state = None  # reset for other tests


def test_get_portfolio_repos(mock_config):
    from ghud.mcp import get_portfolio_repos
    result = get_portfolio_repos()
    assert "testuser/tool_a" in result


def test_get_notifications_tool(mock_config, monkeypatch):
    obj = {"reason": "review_requested", "subject": {"title": "Fix"}, "repository": {"full_name": "testuser/tool_a"}}
    monkeypatch.setattr(subprocess, "run", _mock_run(json.dumps(obj)))
    from ghud.mcp import get_notifications_tool
    result = get_notifications_tool(important_only=True)
    assert len(result) == 1


def test_get_open_prs_tool(mock_config, monkeypatch):
    data = [{"title": "PR", "repository": {"nameWithOwner": "testuser/tool_a"},
             "state": "open", "createdAt": "2026-03-20T00:00:00Z", "updatedAt": "2026-03-22T00:00:00Z",
             "url": "https://...", "commentsCount": 0}]
    monkeypatch.setattr(subprocess, "run", _mock_run(data))
    from ghud.mcp import get_open_prs_tool
    result = get_open_prs_tool()
    assert len(result) == 1


def test_get_dashboard_returns_markdown(mock_config, monkeypatch):
    # Mock all GitHub calls to return empty
    monkeypatch.setattr(subprocess, "run", _mock_run("", returncode=1))
    from ghud.mcp import get_dashboard
    result = get_dashboard(show_all=False, days=7)
    assert isinstance(result, str)
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `cd ~/Projects/ghud && pytest tests/test_mcp.py -v`
Expected: FAIL

- [ ] **Step 4: Implement MCP server**

```python
# src/ghud/mcp.py
"""MCP server exposing ghud tools for AI agents."""

from fastmcp import FastMCP

from ghud.config import find_yaml_path, load_repos_from_yaml
from ghud.github import (
    get_username,
    get_notifications,
    get_open_prs,
    get_merged_prs,
    get_issues_for_repos_batch,
)
from ghud.data import (
    fetch_dashboard_data,
    collect_other_activity,
    filter_important_notifications,
)
from ghud.dashboard import render_dashboard_markdown
from ghud.discover import fetch_all_user_repos, find_new_repos

# Lazy-initialized state — resolved on first tool call, not at import time.
# This avoids subprocess calls during test collection or module import.
_state: dict | None = None


def _get_state() -> dict:
    """Resolve config and username once, cache for subsequent calls."""
    global _state
    if _state is None:
        yaml_path = find_yaml_path()
        repos = load_repos_from_yaml(yaml_path) if yaml_path else []
        username = get_username()
        _state = {"repos": repos, "username": username}
    return _state


mcp = FastMCP("ghud", instructions="GitHub Heads-Up Display — portfolio dashboard tools")


@mcp.tool()
def get_dashboard(show_all: bool = False, days: int = 7) -> str:
    """Get the full GitHub dashboard as formatted markdown.

    Shows notifications, open PRs, recently merged PRs, issues from others,
    and other activity across portfolio repos.
    """
    state = _get_state()
    if not state["repos"] or not state["username"]:
        return "Error: ghud not configured (missing projects.yaml or gh auth)"
    data = fetch_dashboard_data(state["repos"], state["username"], days=days)
    return render_dashboard_markdown(data, show_all=show_all)


@mcp.tool()
def get_notifications_tool(important_only: bool = True) -> list[dict]:
    """Get GitHub notifications for portfolio repos.

    By default returns only important notifications (review_requested, mention,
    assign, team_mention, security_alert). Set important_only=False for all.
    """
    state = _get_state()
    all_notifs = get_notifications()
    portfolio, _ = collect_other_activity(all_notifs, set(state["repos"]))
    return filter_important_notifications(portfolio, important_only=important_only)


@mcp.tool()
def get_open_prs_tool() -> list[dict]:
    """Get your open pull requests across all repos.

    Returns raw API fields: title, repository, state, createdAt, updatedAt,
    url, commentsCount.
    """
    state = _get_state()
    if not state["username"]:
        return []
    return get_open_prs(state["username"])


@mcp.tool()
def get_merged_prs_tool(days: int = 7) -> list[dict]:
    """Get your recently merged pull requests.

    Returns PRs merged within the lookback window (default 7 days).
    """
    state = _get_state()
    if not state["username"]:
        return []
    return get_merged_prs(state["username"], days=days)


@mcp.tool()
def get_issues_from_others() -> list[dict]:
    """Get open issues on portfolio repos created by other people.

    Excludes issues you authored. Returns issues with repo, title, author,
    createdAt, url, labels.
    """
    state = _get_state()
    if not state["repos"] or not state["username"]:
        return []
    issues = get_issues_for_repos_batch(state["repos"], exclude_author=state["username"])
    issues.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
    return issues


@mcp.tool()
def get_portfolio_repos() -> list[str]:
    """Get the list of portfolio repos from projects.yaml."""
    return _get_state()["repos"]


@mcp.tool()
def discover_repos() -> list[dict]:
    """Find GitHub repos not yet tracked in projects.yaml.

    Returns a list of repos with nameWithOwner, name, description, and fork status.
    Does not modify projects.yaml.
    """
    state = _get_state()
    if not state["username"]:
        return []
    github_repos = fetch_all_user_repos(state["username"])
    return find_new_repos(github_repos, state["repos"])


def main():
    """Entry point for ghud-mcp script."""
    mcp.run()
```

- [ ] **Step 5: Run tests**

Run: `cd ~/Projects/ghud && pytest tests/test_mcp.py -v`
Expected: All 4 tests PASS

- [ ] **Step 6: Commit**

```bash
cd ~/Projects/ghud
git add src/ghud/mcp.py src/ghud/__init__.py tests/test_mcp.py pyproject.toml
git commit -m "feat: add MCP server with 7 tools via fastmcp"
```

---

### Task 5: CLI `serve` Subcommand

**Files:**
- Modify: `src/ghud/cli.py`
- Modify: `tests/test_cli.py`

Add `ghud serve` subcommand that launches the MCP server.

- [ ] **Step 1: Write test for serve subcommand**

Add to `tests/test_cli.py`:

```python
def test_serve_subcommand():
    args = parse_args(["serve"])
    assert args.command == "serve"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd ~/Projects/ghud && pytest tests/test_cli.py -v -k serve`
Expected: FAIL

- [ ] **Step 3: Implement serve subcommand**

In `src/ghud/cli.py`, add to `parse_args` after the discover subparser:

```python
    subparsers.add_parser("serve", help="Run MCP server (stdio)")
```

In `main()`, add the serve branch:

```python
def main(argv: list[str] | None = None) -> None:
    args = parse_args(argv)

    if args.command == "discover":
        from ghud.discover import run_discover
        run_discover(args)
    elif args.command == "serve":
        from ghud.mcp import main as mcp_main
        mcp_main()
    else:
        run_dashboard(args)
```

- [ ] **Step 4: Run tests**

Run: `cd ~/Projects/ghud && pytest tests/test_cli.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
cd ~/Projects/ghud
git add src/ghud/cli.py tests/test_cli.py
git commit -m "feat: add 'ghud serve' subcommand for MCP server"
```

---

### Task 6: Integration Test + Reinstall

**Files:** None new

- [ ] **Step 1: Run full test suite**

Run: `cd ~/Projects/ghud && pytest -v`
Expected: All tests PASS

- [ ] **Step 2: Reinstall package**

```bash
cd ~/Projects/ghud && pip install -e ".[dev]"
```

- [ ] **Step 3: Verify CLI**

```bash
ghud --help
ghud serve --help 2>&1 || true
time ghud
```

- [ ] **Step 4: Verify MCP entry point**

```bash
which ghud-mcp
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test"}}}' | timeout 5 ghud-mcp 2>/dev/null | head -1
```

Expected: JSON response with server capabilities

- [ ] **Step 5: Fix any issues**

- [ ] **Step 6: Commit fixes and push**

```bash
cd ~/Projects/ghud
git push origin main
```
