Foreman repo-cloning MCP#62
Open
kyleve wants to merge 12 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lets a Cursor agent running inside a Foreman
cursor-agent workersession call an MCP tool to spin up another working copy of its repo — a lightweightgit worktreeor a fullgit clone— and have Foreman auto-start a worker on it, so the copy immediately becomes a normal Foreman-managed repo. Implements the attached plan.CopyProvenance(worktree|clone + parent + branch) persisted onRepoConfigurationand mirrored onRepo; CodableControlRequest/ControlResponse+ControlRequestHandler; andForemanServicesintentsdescribe(),adoptAndStartWorker(at:provenance:),removeCopy(at:)(stops/drains the worker before removal, gated on recorded provenance). Removal goes through an injectableRepoCopyRemovingseam.ControlServerover a unix-domain socket atApp Support/com.stuff.foreman/control.sock(JSON-lines), started/torn down viaForemanSessionin the app delegate. Stale socket files are unlinked on start.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.foreman-mcpREADME/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(pointsnodeat the builtdist/index.jswithFOREMAN_CONTROL_SOCKET); build it withnpm install && npm run buildinForeman/foreman-mcp/.Test plan
./swiftformat --lint— cleantuist test Foreman-macOS-Tests(ForemanCore + app) — green, incl. new control/provenance/remover suitesforeman-mcp:npm run build+npm test(smoke + integration vs mock socket and a temp git repo) — greendescribeover the real socketspinup_repo_copy→ new copy appears → worker auto-starts)Made with Cursor