vcs-mcp — the Model Context Protocol server
vcs-mcp is a Model Context Protocol server that drives a
single git/jj repository — and, optionally, its GitHub/GitLab/Gitea forge — through the typed
operations of VcsToolkit.Core and VcsToolkit.Forge. It speaks MCP over stdio, so an MCP
client (an IDE, a CLI tool, or any other MCP-capable host) launches it as a subprocess and calls
its tools instead of shelling out to raw git/jj/gh/glab/tea commands.
The server binary lives in VcsToolkit.Mcp.Server; its hermetically-testable core (the tool
catalogue, the dispatcher, the write policy, argument parsing) lives in the VcsToolkit.Mcp
library, which the binary wires to the official ModelContextProtocol SDK.
Installation
vcs-mcp is packaged as a .NET global tool, but the first package has not been published to
NuGet.org yet. To evaluate the current source, build a local package and install from it:
|
After the first public release, the normal install command will be:
|
This requires the .NET 10.0 SDK or later on PATH. The vcs-mcp package is self-contained: it
bundles every VcsToolkit.* assembly it needs inside the package itself (a framework-dependent
dotnet pack/publish of the tool project), so dotnet tool install does not need to restore
sibling NuGet dependencies separately. Update or remove it the same way as any other global tool:
|
Once installed, the vcs-mcp command is on PATH. The server itself drives whatever VCS/forge
CLIs the repository needs (git, jj, gh, glab, tea) as subprocesses — install and
authenticate the ones you intend to use before pointing an MCP client at the server.
Running
|
The server speaks MCP over stdio (JSON-RPC on stdin/stdout); it is meant to be launched by an
MCP client, not run interactively. vcs-mcp --help prints the same reference below.
CLI flags
Flag |
Argument |
Default |
Semantics |
|---|---|---|---|
|
a filesystem path |
current directory ( |
The repository to serve. Opened once at startup via |
|
one of the three literals |
none (auto-detect) |
Force the forge used by every |
|
(flag, no argument) |
off |
Enable every mutating tool. Takes precedence over |
|
a comma-separated list of tool names (repeatable — later occurrences add to the allowed set) |
empty (no mutating tool allowed) |
Enable only the named mutating tools. Read tools are unaffected — they are always available regardless of this flag. Names are validated up front, at parse time, against the fixed list of mutating tool names ( |
|
a whole, non-negative number of seconds |
|
Per-command deadline applied to every git/jj/forge-CLI subprocess the server spawns. |
|
a whole, non-negative number of bytes |
|
Truncates the large-content read tools ( |
|
(flag, no argument) |
— |
Print the usage text and exit |
An unrecognized flag, or a flag missing its required value, is a fatal startup error (the
process prints a message to stderr and exits 1); it never silently starts with a
partially-parsed configuration — unless -h/--help appears earlier in the argument list.
Argument parsing stops at the first -h/--help it reaches, left-to-right, printing the usage
text and exiting 0 without validating anything after it; a bad flag placed before --help
is still fatal, but the same bad flag placed after it is never reached.
Example invocations
|
Forge auto-detection
Unless --forge names one explicitly, the server tries to detect the forge from the
repository's origin remote at startup. Which command it runs depends on the repository's
detected backend (repo.Kind), not on whether a .git directory happens to exist — and a
valid .jj marker always wins over .git during backend detection, so a git-colocated jj
repo is detected as Jj, not Git:
- On a git-backed repo (
.git-only, no.jj), it asksgit remote get-url origin. -
On a jj-backed repo — colocated with git or not — it instead runs
jj git remote list --ignore-working-copy --color neverand parses theorigin <url>line out of the raw output. A colocated repo's.gitdirectory is never consulted directly for this; jj is the source of truth for the remote list on every jj-backed repo. -
If neither finds an
originremote (or the command itself fails), no forge is configured; everyforge_*tool then fails with an invalid-params error naming--forgeas the fix.
The detected (or forced) URL is classified by ForgeKind.OfRemoteUrl, a security-hardened host
matcher: it recognizes github.com, gitlab.com, and gitea.com/codeberg.org — or a proper
subdomain of one of them (*.github.com, etc.) — via an anchored suffix match, so a
lookalike host such as github.com.attacker.net or notgithub.com does not match. It also
guards IPv6-bracket authorities ([::1]:443) against being spoofed by a bracketed hostname or a
zone-id suffix, and folds only ASCII letters when comparing (never a Unicode case fold that
could complete a spoof). A host it does not recognize (a self-hosted GitLab/Gitea instance, an
on-prem GitHub Enterprise Server, …) is not auto-detected — pass --forge explicitly for
those.
Once resolved, the forge client (gh/glab/tea) carries the same --timeout as the repo
client.
Write policy (WriteGate) and the per-repo write lock
Every tool is either a read tool (a query — always available, regardless of --allow-write/
--allow-tools) or a mutating (write-gated) tool. The fixed set of mutating tool names is
WriteTools.all (see the tool reference below for the full list); it is the single source of
truth both for what --allow-tools accepts and for which tools this gate covers.
The gate itself, WriteGate, is one of three states, chosen once at startup from the CLI flags
and never changed at runtime:
-
*
None* (the default — neither--allow-writenor--allow-toolsgiven): no mutating tool is callable. Every mutating tool call is refused up front with an invalid-params error naming the disabled tool and how to enable it. - *
All* (--allow-write): every mutating tool is callable. -
*
Set of Set<string>* (--allow-tools a,b,c, one or more times): only the named tools are callable; every other mutating tool is still refused.--allow-writeoverrides this when both are given.
Each mutating tool method checks WriteGate.Allows <its own name> before doing anything else —
the gate is enforced per-call, not as a one-time startup switch that could be bypassed by some
other code path.
Per-repo write lock. In addition to the gate, the server holds a single SemaphoreSlim(1,
1) (writeLock) that serializes every tool call that touches the local working copy: all
repo_* mutations, the local-checkout-affecting forge tools (forge_pr_checkout, and
every forge_pr_merge/forge_pr_close call — the lock is taken unconditionally for both,
not only when delete_branch is set; delete_branch=true is what can actually switch/delete the
local checkout, but the server holds the same lock regardless, to keep the locking decision
simple and race-free), and repo_try_merge (a real trial merge that materializes content before
rolling itself back, so it needs the same isolation). An MCP host can dispatch tool calls
concurrently; without this lock, two working-copy mutations could interleave (e.g. a
repo_try_merge probe's materialize-then-rollback racing a repo_commit).
Remote-only forge writes (forge_issue_create, forge_issue_close, forge_issue_reopen, forge_issue_comment,
forge_pr_create, forge_pr_comment, forge_pr_edit, forge_pr_mark_ready, forge_pr_review,
forge_release_create)
do not take this local lock — they only touch the remote forge, and the forge's own server
serializes concurrent requests on its side. forge_pr_merge/forge_pr_close are not in this
remote-only group, even when called without delete_branch: both always take the local lock (see
above).
The write lock is purely a local concurrency guard, not a cross-process or cross-machine
lock: it only serializes calls within one running vcs-mcp process against one repository. It
provides no protection against a second vcs-mcp instance, or a human, mutating the same
working copy at the same time.
Tool reference
Every tool takes a single JSON object of named arguments (its inputSchema, generated from the
metadata below) and returns either a JSON result string or an MCP tool error. Argument tables
below list the argument name, JSON type, and whether it's required; optional arguments may be
omitted entirely.
Legend: R/W = read tool (always available) or write tool (gated, see above); Destructive
= the call, or one of its optional parameters, can irrecoverably discard data; Idempotent =
calling twice with the same arguments leaves the same end state as calling once. Every write
tool additionally requires --allow-write, or --allow-tools naming it.
repo_* — repository tools (git/jj, via VcsToolkit.Core)
Reads (always available)
Tool |
Purpose |
Arguments |
|---|---|---|
|
A batched snapshot of the repo state: branch, upstream, ahead/behind, HEAD, dirtiness, change count, conflict, and operation state. |
— |
|
Which backend (git/jj), the repository root, the working directory, and the configured forge (if any). |
— |
|
The working-copy changes (added/modified/deleted/renamed paths). |
— |
|
Aggregate insertion/deletion/file counts for the working copy. |
— |
|
The working copy's unified diff, serialized per file as JSON. When truncated by |
— |
|
Local branch (git) / bookmark (jj) names. |
— |
|
The current branch/bookmark (null when detached/unset). |
— |
|
Paths with unresolved merge conflicts (repo-relative, |
— |
|
Attached worktrees (git) / workspaces (jj). |
— |
|
Configured remotes (name and URL): git remotes are deduplicated to one entry carrying the fetch URL; jj uses |
— |
|
The content of a file at a revision, subject to |
|
|
Up to |
|
|
Per-line authorship of a file at a revision (git blame / jj file annotate), as a JSON array of lines. When truncated by |
|
Writes (gated)
repo_try_merge is write-gated but not destructive: it spawns a real trial merge that
materializes content in order to detect conflicts, then attempts to roll itself back — like
repo_checkout, materializing content is why it needs write access. The rollback is not
best-effort: on both backends a failed rollback is surfaced as an error rather than silently
returning a probe result that would misdescribe the on-disk state. Rollback failure is rare but
possible — most concretely on jj, where the rollback is refused (rather than clobbering
unrelated work) if the op log has diverged from the captured restore point because a concurrent
jj operation ran against the same repository while the probe was in flight. In that case the
call fails with an internal error and the materialized probe change is left in the working copy
instead of being cleaned up; the catalogue's Idempotent: yes hint below assumes a successful
rollback and does not hold for that failure path — do not retry a failed repo_try_merge
call purely because it is marked idempotent. On a rollback-failure error, inspect repo_status/
repo_conflicts/repo_snapshot (and, on git, repo_abort_in_progress) before deciding whether
it is safe to retry or clean up manually.
Tool |
Purpose |
Arguments |
Destructive |
Idempotent |
|---|---|---|---|---|
|
Probe whether merging |
|
no |
yes (assumes rollback succeeded — see caveat above) |
|
Commit exactly the given paths with a message. |
|
no |
no |
|
Switch the working copy to a branch/bookmark/revision (git checkout / jj edit). |
|
no |
yes |
|
Fetch from the default remote (git fetch / jj git fetch). |
— |
no |
yes |
|
Push an existing branch/bookmark to origin. Fast-forward-only (no |
|
no |
yes |
|
Create a worktree/workspace at |
|
no |
no |
|
Remove the worktree/workspace at |
|
yes |
no |
|
Rebase the current work onto |
|
yes |
no |
|
Abort the in-progress operation, if any (git: merge/rebase |
— |
yes |
yes |
|
Continue the in-progress operation after conflict resolution (git: |
— |
no |
no |
|
Delete a local branch (git) / bookmark (jj). |
|
yes |
no |
|
Rename a local branch (git) / bookmark (jj). Preserves the commits. |
|
no |
no |
|
Start new work on top of |
|
no |
no |
forge_* — forge tools (GitHub/GitLab/Gitea, via VcsToolkit.Forge)
Every forge_* tool requires a configured forge (--forge, or a successfully auto-detected
origin remote); otherwise it fails with an invalid-params error. Tools marked "Unsupported on
Gitea" (or another forge) fail with an invalid-params error naming the unsupported operation on
that forge, rather than silently degrading.
Reads (always available)
Tool |
Purpose |
Arguments |
|---|---|---|
|
Whether the forge CLI reports an authenticated session. |
— |
|
The repository/project on the configured forge. Unsupported on Gitea. |
— |
|
The forge's identity and flat capability map. |
— |
|
Pull/merge requests on the configured forge, open by default and capped at 100 by default. On Gitea, |
|
|
A single pull/merge request by number. |
|
|
Pull/merge requests whose source branch is |
|
|
The PR/MR's coarse CI status. Unsupported on Gitea. |
|
|
The PR/MR's unified diff, serialized per file as JSON. When truncated by |
|
|
Issues on the configured forge, open by default and capped at 100 by default. Every state maps onto the CLI's own filter on all three forges — issues have no merged state, so Gitea needs none of |
|
|
A single issue by number, with body and URL filled. |
|
|
Releases on the configured forge, newest first (up to 100). |
— |
|
A single release by tag. Unsupported on Gitea — filter |
|
Writes (gated)
Tool |
Purpose |
Arguments |
Destructive |
Idempotent |
|---|---|---|---|---|
|
Open an issue, returning the CLI's output (the URL on success). |
|
no |
no |
|
Close an issue (reopenable). |
|
no |
yes |
|
Reopen a closed issue. Unsupported on Gitea ( |
|
no |
yes |
|
Post a comment to an existing issue, returning the CLI's output. |
|
no |
no |
|
Open a pull/merge request, returning the CLI's output (the URL on success). |
|
no |
no |
|
Merge a pull/merge request with a strategy ( |
|
yes |
no |
|
Close a pull/merge request without merging. |
|
yes |
yes |
|
Mark a draft pull/merge request as ready for review. Unsupported on Gitea. |
|
no |
yes |
|
Post a comment to an existing pull/merge request, returning the CLI's output. |
|
no |
no |
|
Edit a pull/merge request's title and/or body (at least one required). Unsupported on Gitea ( |
|
no |
yes |
|
Check out a pull/merge request's branch into the local working copy ( |
|
no |
yes |
|
Submit a review on a pull/merge request: |
|
no |
no |
|
Create a release for a Git tag. |
|
no |
no |
|
Delete a release by tag. Unsupported on Gitea ( |
|
yes |
no |
Output-size budget
repo_show_file, repo_annotate, repo_diff, and forge_pr_diff can return arbitrarily large
output (a full file, a full per-line annotation, a full working-copy diff, or a full pull-request
diff). All four are subject to
--output-budget (default 200000 bytes; 0 disables it), but plain text and JSON arrays use
different truncation policies. Content within the budget passes through unchanged.
Structured JSON and validity. repo_show_file returns plain text, so it continues to
truncate the raw file content at a full UTF-8 character boundary and append a
[truncated: showing N of M bytes] marker. repo_annotate, repo_diff, and forge_pr_diff
instead truncate at an array-item boundary. When truncation is necessary, they return a structured JSON envelope
whose items field contains the retained prefix, truncated is true, shown is the number of
retained items, and total is the original item count. The envelope is always valid JSON. If the
budget cannot fit even the minimum envelope with zero items, the complete empty envelope is still
returned: valid JSON and explicit truncation metadata take precedence over exact adherence to an
impossibly small budget. Consumers of any JSON-array tool can therefore safely parse both the
full array and the truncated envelope as JSON.
Errors
Internally the server classifies every tool failure into one of two kinds, and surfaces each on a different MCP error channel, so a client can tell them apart programmatically without pattern-matching the message text:
-
Invalid params — the caller's input/request was refused: a bad or missing argument, an
unknown tool, a disabled write tool (not covered by
--allow-write/--allow-tools), an unsupported forge operation on the configured forge, or no forge configured at all. This is raised as a JSON-RPC protocol error: the tool call returns no normal result at all — instead the server responds with a JSON-RPC error carryingMcpErrorCode.InvalidParams(the standard invalid-params code) and the human-readable message. Most MCP client SDKs surface this as a failed/thrown call rather than aCallToolResult. -
Internal error — a backend (git/jj/forge CLI) or other internal execution failure, e.g. a
spawn failure or an unexpected CLI exit. This is returned inside a normal MCP tool-call
result with
isError: trueand a single text content block carrying the human-readable message — the MCP convention for execution errors, so the model sees the detail and can self-correct.
So the two kinds are distinguishable on the wire: an invalid-params refusal arrives as a
JSON-RPC protocol error (a failed request with an error code), whereas an internal execution
failure arrives as a successful request whose result has isError: true. A client that needs to
react differently to "your input was wrong" versus "something failed on the backend" can switch
on that channel instead of parsing the message text. Either way, the server process itself keeps
running and the MCP session stays open for the next call.
Example MCP client configuration
Most MCP clients read a mcpServers block naming the command to launch and its arguments. For
example, to serve the current repository read-only, with a 60-second per-command timeout:
|
To also allow committing and pushing (but nothing else destructive):
|
Adjust the block's exact top-level shape (some clients nest it differently, or take command/
args under a different key) to match your specific MCP client's configuration format; the
command/args values themselves are the same regardless.
See also: security model.
VcsToolkit