Skip to content

Foreman repo-cloning MCP#62

Open
kyleve wants to merge 12 commits into
mainfrom
foreman-repo-cloning-mcp
Open

Foreman repo-cloning MCP#62
kyleve wants to merge 12 commits into
mainfrom
foreman-repo-cloning-mcp

Conversation

@kyleve

@kyleve kyleve commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Lets a Cursor agent running inside a Foreman cursor-agent worker session call an MCP tool to spin up another working copy of its repo — a lightweight git worktree or a full git clone — and have Foreman auto-start a worker on it, so the copy immediately becomes a normal Foreman-managed repo. Implements the attached plan.

  • ForemanCore control layerCopyProvenance (worktree|clone + parent + branch) persisted on RepoConfiguration and mirrored on Repo; Codable ControlRequest/ControlResponse + ControlRequestHandler; and ForemanServices intents describe(), adoptAndStartWorker(at:provenance:), removeCopy(at:) (stops/drains the worker before removal, gated on recorded provenance). Removal goes through an injectable RepoCopyRemoving seam.
  • App control socket — a thin ControlServer over a unix-domain socket at App Support/com.stuff.foreman/control.sock (JSON-lines), started/torn down via ForemanSession in the app delegate. Stale socket files are unlinked on start.
  • foreman-mcp — a Node/TypeScript stdio MCP server (the repo's only non-Swift module) with spinup_repo_copy, list_repos, remove_repo_copy. Creation git runs locally; worker lifecycle + removal are delegated to Foreman over the socket, with graceful degradation when Foreman is down.
  • Minimal UI — a worktree/clone provenance badge on the sidebar row, a Copy section + Remove Copy… action (with confirmation + error alert) in the worker detail.
  • Docs — new foreman-mcp README/AGENTS and updates to the Foreman app, ForemanCore, and root docs.

Boundary: the MCP owns creation git; Foreman owns worker lifecycle and copy removal, so the Remove-copy path is identical from the MCP or the UI.

Registration (not in-repo)

The server is registered per-user in ~/.cursor/mcp.json (points node at the built dist/index.js with FOREMAN_CONTROL_SOCKET); build it with npm install && npm run build in Foreman/foreman-mcp/.

Test plan

  • ./swiftformat --lint — clean
  • tuist test Foreman-macOS-Tests (ForemanCore + app) — green, incl. new control/provenance/remover suites
  • foreman-mcp: npm run build + npm test (smoke + integration vs mock socket and a temp git repo) — green
  • Live socket check: launched the built app, queried describe over the real socket
  • Manual E2E from a real Foreman worker session (call spinup_repo_copy → new copy appears → worker auto-starts)

Made with Cursor

kyleve and others added 12 commits July 5, 2026 23:36
Groundwork for the repo-cloning MCP: the transport-agnostic half of
Foreman's control endpoint lives in Core so it can be unit-tested.

- CopyProvenance (worktree|clone + parent repo + branch), persisted on
  RepoConfiguration and mirrored on Repo.provenance so a copy's origin
  survives and can be removed correctly.
- Codable ControlRequest/ControlResponse + DTOs and a ControlRequestHandler
  mapping requests to ForemanServices; this is the wire contract foreman-mcp
  mirrors.
- ForemanServices intents: describe(), adoptAndStartWorker(at:provenance:)
  (validates the path is a direct child of the scan dir), and removeCopy(at:)
  (stops + drains the worker before removal, gated on recorded provenance).
- RepoCopyRemoving/SystemRepoCopyRemover seam (git worktree remove / trash a
  clone), injected so tests never touch the filesystem.
- ControlError messages via the generated-symbol catalog.

Covered by new Swift Testing suites using temp git repos and a recording
remover; failures surface honestly rather than being swallowed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a thin ControlServer that owns a unix-domain socket at
App Support/com.stuff.foreman/control.sock (JSON-lines), decodes each request
line, calls ForemanCore's ControlRequestHandler on the main actor, and encodes
the reply. A stale socket file is unlinked on start so a leftover never blocks
binding.

Wired through ForemanSession (startControlServer/stopControlServer) and driven
by the app delegate — started in applicationDidFinishLaunching, torn down in
applicationWillTerminate. ForemanSession also gains removeCopy(_:) and an
actionError channel for the upcoming Remove-copy UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
A Node/TypeScript MCP server (the repo's only non-Swift module) that lets a
Cursor agent spin up copies of the repo it's in and hand them to Foreman.

- spinup_repo_copy(mode, ...): create a git worktree or clone as a sibling
  under Foreman's scan directory (queried over the control socket, falling back
  to the source's parent), then adopt it so Foreman starts a worker. When
  Foreman is down the git copy still succeeds and the reply says plainly that
  no worker started — never a silent success.
- list_repos / remove_repo_copy: delegate to Foreman so removal is identical
  whether triggered here or from the app UI.

Creation git runs locally via execFile (git located off the known install
paths, since GUI-launched processes don't inherit PATH); the control client
mirrors ControlProtocol.swift over JSON-lines. stdout is reserved for JSON-RPC,
so all logging goes to stderr. Covered by smoke + integration tests against a
mock socket and a temp git repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
For repos Foreman created as copies, surface their origin and offer removal —
the view stays thin, with provenance mapping in CopyProvenanceDisplay and the
removal logic already in Core.

- Sidebar row: a worktree/clone badge whose tooltip names the parent repo and
  branch.
- Worker detail: a Copy section (kind / copied-from / branch) plus a
  Remove Copy… button behind a confirmation dialog that calls
  ForemanSession.removeCopy(_:).
- The main window shows an alert bound to the session's action-error channel
  when a removal fails.

New user-facing copy goes through the generated-symbol catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add foreman-mcp's README.md + AGENTS.md (new module) and update the Foreman
app, ForemanCore, and root docs for the control socket, copy provenance, and
the MCP intents. Run ./sync-agents to regenerate the (gitignored) CLAUDE.md
files.

Co-authored-by: Cursor <cursoragent@cursor.com>
The accept DispatchSource ran on a serial queue and served each connection
inline with a blocking, unbounded read, so a peer that connected but never
finished sending its request line stalled the whole endpoint (no further
accepts, no other requests). Only the trusted foreman-mcp connects today, but a
half-open or hung peer shouldn't be able to wedge it.

Accept on a dedicated serial queue and hand each connection to a concurrent
work queue, and set SO_RCVTIMEO on the accepted socket so a stalled read is
abandoned instead of pinning a thread.

Co-authored-by: Cursor <cursoragent@cursor.com>
For an already-adopted copy the intent path called both startIfEnabled() and
retry(). startIfEnabled() already (re)starts an enabled worker whenever it isn't
live — including .failed, which isn't a live state — so retry() was redundant
and could produce a second spawn attempt in the failed->failed case. Keep just
startIfEnabled().

Co-authored-by: Cursor <cursoragent@cursor.com>
removeCopy stops (and drains) the worker before deleting the copy. If the
worktree/clone removal — or the wait for the worker to stop — then failed, the
copy was left on disk but silently stopped and disabled. Make the operation
recover: on any failure after stopping, re-enable the repo so its worker starts
again, leaving the copy exactly as we found it. A precise ControlError
(workerDidNotStop) is preserved; only opaque remover errors are wrapped as
removeFailed. Update the failure test to assert the worker is running again.

Co-authored-by: Cursor <cursoragent@cursor.com>
adoptAndStartWorker validates a copy's parent against the standardized scan
directory, but describe() returned the raw resolvedScanDirectory.path. The MCP
builds the copy path from describe()'s value, so a non-normalized scan directory
(trailing slash, ./.., etc.) could make a legitimately-placed copy fail adopt's
parent-equality check. Return the standardized form so the two always match, and
assert that form in the describe tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
The name argument was only checked for '/'. Tighten it: reject empty names,
leading dots ('.'/'..' escape the scan dir, and a hidden dir is skipped by
Foreman's discovery so the copy could never be adopted), backslashes, and NUL.
The git copy is created before Foreman validates, so this stops an odd name from
placing a copy in an unexpected spot. Covered by new integration cases that
assert the tool errors and sends no control request.

Co-authored-by: Cursor <cursoragent@cursor.com>
The app's ControlServer hid the riskiest new code — byte-level line framing,
EOF/oversize handling, malformed-input replies, response encoding — behind
socket setup, so it had no automated coverage.

Extract that into a transport-agnostic ControlConnection in ForemanCore
(readLine/writeLine + respond(to:handler:)) and have ControlServer delegate to
it, keeping only the socket, accept loop, and threading in the app. Add
ForemanCoreTests that drive the framing and a full request→response round trip
over a real socketpair (incl. multi-line framing, EOF, oversize rejection, and
malformed→failure). Update the module docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Cover the previously-untested clone path: a happy path that asserts the clone is
moved (not hard-deleted) to the volume-appropriate Trash — cleaning the moved
item up afterwards via a uniquely-named directory so no Trash litter is left —
and an error path asserting a missing item surfaces rather than being swallowed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant