Compare commits
5 Commits
80d2e10fec
...
d3abd4416e
| Author | SHA1 | Date | |
|---|---|---|---|
| d3abd4416e | |||
| e3336efc25 | |||
| f23f2f2b30 | |||
| 2d7f814a23 | |||
| dfa45de40c |
@@ -12,6 +12,11 @@ tmp_dir = "tmp"
|
||||
exclude_dir = ["tmp", "bin", ".git", "repos"]
|
||||
delay = 500
|
||||
stop_on_error = true
|
||||
# Poll for changes instead of relying on fsnotify: filesystem events do NOT
|
||||
# cross the Windows host -> Linux container bind mount, so watch-based reload
|
||||
# silently never fires. Polling is the reliable option in Docker on Windows.
|
||||
poll = true
|
||||
poll_interval = 500
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
+21
-2
@@ -7,6 +7,20 @@
|
||||
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
|
||||
LISTEN_ADDR=127.0.0.1:8080
|
||||
|
||||
# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only
|
||||
# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS
|
||||
# listener starts alongside HTTP. docker-compose sets these to the mounted certs.
|
||||
# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA
|
||||
# your OS — and Claude Desktop — will trust), from the project root:
|
||||
# winget install FiloSottile.mkcert
|
||||
# mkcert -install # trust step (adds the local CA)
|
||||
# mkdir certs
|
||||
# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
||||
# Then connect Claude Desktop to https://localhost:8443/mcp
|
||||
HTTPS_ADDR=
|
||||
TLS_CERT_FILE=
|
||||
TLS_KEY_FILE=
|
||||
|
||||
# Roots to scan for Git repositories, comma-separated (absolute paths).
|
||||
# Inside Docker these must be the *container* paths that the host roots are
|
||||
# mounted to (see docker-compose.yml). Example: /repos,/work/other
|
||||
@@ -44,7 +58,12 @@ APP_ENV=dev
|
||||
# Optional: also append structured logs to this file. Leave empty to disable.
|
||||
LOG_FILE=
|
||||
|
||||
# --- Forge integration — OPTIONAL, read-only, token-gated (AGENT.md §8) -----
|
||||
# With no token the feature is simply absent; the rest of the app is unaffected.
|
||||
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
|
||||
# The primary host is a self-hosted Gitea/Forgejo (git.nilles.net). With no
|
||||
# token the forge features are simply absent; the rest of the app is unaffected.
|
||||
# Writes (merge PR + delete branch, for "Merge & clean up") are each confirmed
|
||||
# per AGENT.md §1.4. The token needs repo read + PR write + branch delete scope.
|
||||
GITEA_TOKEN=
|
||||
# Later providers, behind the same interface (unused for now):
|
||||
GITHUB_TOKEN=
|
||||
GITLAB_TOKEN=
|
||||
|
||||
@@ -10,6 +10,9 @@ gitmanager.exe
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local TLS certificates (generated with mkcert; never commit)
|
||||
/certs/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
@@ -22,6 +22,23 @@ dockable UI panels and is architected so that host/forge integrations
|
||||
(GitHub/GitLab pull‑ and merge‑requests) can be attached without touching the
|
||||
core.
|
||||
|
||||
**The larger purpose — a two‑way companion to Claude.** The dashboard is the
|
||||
visible half. The app is also built to work *with* Claude (used from Claude
|
||||
Desktop / Claude Code) in both directions:
|
||||
- **Claude → app:** the app is an **MCP server** Claude can drive — list/switch
|
||||
repos, run Git operations, and create/merge/clean‑up pull requests — so Claude
|
||||
can manage repositories on the user's behalf.
|
||||
- **App → Claude:** when the user does something in the app (switches project,
|
||||
merges a PR), the app makes that known so Claude stays in sync. The headline
|
||||
case is a **graceful project handoff**: the user switches project in the app,
|
||||
Claude finishes to a safe stopping point, switches, and the user is notified
|
||||
(Section 8).
|
||||
|
||||
The intended user is a developer who does **not** want to memorize Git commands:
|
||||
the GUI offers plain‑language, right‑click commands (Section 6), and Claude can
|
||||
run the same operations through the MCP surface. This is why the app exists — a
|
||||
plain dashboard is only step one.
|
||||
|
||||
This is a **dev environment first**. It is expected to grow continuously via
|
||||
incremental requests ("add X functionality"). Every addition must keep the
|
||||
self‑documenting workflow in Section 9 intact.
|
||||
@@ -143,6 +160,18 @@ The app must be runnable by a new developer with: clone → copy `.env.example`
|
||||
`.env` → set the repo roots → `docker compose up`. Document any host‑side
|
||||
prerequisites (SSH agent, credential helper) in `.env.example` and the help page.
|
||||
|
||||
### 1.7 One service layer behind both the GUI and the MCP server
|
||||
|
||||
The web UI and the MCP server (Section 8) are **two front doors to the same
|
||||
capabilities** — never two implementations. Every operation (a Git action, a
|
||||
forge action, switching the active project) lives once in an internal service
|
||||
that goes through the `internal/git` and `internal/forge` boundaries; the Echo
|
||||
HTTP handlers and the MCP tool handlers are **thin adapters** that call it. A
|
||||
capability added for the GUI is therefore available to Claude, and vice versa,
|
||||
and safety rules (§1.4) are enforced in the shared layer so neither front door
|
||||
can bypass them. Do not implement a Git/forge operation directly in an HTTP or
|
||||
MCP handler.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology stack (locked unless told otherwise)
|
||||
@@ -154,7 +183,9 @@ prerequisites (SSH agent, credential helper) in `.env.example` and the help page
|
||||
| Page rendering | Go server‑rendered HTML shell | Server emits the page + declares web components; components fetch their own data. Use `html/template`. |
|
||||
| Git access | **System `git` via `os/exec`**, wrapped behind an `internal/git` interface | The system binary is authoritative: it honors the user's credential helpers, SSH keys, hooks, and config exactly. Pure‑Go `go-git` may be proposed for cheap read‑only queries, but only behind the same interface and only with approval. |
|
||||
| Repo discovery / index | **In‑memory cache + `github.com/fsnotify/fsnotify`** (optional) | Scan roots for `.git`, hold an index, refresh on interval and/or on filesystem change. |
|
||||
| Forge integration (PRs/MRs) | **Provider‑abstracted** (`internal/forge`) — GitHub via `github.com/google/go-github`, GitLab via `gitlab.com/gitlab-org/api/client-go` | Optional, read‑only by default, enabled per‑host when a token is configured. See Section 8. |
|
||||
| Forge integration (PRs/MRs) | **Provider‑abstracted** (`internal/forge`) — **Gitea/Forgejo first** via `code.gitea.io/sdk/gitea`; GitHub (`github.com/google/go-github`) / GitLab later behind the same interface | **Read/write**, token‑gated per host. Writes (merge PR, delete branch) are confirmed per §1.4. See Section 8. The primary host is a self‑hosted Gitea (`git.nilles.net`). |
|
||||
| MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. |
|
||||
| Real‑time (app → browser) | **Server‑Sent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. |
|
||||
| Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. |
|
||||
| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. |
|
||||
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). |
|
||||
@@ -183,7 +214,10 @@ Section 0).
|
||||
│ ├── config/ # .env loading, typed config struct
|
||||
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
|
||||
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
|
||||
│ ├── forge/ # OPTIONAL seam: GitHub/GitLab PR/MR + remote metadata (provider-abstracted)
|
||||
│ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7)
|
||||
│ ├── forge/ # provider-abstracted PR/MR read+write (Gitea first) — Section 8
|
||||
│ ├── mcp/ # MCP server: tool handlers (thin adapters over service) — Section 8
|
||||
│ ├── activity/ # active-project state, activity feed, pending-switch handoff — Section 8
|
||||
│ ├── logging/ # slog handler → stdout/stderr (+ optional file)
|
||||
│ └── render/ # html/template page shell rendering
|
||||
├── components/ # ALL web components live here (Section 1.2)
|
||||
@@ -266,6 +300,13 @@ self‑fetching, independent lifecycle, cleanup on disconnect). Expected surface
|
||||
- A **repo detail panel** for the selected repo: branches, remotes, recent commit
|
||||
log, stashes, tags.
|
||||
- **Diff / commit views** rendered from server‑produced unified diffs.
|
||||
- A **right‑click context menu** on any item (repo, branch, PR, stash) is the
|
||||
primary way commands are run. This is the app's reason for being (Section 0):
|
||||
commands read in **plain language** for people who don't memorize Git — e.g.
|
||||
"Get latest" (pull), "Save my work" (commit), "Publish" (push), "Merge &
|
||||
clean up" (merge PR + delete branch). Keep a friendly‑name → Git/forge‑op
|
||||
vocabulary; the same operations are exposed to Claude as MCP tools (Section 8),
|
||||
both calling the one service layer (§1.7).
|
||||
- An **action surface** for Git operations. Safe operations (fetch, pull,
|
||||
checkout, create branch, stage, commit, push) can proceed on a normal click;
|
||||
**destructive operations follow Section 1.4** (explicit control + confirmation
|
||||
@@ -274,8 +315,8 @@ self‑fetching, independent lifecycle, cleanup on disconnect). Expected surface
|
||||
`repo:select` event the detail panel listens for) — never shared globals.
|
||||
|
||||
Server endpoints return JSON for the components to self‑fetch; mutating endpoints
|
||||
route through `internal/git` and trigger an index refresh for the affected repo so
|
||||
the UI reflects reality without a full rescan.
|
||||
route through the service layer (§1.7) and trigger an index refresh for the
|
||||
affected repo so the UI reflects reality without a full rescan.
|
||||
|
||||
---
|
||||
|
||||
@@ -292,25 +333,107 @@ the UI reflects reality without a full rescan.
|
||||
|
||||
---
|
||||
|
||||
## 8. Forge integration — OPTIONAL seam (PRs / MRs)
|
||||
## 8. Claude integration (MCP server · activity feed · graceful handoff · forge)
|
||||
|
||||
Viewing pull/merge requests requires talking to a hosting provider, which is
|
||||
**outside** the "Git is the store" core. Keep it isolated and optional.
|
||||
This is the app's defining pillar (Section 0): GitManager works *with* Claude in
|
||||
both directions. Everything here goes through the one service layer (§1.7) and
|
||||
obeys the safety rules (§1.4).
|
||||
|
||||
- Live in `internal/forge` behind a **provider interface** so GitHub, GitLab, and
|
||||
others can drop in without touching the dashboard or the Git boundary.
|
||||
- **Read‑only by default**: list open PRs/MRs and their CI/check status for a repo
|
||||
whose remote points at a supported host. Any write action (comment, merge,
|
||||
approve) is a **separate, explicitly‑requested** capability — do not build write
|
||||
paths without asking, and route them through Section 1.4 if destructive.
|
||||
### 8.1 MCP server — Claude drives the app (`internal/mcp`)
|
||||
|
||||
- The app serves an **MCP endpoint over Streamable HTTP at `/mcp`** using
|
||||
`github.com/modelcontextprotocol/go-sdk`. Like the rest of the app it is
|
||||
**localhost‑bound and unauthenticated** (Section 0) — do not expose it off‑host.
|
||||
- **How Claude Desktop connects — the local stdio bridge, NOT the GUI connector.**
|
||||
The "Add custom connector" GUI is for **remote, publicly‑reachable** servers:
|
||||
it probes (and would call tools) **from Anthropic's cloud**, which cannot reach
|
||||
`127.0.0.1`. So a localhost URL there fails "couldn't reach the server" even
|
||||
though a local browser reaches it. The working path is a **local stdio bridge**
|
||||
configured in `claude_desktop_config.json` under `mcpServers`, launched on the
|
||||
user's machine:
|
||||
```json
|
||||
"gitmanager": { "command": "cmd",
|
||||
"args": ["/c","npx","-y","mcp-remote","http://127.0.0.1:8080/mcp"] }
|
||||
```
|
||||
`mcp-remote` runs locally and speaks stdio to Claude Desktop, so it reaches the
|
||||
local endpoint directly and needs **no public exposure and no HTTPS**. Do **not**
|
||||
reach for a public tunnel — that would expose an unauthenticated repo‑management
|
||||
app to the internet.
|
||||
- **HTTPS is still available** (`:8443`, mkcert cert) for clients that require it,
|
||||
but is not needed for the stdio‑bridge path above.
|
||||
- MCP **tools are thin adapters** over the service layer — no Git/forge logic in
|
||||
the tool handlers (§1.7). Expected tools (grow as features land):
|
||||
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
|
||||
`get_pending_switch`, `list_prs`.
|
||||
- Act: `git_status/checkout/commit/push/pull/create_branch`,
|
||||
`create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`.
|
||||
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The
|
||||
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
|
||||
structured output must be a JSON **object** (`type: "object"`). A handler that
|
||||
returns `[]T` yields `outputSchema.type: "array"`, which Claude Desktop rejects
|
||||
at `tools/list` — and one bad tool takes the whole server down. Wrap any
|
||||
collection result in a named output struct (e.g. `list_repos` returns
|
||||
`listReposOutput{ Repos []repos.State }`, not `[]repos.State`). Fixed 2026-09-20.
|
||||
- **Destructive tools carry the §1.4 contract into MCP:** they describe exactly
|
||||
what they will do and default to the safe variant. The confirmation is the
|
||||
human's — surfaced through Claude and/or the app UI — not something the tool
|
||||
silently assumes.
|
||||
|
||||
### 8.2 Activity feed & active project (`internal/activity`)
|
||||
|
||||
- The app keeps, in memory (and mirrored to the logs — no new datastore, §1.3):
|
||||
- the **active project** (the single repo/task currently in focus), and
|
||||
- an **activity feed** of what happened (user *and* Claude actions: repo
|
||||
switched, committed, PR merged, …).
|
||||
- Both are **queryable** (`get_active_project`, `get_activity`) so Claude can
|
||||
**sync on any turn boundary** — the reliable, pull‑based foundation. The app
|
||||
also **pushes** these to the browser over SSE for live UI. This pull‑first
|
||||
design does not depend on the host letting a connector wake Claude.
|
||||
|
||||
### 8.3 Graceful project handoff (the headline flow)
|
||||
|
||||
When the user switches project/task in the app, it is a **request**, not an
|
||||
instant yank. The cooperative protocol:
|
||||
|
||||
1. **User** picks a new project/task in the app → the app records a
|
||||
**pending‑switch request** (target + optional note) and the UI shows
|
||||
"waiting for Claude to reach a good stopping point."
|
||||
2. **Claude** sees the pending request (it checks at its natural turn‑boundary
|
||||
checkpoints via `get_pending_switch`). It **finishes to a safe stopping
|
||||
point and preserves work** — never abandons uncommitted changes to switch;
|
||||
it completes the in‑flight step and commits/stashes as appropriate — then
|
||||
performs the switch (`set_active_project`, moving its working context to the
|
||||
new repo) and calls **`ack_switch`** with a short summary of where it left
|
||||
the previous project.
|
||||
3. **App** marks the request fulfilled and **notifies the user** over SSE
|
||||
("Claude switched to *ProjectB*; *ProjectA* left at: …"). The user proceeds.
|
||||
|
||||
**Rule:** the switch is Claude‑completed at a checkpoint, not app‑forced. Losing
|
||||
or interrupting uncommitted work to satisfy a switch is a §1.4‑class violation.
|
||||
|
||||
> Fully autonomous "Claude starts working the instant you click, with no turn
|
||||
> from you" is intentionally **not** assumed — it depends on host push support.
|
||||
> Build 8.2–8.3 pull‑first; layer any auto‑wake on top only where the host allows.
|
||||
|
||||
### 8.4 Forge integration — read **and write** (`internal/forge`)
|
||||
|
||||
Talking to the hosting provider is isolated behind a **provider interface**
|
||||
(**Gitea/Forgejo first** — the primary host is `git.nilles.net`; GitHub/GitLab
|
||||
later behind the same interface).
|
||||
|
||||
- **Read:** list open PRs/MRs and their CI/check status for a repo whose remote
|
||||
points at a supported host.
|
||||
- **Write (enabled):** create a PR, **merge a PR, and delete the source branch**
|
||||
— this powers "**Merge & clean up**", the feature that makes PRs usable for a
|
||||
user who otherwise finds them clutter (Section 0). Every write is **confirmed
|
||||
per §1.4**, names the PR/branch, and prefers the tidy default (squash‑merge +
|
||||
delete branch). Note a merged PR remains in the host's history; "clean up"
|
||||
means removing the **branch**, not falsifying history.
|
||||
- **Enablement is per‑host and token‑gated.** Tokens come from `.env`
|
||||
(e.g. `GITHUB_TOKEN`, `GITLAB_TOKEN`); with no token, the feature is simply
|
||||
absent and the rest of the app works unchanged (**graceful degradation** — never
|
||||
a hard dependency).
|
||||
- The provider is inferred from a repo's remote URL. Never send repo data to a host
|
||||
the user didn't configure.
|
||||
|
||||
Design the interface cleanly now; implement providers incrementally as requested.
|
||||
(e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and
|
||||
the rest of the app works unchanged (**graceful degradation**).
|
||||
- The provider is inferred from a repo's remote URL. Never send repo data to a
|
||||
host the user didn't configure.
|
||||
|
||||
---
|
||||
|
||||
@@ -373,9 +496,12 @@ component carries its own context.
|
||||
2. **Check the rules** in Section 1. If the request conflicts, **pause and ask.**
|
||||
3. If the work is UI: it is a **web component** in `components/<name>/` with its
|
||||
own `.md`. No exceptions without asking.
|
||||
4. If it touches repositories: go through the **`internal/git` boundary**, respect
|
||||
**Git = system of record** (Section 1.3), and treat **destructive operations**
|
||||
per Section 1.4. Never add a datastore.
|
||||
4. If it touches repositories or forges: put the logic in the **service layer**
|
||||
(§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP
|
||||
or MCP handler — so both the GUI and Claude get it. Respect **Git = system of
|
||||
record** (§1.3), treat **destructive operations** per §1.4, and never add a
|
||||
datastore. A new capability generally means: service method → HTTP handler →
|
||||
MCP tool → UI control.
|
||||
5. If it needs a **new dependency** or a **new top‑level folder**, propose it and
|
||||
wait for approval.
|
||||
6. Implement, run it in the **docker‑compose dev environment**, verify hot reload
|
||||
@@ -396,10 +522,29 @@ silently guess.)*
|
||||
it should be enabled, and the interval / rate limit, before turning it on.
|
||||
- **Git access library:** system `git` via `os/exec` is the locked default
|
||||
(Section 2). Confirm before introducing `go-git` for any read path.
|
||||
- **Forge providers:** which to support first (GitHub? GitLab?), and whether any
|
||||
**write** actions (merge/comment/approve) are ever in scope (default: read‑only).
|
||||
- **Listen address / exposure:** localhost‑only by default. Confirm before binding
|
||||
to a non‑local interface — there is no auth (Section 0).
|
||||
- ✅ **RESOLVED 2026-09-19:** **Forge = Gitea/Forgejo first** (`git.nilles.net`),
|
||||
**read + write** — merge PR + delete branch ("Merge & clean up"), each confirmed
|
||||
per §1.4 (Section 8.4). GitHub/GitLab later behind the same interface.
|
||||
- ✅ **RESOLVED 2026-09-19:** **MCP transport = Streamable HTTP at `/mcp`**, added
|
||||
in Claude Desktop as a custom connector (Section 8.1).
|
||||
- ✅ **RESOLVED 2026-09-19:** **Project handoff is cooperative** — user requests a
|
||||
switch, Claude finishes to a safe checkpoint, switches, and the user is notified;
|
||||
pull‑first, not autonomous (Section 8.3).
|
||||
- ✅ **RESOLVED 2026-09-20:** Claude Desktop's **GUI "custom connector" cannot
|
||||
reach a localhost server** — it validates/calls from Anthropic's cloud. Solved
|
||||
with the **local stdio bridge** (`claude_desktop_config.json` → `mcpServers` →
|
||||
`mcp-remote http://127.0.0.1:8080/mcp`), §8.1. (HTTPS on `:8443` via mkcert was
|
||||
added earlier and still works, but is not required for this path.)
|
||||
- **Idle‑trigger for handoff:** the pull model syncs at Claude's turn boundaries.
|
||||
If Claude is idle when the user switches, decide the nudge (user's next message,
|
||||
a heartbeat/poll, or a host push if available) — do not assume instant wake.
|
||||
- **Coordination‑state lifetime:** active project / pending‑switch / activity feed
|
||||
are in‑memory today (§1.3). Confirm if any must survive an app restart before
|
||||
adding any persistence.
|
||||
- **Gitea token scope:** which token scopes to require (repo read + PR write +
|
||||
branch delete) and how it is provisioned; document in `.env.example`.
|
||||
- **Listen address / exposure:** localhost‑only by default (covers `/mcp` too).
|
||||
Confirm before binding to a non‑local interface — there is no auth (Section 0).
|
||||
- **Credential path from the container:** SSH agent socket vs mounted keys vs
|
||||
credential helper, for pushing/fetching from inside Docker.
|
||||
|
||||
|
||||
@@ -32,3 +32,69 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
|
||||
canonical import path (e.g. a GitHub URL).
|
||||
- All items in AGENT.md §11 (discovery strategy, background fetch, forge
|
||||
providers, listen address, container credentials) remain open.
|
||||
|
||||
## 2026-09-19 — Redefine the app as a two-way Claude companion (contract only)
|
||||
- **What:** Updated AGENT.md to make the Claude integration the defining pillar,
|
||||
no code yet. §0 now states the two-way purpose (Claude↔app) and the
|
||||
non-expert, GUI-first goal; added law §1.7 (one service layer behind both the
|
||||
GUI and the MCP server); stack table gained MCP server (Go SDK over Streamable
|
||||
HTTP at `/mcp`), SSE (app→browser), and flipped forge to Gitea-first read+write;
|
||||
layout added `internal/{service,mcp,activity}`; §6 added the right-click
|
||||
plain-language command vocabulary; **§8 rewritten** into "Claude integration"
|
||||
(8.1 MCP server, 8.2 activity feed + active project, 8.3 graceful project
|
||||
handoff, 8.4 forge read+write with "Merge & clean up"); §10 step 4 and §11
|
||||
updated (three decisions resolved, new open items). `.env.example` now documents
|
||||
`GITEA_TOKEN` (read+write scope).
|
||||
- **Why:** Thomas described the real vision — the app should act as an extension
|
||||
of Claude: usable like an MCP by Claude, notifying Claude of in-app actions to
|
||||
stay in sync, cooperative project handoff when he's interrupted, plain-language
|
||||
right-click commands for non-experts, and one-click "merge & clean up" so PRs
|
||||
stop cluttering repos. Decisions locked: cooperative pull-first handoff; Gitea
|
||||
writes enabled (confirmed per §1.4); MCP over HTTP `/mcp`.
|
||||
- **Affects:** `AGENT.md`, `.env.example` (architecture/contract only — no code).
|
||||
|
||||
## 2026-09-19 — Service layer + MCP server (Claude integration, read tools)
|
||||
- **What:** Slice 1 — extracted `internal/service`, the one capability layer both
|
||||
the HTTP API and the MCP server call (§1.7); the `/api/repos` and `/api/repo`
|
||||
handlers now route through it. Slice 2 — added `internal/mcp`: an MCP server
|
||||
(`github.com/modelcontextprotocol/go-sdk` v1.8.0) served over Streamable HTTP at
|
||||
`/mcp`, with read tools `list_repos` and `get_repo` as thin adapters over the
|
||||
service. Added a round-trip test (`internal/mcp/mcp_test.go`) using a real temp
|
||||
git repo + the in-memory MCP transport. Verified the HTTP `/mcp` handshake
|
||||
locally and in Docker.
|
||||
- **Why:** First step of the two-way Claude integration (AGENT.md §8.1) — prove
|
||||
Claude can connect to the app over MCP before building deeper features on it.
|
||||
- **Affects:** `internal/service` (new), `internal/mcp` (new), `cmd/server/main.go`,
|
||||
`go.mod`/`go.sum`, `.air.toml`.
|
||||
- **Gotcha:** Docker-on-Windows bind mounts do NOT deliver filesystem events, so
|
||||
air's watch-based reload silently never fired. Fixed by enabling air polling
|
||||
(`poll = true`, `poll_interval = 500` in `.air.toml`).
|
||||
|
||||
## 2026-09-20 — HTTPS for the MCP connector (local TLS via mkcert)
|
||||
- **What:** Added an optional HTTPS listener alongside HTTP. New config
|
||||
`HTTPS_ADDR`, `TLS_CERT_FILE`, `TLS_KEY_FILE`; when set, `cmd/server` starts
|
||||
`e.StartTLS` on the same Echo app (best-effort — a missing cert logs a warning
|
||||
and stays HTTP-only). docker-compose publishes `127.0.0.1:8443` and points the
|
||||
TLS vars at `certs/localhost.pem` (mounted via the existing source mount).
|
||||
`.gitignore` ignores `/certs/`; `.env.example` documents the mkcert steps.
|
||||
- **Why:** Claude Desktop's custom MCP connector only accepts `https://` URLs.
|
||||
Local TLS with an mkcert-trusted cert lets `https://localhost:8443/mcp` work
|
||||
without exposing the unauthenticated app via a public tunnel (AGENT.md §8.1).
|
||||
- **Affects:** `internal/config`, `cmd/server/main.go`, `docker-compose.yml`,
|
||||
`.gitignore`, `.env.example`, `AGENT.md` (§8.1, §11).
|
||||
- **Host setup (user-run):** the local CA install (`mkcert -install`) is a
|
||||
security-settings change performed by the user, not the app.
|
||||
|
||||
## 2026-09-20 — Connect Claude Desktop via local stdio bridge (mcp-remote)
|
||||
- **What:** Corrected the Claude Desktop connection method in AGENT.md (§8.1, §11).
|
||||
The GUI "Add custom connector" flow can NOT reach a localhost server — it probes
|
||||
and calls tools from Anthropic's cloud, so `https://127.0.0.1:8443/mcp` fails
|
||||
"couldn't reach the server" even though a local browser reaches it. The working
|
||||
path is a local stdio bridge in `claude_desktop_config.json`:
|
||||
`mcpServers.gitmanager = cmd /c npx -y mcp-remote http://127.0.0.1:8080/mcp`.
|
||||
Added that entry to the user's Claude Desktop config (backup saved alongside).
|
||||
- **Why:** Keep the app localhost-only + unauthenticated (§0) while still letting
|
||||
Claude Desktop drive it. `mcp-remote` runs locally, so it reaches the local
|
||||
endpoint directly — no public exposure, no HTTPS needed for this path.
|
||||
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
|
||||
the repo). HTTPS/`:8443` from the prior entry stays available but is now optional.
|
||||
|
||||
+32
-8
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -18,8 +17,10 @@ import (
|
||||
"gitmanager/internal/config"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/logging"
|
||||
mcpserver "gitmanager/internal/mcp"
|
||||
"gitmanager/internal/render"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -50,6 +51,9 @@ func main() {
|
||||
go scanner.Run(scanCtx)
|
||||
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
||||
|
||||
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
||||
svc := service.New(g, scanner.Index)
|
||||
|
||||
tmpl, err := render.New("web/templates")
|
||||
if err != nil {
|
||||
log.Error("failed to parse templates", "err", err)
|
||||
@@ -79,20 +83,24 @@ func main() {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
e.GET("/api/repos", func(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, scanner.Index.List())
|
||||
return c.JSON(http.StatusOK, svc.ListRepos())
|
||||
})
|
||||
e.GET("/api/repo", func(c echo.Context) error {
|
||||
// Only serve details for a repo we already discovered — never run git
|
||||
// against an arbitrary path supplied in the query string. Clean the
|
||||
// input so separator style (/, \) doesn't defeat the exact-match lookup.
|
||||
path := filepath.Clean(c.QueryParam("path"))
|
||||
base, ok := scanner.Index.Get(path)
|
||||
// The service only serves details for an already-discovered repo — it
|
||||
// never runs git against an arbitrary caller-supplied path (§1.3).
|
||||
detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path"))
|
||||
if !ok {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, repos.BuildDetail(c.Request().Context(), g, base))
|
||||
return c.JSON(http.StatusOK, detail)
|
||||
})
|
||||
|
||||
// MCP server — Claude connects here as a custom connector (§8.1). Same
|
||||
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
|
||||
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
|
||||
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
|
||||
log.Info("mcp server mounted", "path", "/mcp")
|
||||
|
||||
// Serve with graceful shutdown.
|
||||
go func() {
|
||||
if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed {
|
||||
@@ -102,6 +110,22 @@ func main() {
|
||||
}()
|
||||
log.Info("listening", "addr", cfg.ListenAddr)
|
||||
|
||||
// Optional HTTPS listener (same Echo app). Required for the MCP connector,
|
||||
// which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable
|
||||
// cert logs a warning and leaves the app running over HTTP.
|
||||
if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
|
||||
if _, err := os.Stat(cfg.TLSCertFile); err != nil {
|
||||
log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err)
|
||||
} else {
|
||||
go func() {
|
||||
if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("TLS server error", "err", err)
|
||||
}
|
||||
}()
|
||||
log.Info("listening (https)", "addr", cfg.HTTPSAddr)
|
||||
}
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
@@ -12,10 +12,17 @@ services:
|
||||
# Bind all interfaces INSIDE the container so the published port reaches
|
||||
# it; the `ports` mapping below still keeps it localhost-only on the HOST.
|
||||
- LISTEN_ADDR=0.0.0.0:8080
|
||||
# HTTPS for the MCP connector (Claude Desktop only accepts https URLs).
|
||||
# Certs are generated on the host with mkcert (see README/.env.example)
|
||||
# and mounted read-only below.
|
||||
- HTTPS_ADDR=0.0.0.0:8443
|
||||
- TLS_CERT_FILE=/app/certs/localhost.pem
|
||||
- TLS_KEY_FILE=/app/certs/localhost-key.pem
|
||||
# The scanner looks here; matches the volume mount below.
|
||||
- GIT_REPO_ROOTS=/repos
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:8443:8443"
|
||||
volumes:
|
||||
# Source, for hot reload.
|
||||
- .:/app
|
||||
|
||||
@@ -5,16 +5,23 @@ go 1.26
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.15.4
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
@@ -10,23 +16,37 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -14,7 +14,14 @@ import (
|
||||
|
||||
// Config is the typed application configuration.
|
||||
type Config struct {
|
||||
ListenAddr string // address the HTTP server binds to
|
||||
ListenAddr string // address the plain HTTP server binds to
|
||||
|
||||
// TLS: when HTTPSAddr and both cert/key files are set, an HTTPS listener is
|
||||
// started in addition to the HTTP one. Claude Desktop's MCP connector only
|
||||
// accepts https:// URLs, so /mcp must be reachable over TLS (AGENT.md §8.1).
|
||||
HTTPSAddr string // address the HTTPS server binds to ("" disables TLS)
|
||||
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
|
||||
TLSKeyFile string // PEM private key
|
||||
|
||||
RepoRoots []string // roots to scan for git repositories
|
||||
GitBin string // path to the git binary
|
||||
@@ -38,6 +45,9 @@ func Load() (Config, error) {
|
||||
|
||||
c := Config{
|
||||
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||
HTTPSAddr: env("HTTPS_ADDR", ""),
|
||||
TLSCertFile: env("TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: env("TLS_KEY_FILE", ""),
|
||||
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
|
||||
GitBin: env("GIT_BIN", "git"),
|
||||
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package mcp exposes GitManager's capabilities to Claude as an MCP server over
|
||||
// Streamable HTTP (AGENT.md §8.1). The tool handlers are THIN ADAPTERS over the
|
||||
// shared service layer (§1.7) — no Git/forge logic lives here. Read tools only
|
||||
// for now; acting/destructive tools arrive with the service methods that back
|
||||
// them, carrying the §1.4 confirmation contract.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
)
|
||||
|
||||
// getRepoInput is the argument schema for the get_repo tool.
|
||||
type getRepoInput struct {
|
||||
Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"`
|
||||
}
|
||||
|
||||
// listReposOutput wraps the repository list. MCP structured output must be a JSON
|
||||
// object, so the SDK-inferred outputSchema has to be type "object" — returning a
|
||||
// bare slice yields type "array", which Claude Desktop rejects at tools/list.
|
||||
type listReposOutput struct {
|
||||
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
|
||||
}
|
||||
|
||||
// NewServer builds the MCP server and registers the (currently read-only) tools.
|
||||
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||
Name: "gitmanager",
|
||||
Title: "GitManager",
|
||||
Version: version,
|
||||
Description: "Discover and inspect the user's local Git repositories.",
|
||||
}, nil)
|
||||
|
||||
// list_repos — no arguments (empty struct = object schema with no properties).
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "list_repos",
|
||||
Description: "List every Git repository GitManager has discovered, each with its current branch, dirty/clean state, ahead/behind counts, and remote names.",
|
||||
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listReposOutput, error) {
|
||||
return nil, listReposOutput{Repos: svc.ListRepos()}, nil
|
||||
})
|
||||
|
||||
// get_repo — details for one already-discovered repository.
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "get_repo",
|
||||
Description: "Get details for one repository: its local branches (with upstreams), recent commits, and remote URLs. The path must be one returned by list_repos.",
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRepoInput) (*mcpsdk.CallToolResult, repos.Detail, error) {
|
||||
detail, ok := svc.RepoDetail(ctx, in.Path)
|
||||
if !ok {
|
||||
return nil, repos.Detail{}, fmt.Errorf("unknown repository %q — call list_repos for valid paths", in.Path)
|
||||
}
|
||||
return nil, detail, nil
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler serves the MCP server over Streamable HTTP. Mount it at /mcp. Like the
|
||||
// rest of the app it is localhost-bound and unauthenticated (§8.1) — the same
|
||||
// server instance backs every session.
|
||||
func Handler(s *mcpsdk.Server) http.Handler {
|
||||
return mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server {
|
||||
return s
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
)
|
||||
|
||||
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
|
||||
// service -> MCP tools, called by an in-memory MCP client.
|
||||
func TestMCPRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "myrepo")
|
||||
if err := os.Mkdir(repoPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repoPath, "init", "-b", "main")
|
||||
runGit(t, repoPath, "config", "user.email", "test@example.com")
|
||||
runGit(t, repoPath, "config", "user.name", "Test")
|
||||
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hi\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repoPath, "add", "-A")
|
||||
runGit(t, repoPath, "commit", "-m", "first commit")
|
||||
|
||||
// Populate the index via the real scanner, then build service + MCP server.
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
g := git.New("git")
|
||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||
scanner.Refresh(context.Background())
|
||||
|
||||
svc := service.New(g, scanner.Index)
|
||||
srv := NewServer(svc, "test")
|
||||
|
||||
// Wire an in-memory client<->server session.
|
||||
ctx := context.Background()
|
||||
clientT, serverT := mcpsdk.NewInMemoryTransports()
|
||||
serverSession, err := srv.Connect(ctx, serverT, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("server connect: %v", err)
|
||||
}
|
||||
defer serverSession.Close()
|
||||
|
||||
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0"}, nil)
|
||||
cs, err := client.Connect(ctx, clientT, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("client connect: %v", err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// list_repos should find our one repo.
|
||||
res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "list_repos"})
|
||||
if err != nil {
|
||||
t.Fatalf("list_repos: %v", err)
|
||||
}
|
||||
var listOut listReposOutput
|
||||
decodeResult(t, res, &listOut)
|
||||
states := listOut.Repos
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected 1 repo, got %d: %+v", len(states), states)
|
||||
}
|
||||
if states[0].Name != "myrepo" || states[0].Branch != "main" {
|
||||
t.Fatalf("unexpected repo state: %+v", states[0])
|
||||
}
|
||||
|
||||
// get_repo should return detail including the commit we made.
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "get_repo",
|
||||
Arguments: map[string]any{"path": states[0].Path},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get_repo: %v", err)
|
||||
}
|
||||
var detail repos.Detail
|
||||
decodeResult(t, res, &detail)
|
||||
if len(detail.Commits) != 1 || detail.Commits[0].Subject != "first commit" {
|
||||
t.Fatalf("unexpected detail commits: %+v", detail.Commits)
|
||||
}
|
||||
|
||||
// get_repo with a bad path is a tool error, not a protocol error.
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "get_repo",
|
||||
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get_repo(bad) protocol error: %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatalf("expected IsError for unknown repo, got success")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeResult unmarshals the JSON text content of a tool result into v.
|
||||
func decodeResult(t *testing.T, res *mcpsdk.CallToolResult, v any) {
|
||||
t.Helper()
|
||||
for _, c := range res.Content {
|
||||
if tc, ok := c.(*mcpsdk.TextContent); ok {
|
||||
if err := json.Unmarshal([]byte(tc.Text), v); err != nil {
|
||||
t.Fatalf("unmarshal result: %v (text=%s)", err, tc.Text)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("no text content in result: %+v", res.Content)
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package service is the ONE capability layer behind both the HTTP API and the
|
||||
// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin
|
||||
// adapters that call these methods; Git/forge logic never lives in a handler.
|
||||
// Everything here goes through the internal/git (and later internal/forge)
|
||||
// boundaries and obeys the safety rules (§1.4).
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
)
|
||||
|
||||
// Service holds the shared dependencies the capabilities need.
|
||||
type Service struct {
|
||||
git *git.CLI
|
||||
index *repos.Index
|
||||
}
|
||||
|
||||
// New builds a Service over the git boundary and the scanner's repo index.
|
||||
func New(g *git.CLI, index *repos.Index) *Service {
|
||||
return &Service{git: g, index: index}
|
||||
}
|
||||
|
||||
// ListRepos returns a snapshot of every discovered repository.
|
||||
func (s *Service) ListRepos() []repos.State {
|
||||
return s.index.List()
|
||||
}
|
||||
|
||||
// GetRepo returns the cached state for one repo, or false if it is not indexed.
|
||||
// The path is cleaned so separator style does not defeat the exact-match lookup.
|
||||
func (s *Service) GetRepo(path string) (repos.State, bool) {
|
||||
return s.index.Get(filepath.Clean(path))
|
||||
}
|
||||
|
||||
// RepoDetail returns the enriched detail (branches, commits, remotes) for one
|
||||
// repo. The second return is false when the path is not an indexed repository —
|
||||
// we never run git against an arbitrary caller-supplied path (§1.3).
|
||||
func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) {
|
||||
base, ok := s.index.Get(filepath.Clean(path))
|
||||
if !ok {
|
||||
return repos.Detail{}, false
|
||||
}
|
||||
return repos.BuildDetail(ctx, s.git, base), true
|
||||
}
|
||||
Reference in New Issue
Block a user