Logo VcsToolkit

Extending VcsToolkit

This is the contributor workflow for adding a new capability: validate the real CLI contract, add the typed implementation, prove it hermetically, document it, and update the changelog and the approved public-API baseline — all in the same change set. Read docs/architecture.md first to pick the right layer: a CLI wrapper (VcsToolkit.Git/Jj/GitHub/GitLab/Gitea) owns one CLI's argv and parsing; a facade (VcsToolkit.Core for git/jj, VcsToolkit.Forge for the three forges) unifies the wrappers behind one backend-agnostic surface; VcsToolkit.Mcp exposes a facade operation as an agent-callable tool. Most new capabilities touch all three layers, from the bottom up — that is the order below.

1. Adding a typed method to a CLI wrapper

Validate the CLI before designing the API — mandatory first step

Before writing a signature, run the installed binary (git/jj/gh/glab/tea) against a disposable, one-shot repository and record what it actually does:

Do not infer glab's or tea's behaviour from a similarly named gh command. The three forge CLIs are three independently developed tools with genuinely different argument parsers, flag names, JSON shapes, and command coverage — tea in particular is missing whole commands gh/glab have (see ForgeCapabilities and ForgeOp in src/VcsToolkit.Forge/Dto.fs), and urfave/cli (tea's CLI framework) even stops recognising --flag tokens once it hits the first bare positional, which gh's and glab's parsers do not do — a defaults-from-gh assumption breaks silently on either. Validate each of the three independently, live.

A small real example: GitHub.PrView (src/VcsToolkit.GitHub/GitHub.fs) is exactly gh pr view <number> --json <fields> — a numeric positional (safe without a guard, see below) plus a fixed field list built from the observed JSON shape:

member _.PrView(dir: string, number: uint64) =
    core.TryParse(core.CommandIn(dir, [ "pr"; "view"; string number; "--json"; PR_FIELDS ]), GitHubParse.parsePr)

Its parser (src/VcsToolkit.GitHub/Parse.fs) reads exactly the JSON field names observed on the real CLI, through the shared total JSON helpers in VcsToolkit.CliSupport.Json (src/VcsToolkit.CliSupport/Json.fs) — Json.strOr returns "" for an absent, null, or wrong-kind field rather than throwing:

let private toPr (el: JsonElement) : PullRequest =
    { Number = Json.u64Or el "number"
      Title = Json.strOr el "title"
      State = Json.strOr el "state"
      HeadRefName = Json.strOr el "headRefName"
      BaseRefName = Json.strOr el "baseRefName"
      Url = Json.strOr el "url"
      Labels = nestedNames el "labels" "name"
      Assignees = nestedNames el "assignees" "login" }

Model an ordinary failure, a meaningful predicate exit code, and "the command failed but still emitted JSON" as three distinct cases — they usually need different handling, and conflating them is a common source of a wrapper method that "mostly works".

Where the option type and the parser live

An operation with two or more options, or any bare boolean, gets its own options record in that client's Types.fs rather than a long positional parameter list — an ambiguous call like prClose(number, true) is exactly what this avoids. PrMerge (src/VcsToolkit.GitHub/Types.fs) is the pattern: named strategy constructors plus fluent With* members, so a call reads as PrMerge.Squash.WithAuto() rather than a tuple of anonymous booleans:

type PrMerge =
    { Strategy: MergeStrategy
      Auto: bool
      DeleteBranch: bool }

    static member Merge = { Strategy = MergeStrategy.Merge; Auto = false; DeleteBranch = false }
    static member Squash = { Strategy = MergeStrategy.Squash; Auto = false; DeleteBranch = false }
    static member Rebase = { Strategy = MergeStrategy.Rebase; Auto = false; DeleteBranch = false }
    member this.WithAuto() = { this with Auto = true }
    member this.WithDeleteBranch() = { this with DeleteBranch = true }

The parser lives in that client's Parse.fs as a private JsonElement -> 'T mapper composed with the shared Json.parseObject/Json.parseArray (VcsToolkit.CliSupport), never inline at the call site — every other method on the client reuses the same tolerant-parsing contract.

Guard positional argv before spawning

Any caller-supplied value that lands in a bare positional argv slot must be checked by rejectFlagLike (src/VcsToolkit.CliSupport/Classify.fs) before the command is built: it refuses an empty/whitespace-only value, a value starting with - (which the driven CLI would parse as a flag instead of the intended positional), or a value containing a NUL byte. checkFlags (src/VcsToolkit.CliSupport/Wrappers.fs) applies the guard to a whole (what, value) list at once, short-circuiting on the first refusal, so one call covers every positional a method forwards:

let checkFlags (program: string) (checks: (string * string) list) : Result<unit, ProcessError> =
    let bad = checks |> List.tryPick (fun (what, value) ->
        match rejectFlagLike program what value with
        | Error e -> Some e
        | Ok() -> None)
    match bad with
    | Some e -> Error e
    | None -> Ok()

Two exemptions worth knowing before over-applying the guard:

For a caller-supplied path list rather than a single value, add the CLI's own -- terminator as defense in depth, so the driven CLI itself stops parsing flags at that point regardless of what a path looks like — Git.Checkout and Git.Blame (src/VcsToolkit.Git/Git.fs) both do this:

core.RunUnit(core.CommandIn(dir, [ "checkout"; reference; "--" ]))
// ...
let args = [ "blame"; "--line-porcelain" ] @ Option.toList rev @ [ "--"; path ]

An embedded NUL byte inside a path needs its own guard even after -- (a NUL cannot be represented in argv at all on either OS) — see checkNoEmbeddedNul in src/VcsToolkit.Git/Git.fs for the pattern on a path list, including the point where it routes through a NUL-safe stdin transport instead of argv once the guard passes.

Why this belongs in the wrapper, not the facade or MCP. Only the wrapper knows the CLI's actual argv layout — which slot is a flag and which is a bare positional, and therefore which values need rejectFlagLike at all. A blanket "reject leading dash" rule applied one layer up (the facade or an MCP tool) would incorrectly refuse a legitimate flag-value like a Markdown body starting with -. The same reasoning applies to exit codes and parsing: only the wrapper has observed the real CLI's exit code contract and JSON shape, so only it can turn a raw ProcessResult into a typed Result — a facade or MCP tool receives an already-typed value and must not re-interpret CLI-specific process mechanics.

Test argv, parsing, and failure paths without a process

Inject a ScriptedRunner (ProcessKit.Testing) so the real command-building and parsing code runs against a scripted reply instead of a live process. The test helpers in tests/VcsToolkit.GitHub.Tests/GitHubTests.fs are the reusable shapes:

let private scripted (tokens: string list) (reply: Reply) =
    GitHub.WithRunner(ScriptedRunner().On(tokens, reply))

// Answers ANY command Ok "" — proves a guard refused BEFORE anything spawned
// (a refusal returns Error; a leak through the guard would return Ok).
let private permissive () =
    GitHub.WithRunner(ScriptedRunner().Fallback(Reply.Ok ""))

// Records the exact argv the runner was called with, for asserting flag
// PRESENCE, ABSENCE, and order — `.On`'s subsequence match alone can't do that.
let private capturing (reply: Reply) : GitHub * ResizeArray<string> = ...
[<Test>]
member _.PrViewBuildsNumberedQuery() : Task =
    task {
        let json = """{"number":42,"title":"t","state":"OPEN","headRefName":"h","baseRefName":"main","url":"u"}"""
        let gh = scripted [ "pr"; "view"; "42"; "--json" ] (Reply.Ok json)
        match! gh.PrView(".", 42UL) with
        | Ok pr -> Assert.That(pr.Number, Is.EqualTo 42UL)
        | Error e -> Assert.Fail $"pr view failed: {e}"
    }

For every new method add: a successful-parse test; an exact-argv test via capturing that also asserts a flag is absent when its option wasn't requested; every observed exit-code case using a failing Reply; and — for any bare positional — a permissive()-backed test proving rejectFlagLike refuses a flag-like value, an empty value, and a NUL byte before the scripted runner is ever reached (assert Error, never inspect argv, since the refusal must happen pre-spawn). Add a real-binary integration test only for a behaviour the hermetic seam genuinely cannot prove (e.g. an actual exit code from a real repository state).

Finish the wrapper layer

2. Adding a facade operation

Add an operation to VcsToolkit.Core (Repo, unifying Git/Jj) or VcsToolkit.Forge (Forge, unifying GitHub/GitLab/Gitea) only when it has a genuinely portable meaning across every backend it claims to unify. A facade is the least common denominator, not one backend's CLI renamed — an operation that only one backend can do at all belongs on that backend's own escape hatch (Repo.Git/ Repo.Jj), not forced onto the shared surface.

Dispatch over the backend

Repo's members pattern-match the bound Backend and forward to a same-named function in GitBackend/JjBackendRepo.Fetch (src/VcsToolkit.Core/Repo.fs) is the minimal shape:

member _.Fetch() =
    match backend with
    | Backend.Git g -> GitBackend.fetch g cwd
    | Backend.Jj j -> JjBackend.fetch j cwd

Forge's members follow the same shape one layer wider, over three backends instead of two, plus the Unsupported case for a backend whose CLI has no equivalent command at all — Forge.PrMarkReady (src/VcsToolkit.Forge/Forge.fs):

member _.PrMarkReady(number: uint64) =
    match backend with
    | Backend.GitHub(c, _) -> GitHubForge.prMarkReady c cwd number
    | Backend.GitLab(c, _) -> GitLabForge.prMarkReady c cwd number
    | Backend.Gitea _ -> task { return Error(ForgeError.Unsupported(ForgeKind.Gitea, "prMarkReady")) }
    | Backend.Unknown -> task { return Error(ForgeError.Unsupported(ForgeKind.Unknown, "prMarkReady")) }

Add the dispatch arm for every backend explicitly — there is deliberately no catch-all _ -> Unsupported arm, so a new backend (or a new option on an existing operation) forces every call site to state its support decision rather than silently inheriting one.

Make divergence explicit — never silently drop a requested feature

Keep only the fields/requirements that mean the same thing on every backend on the facade's DTO; use an option for information one backend cannot supply, and refuse structurally with Unsupported for an operation or option one backend cannot perform before any spawn — never perform a different, silently-degraded operation instead of what was actually requested.

Forge.PrMerge (src/VcsToolkit.Forge/Forge.fs) is the real example: every backend maps the merge strategy, but Auto/DeleteBranch are GitHub-only (gh's --auto/ --delete-branch; glab/tea have no confirmed equivalent). The unsupported check runs, and can short-circuit, before the version-gated dispatch below it:

member _.PrMerge(number: uint64, merge: PrMerge) =
    let unsupported =
        match backend with
        | Backend.GitLab _ -> ForgeSupport.unsupportedMerge ForgeKind.GitLab merge
        | Backend.Gitea _ -> ForgeSupport.unsupportedMerge ForgeKind.Gitea merge
        | Backend.GitHub _
        | Backend.Unknown -> None

    match unsupported with
    | Some e -> task { return Error e }
    | None -> gated backend "prMerge" (fun () -> (* dispatch *))

Two different granularities of "unsupported" exist on purpose, and a new operation should pick the right one:

Document the equivalent support matrix (which backend does what, and every Unsupported case) in the owning facade type's doc comments — there is no separate per-facade guide in this repository the way the wrapper clients have none either; the facade member's own doc comment is the source of truth docs/mcp-server.md's tool descriptions must stay consistent with (see part 3).

Test through the facade's construction seam

Build a Repo/Forge handle directly over a scripted wrapper client — Repo.FromGit/ Repo.FromJj, or Forge's equivalent GitHub/GitLab/Gitea constructors — rather than re-testing the wrapper's own argv/parsing a second time. The facade-level test asserts that dispatch reaches the right backend and that Unsupported/Supports behave as documented; the wrapper-level tests (part 1) already own argv and parsing correctness.

A new Repo read method ships with a parity scenario

Rule: every read method added to Repo that has an implementation on both backends also gets a scenario in tests/VcsToolkit.Core.ParityTests — the git-vs-jj matrix that drives each read method through Repo.Open over a real GitSandbox and a real JjSandbox and compares the two facade results with each other, element by element. Scripted-runner tests (above) prove dispatch; only this matrix proves the two backends actually answer the same question the same way, which is the whole promise of the facade.

This is enforced, not just documented: EveryFacadeReadMethodHasAParityScenario (RepoParityTests.fs) reflects over Repo's public instance methods and fails on any member that is neither covered by a scenario nor listed as a non-read operation. So the work when adding a member is:

Scenario steps go through the harness (Harness.fs): write the scenario once against ScenarioRepo's uniform vocabulary (CommitAll, Rename, AddRemote, CreateConflict, …) and it is replayed on both backends. Anything backend-specific — a revision expression (CommittedRev/HistoryRev), how a conflict is produced — belongs in that harness, not in the test body. The suite runs in the ordinary CI test job on all three OSes, where REQUIRE_JJ=1 makes a missing jj a failure instead of a skip.

3. Exposing an operation in MCP

An MCP tool in VcsToolkit.Mcp (src/VcsToolkit.Mcp/) is a thin, policy-enforcing adapter over an existing Repo/Forge operation — it must never assemble CLI argv or duplicate wrapper-level validation. Add the server method (Server.fs), its ToolSpec entry (Catalog.fs), and — if it mutates — its WriteTools.all entry (WriteGate.fs), together.

Name and describe it

Use repo_* for VcsToolkit.Core operations and forge_* for VcsToolkit.Forge operations. The name is public MCP API, and for a mutating tool it is also the literal string a --allow-tools value must match — WriteTools.all (src/VcsToolkit.Mcp/WriteGate.fs) is the single source of truth both the write gate and Catalog's ReadOnly/ Destructive hints key off of. Add a ToolSpec via Catalog's read/write helper (src/VcsToolkit.Mcp/Catalog.fs), which fix ReadOnly/Destructive and append the write-access sentence automatically:

write
    "forge_pr_merge"
    "Merge a pull/merge request with a strategy (merge|squash|rebase). auto/delete_branch are GitHub-only; on GitLab/Gitea either is refused as Unsupported."
    true   // destructive: an irrecoverable, real state change on the remote
    false  // idempotent: merging twice is not the same as merging once
    [ pNumber; (* ... *) ]

destructive/idempotent are evaluated per tool on its actual worst case (including what its optional parameters can do, e.g. force/delete_branch), not defaulted — a creating call is never idempotent, and a call is destructive the moment any one of its parameter combinations can irrecoverably discard data.

Write-gate every mutation before calling the facade

A mutating tool's server method must check the write gate before doing anything. VcsMcpServer (src/VcsToolkit.Mcp/Server.fs) has three helpers for this, and picking the right one matters:

member this.ForgePrMerge(number: uint64, strategy: string, auto: bool, deleteBranch: bool) =
    this.WithForgeRepoWrite "forge_pr_merge" (fun f -> (* ... f.PrMerge(number, merge) ... *))

A plain repo_* mutation uses the parallel WithRepoWrite(tool, action), which gates and holds the same lock without resolving a forge.

Bound content output

A tool returning potentially large content must apply the server's configured outputBudget using the policy that matches its result type:

Use applyJsonArrayOutputBudget as the pattern for future large JSON-array read tools so truncated responses remain parseable and carry explicit completeness metadata.

Finish the MCP layer

Cross-cutting F# port requirements

These are the conventions a newcomer coming from another language's port of this toolkit is most likely to violate first, because they have no equivalent in most other ecosystems:

See also

Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
Multiple items
val uint64: value: 'T -> uint64 (requires member op_Explicit)

--------------------
type uint64 = System.UInt64

--------------------
type uint64<'Measure> = uint64
type bool = System.Boolean
type 'T list = List<'T>
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
type unit = Unit
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val tryPick: chooser: ('T -> 'U option) -> list: 'T list -> 'U option
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
union case Option.Some: Value: 'T -> Option<'T>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
union case Option.None: Option<'T>
module Option from Microsoft.FSharp.Core
val toList: option: 'T option -> 'T list
type ResizeArray<'T> = System.Collections.Generic.List<'T>
val task: TaskBuilder

Type something to start searching.