ADR-0048: Supply read-only adrkit tools to GitHub Copilot through the plugin extension instead of MCP wiring
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-08 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | component |
| Scope | component |
| Tags | agent-plugin, copilot, mcp, governance |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Relates to | 0007, 0014, 0016, 0028, 0045, 0046 |
| Affects | path:packages/adapters/agent-plugin/extensions/**, path:docs/reference-verification-agent-plugin.md |
| Source | docs/adr/0048-supply-read-only-adrkit-tools-to-github-copilot-through-the-plugin-extension-ins.md |
Status: proposed. Agent-drafted and not ratified. This record amends ADR-0028’s component inventory and the consequence it named (“revisit when a host exposes the workspace to a plugin-spawned server”). It does not reverse ADR-0028’s decision to omit the MCP wiring: the plugin still ships no
.mcp.json. It amends the inventory of ADR-0045 and ADR-0046 (one more registration in the same extension) and supersedes none of them.
Amended in plugin 0.9.1 (a correction, no new record).
adr_checkwithbaseor with neither lists the union ofgit diff <base>...HEAD,git diff HEAD, and untracked files that are not ignored, not the range alone, and a directory git does not treat as a work tree is its own fixed failure (not-work-tree, orgit-unsafe-directoryfor git’s ownership check). The table below states the 0.7.0 default. This record’s status and its read-only and argument-validation boundaries are unchanged.
Context
Section titled “Context”ADR-0028 omitted the plugin’s MCP wiring for a measured reason. Copilot CLI
spawns a plugin’s MCP servers with a working directory that is neither the
workspace nor any Git repository, and nothing in their environment names the
repository. The adrkit MCP server requires a Git worktree root, so it exits
during initialize and logs Failed to start MCP client for adrkit every
session. The plugin’s skill and commands therefore reach adrkit through the
adr CLI, which runs in the agent’s working directory. ADR-0028 called that
“the honest default until a host exposes the workspace to a plugin-spawned
server. Revisit when one does.”
A Copilot extension is such a process. ADR-0045 and ADR-0046 already ship one,
extensions/adrkit/, and the extension process was measured starting in the
session’s directory (Copilot CLI, and an app session for the canvas). The same
joinSession call that registers the workflow and the canvas accepts tools:
plain objects with a name, a description, a JSON Schema, and a handler that
runs in the extension process (Tool in the SDK’s types.d.ts, Copilot CLI
1.0.93; @experimental like the rest of the surface).
So the question ADR-0028 deferred has a concrete form: should the extension give Copilot first-class adrkit tools, and under what boundary? Three things make it a decision rather than a convenience:
- These tools run code on a model’s request. A model that has just read untrusted repository text chooses the arguments, and extension code runs outside Copilot’s per-command shell prompts.
- A bad registration can take the extension down. Measured below: an invalid tool name makes the runtime refuse the whole join, workflow and canvas included.
- The directory is not where it looks. Measured below: a tool invocation
carries no directory, and the extension’s
process.cwd()goes stale when the session’s directory changes.
Decision
Section titled “Decision”We will register three read-only tools from the existing adrkit extension,
in the same guarded joinSession as the workflow and the canvas:
| Tool | Runs | Arguments |
|---|---|---|
adr_check |
adr check --json [--dir d] -- <files> |
paths (repository-relative), or base (files from git diff -z --name-only <base>...HEAD), or neither (origin/main, with the workflow’s fallback); dir |
adr_explain |
adr explain --json [--dir d] -- <path> |
path (one, required); dir |
adr_lint |
adr lint --json [--dir d] |
dir |
The plugin still ships no .mcp.json. The tools are not an MCP server and do
not try to be one; they wrap the same read-only CLI commands the skill and
commands already document.
Boundary.
- The executable is chosen by the environment only, by
resolveClifromreview.mjs, in the workflow’s order:$ADRKIT_CLI, then./node_modules/.bin/adronly whenADRKIT_ALLOW_REPO_CLI=1, thenadronPATH. No tool argument can select or influence the executable; an unknown key such ascliis refused. - Arguments are validated in the extension, because the host hands the
schema to the model but does not enforce it. A path must be a non-empty
string, relative (POSIX, Windows, UNC, and Windows drive-relative
C:fooforms are refused), free of any..segment and of control characters (C0, DEL, C1, the line and paragraph separators, and the bidi embedding, override, and isolate controls), must not start with-, and is at most 1024 characters;pathsholds 1 to 200 entries;baseis a revision of at most 256 characters from a conservative character set that cannot start with-;dirfollows the path rules;pathsandbaseare exclusive. Paths reachadrafter--. Before anything is spawned, the corpus directory is resolved withrealpathand refused with the fixedsymlink-escapemessage if it leaves the session root through a symlink. It is computed once (the argument, else a non-empty$ADRKIT_DIR, elsedocs/adr) and that same value is passed to the CLI as--dir. A directory from$ADRKIT_DIRis the user’s own and is trusted, so an absolute or outside value works. Paths are not realpath-checked: the CLI’s marker reader refuses a path with a symlink component, so one escaping file does not fail a wholeadr_check. - Results never carry exception text. A rejected argument, an unresolvable
$ADRKIT_CLI, a process that cannot start, an output larger than the 64 MiB buffer, a command line too long for the system, a process killed by a signal, and a base git cannot diff (worded differently with and without abase) each return a fixed message selected by code (resultType: "failure"). A rejected argument is never echoed back; a CLI failure after validation may carry the validatedfilesfor context, never an exception’s message. That follows the CodeQLjs/stack-trace-exposurefinding on ADR-0046’s canvas. - A non-zero
adrexit with a report is data. Exit 0 or 1 with JSON on stdout isresultType: "success"with{ exitCode, report };adruses 1 for “found something”. Exit 2, the CLI’s usage-error path, is a failure that carries the CLI’s own message (for exampleCorpus directory not found), capped at 2 KiB with any stack-frame line (^\s+at) removed. Every other exit without a report, including exit 0 or 1 with non-JSON output and a crash, is a failure with a fixed message and the exit code only, because a crashed CLI’s stderr is a stack with install paths. - No result names a writing command. A record’s own text can contain one,
so every string in a result is scrubbed of the record-creating, ratifying,
and migrating
adrsubcommands before it is serialized, the same rule the plugin’s wiring test applies to every component. The separator is matched tolerantly: any run of whitespace (newline and tab included) and format characters (\p{Cf}, which includes U+200B to U+200D, U+2060, and U+FEFF). Scrubbing the serialized JSON would miss a newline, which JSON writes as\n(found in review). - No permission prompt per call (
skipPermission: true). The workflow and the canvas’srefreshalready spawn the same environment-chosen CLI on a model-initiated path without one. The trust posture is identical: the executable comes from the environment, every path is validated, and every subcommand reached is read-only. A prompt on each lookup would teach users to approve it blindly. - Default deferral (
deferunset). In the measured session the three tools were offered directly (deferLoadingabsent among 215 tools).
Working directory. The tools start from the extension’s process.cwd()
and then follow the session.context_changed event’s cwd, accepting only an
absolute path. The invocation carries no directory, and process.cwd() does
not move when the session’s directory does (measured below). The handler is
passed to joinSession as onEvent, which the SDK registers before it issues
the join RPC (measured: events such as session.tools_updated arrived through
it before the join resolved), so a change that arrives while the join is in
flight is kept. A change made before the extension process was forked is
already its process.cwd(). What remains is the window between the fork and
the join RPC, while the module loads; a change there is not replayed, and is
unmeasured.
Registration. register.mjs builds the tools in their own try, so a
throwing factory leaves the workflow and the canvas registered and is logged.
Because the runtime can refuse the whole join over a tool definition, a refused
join is retried without the tools, and then with the workflow alone (the
ADR-0046 fallback for a runtime that does not know canvases); at most three
joins. The runtime’s refusal does not say which piece it objected to, so the
log names what the successful join left out and quotes each refusal, rather
than blaming one piece.
Amended by ADR-0049 (proposed). The tools and the advisory hooks share one ladder: without the hooks, without the tools, without both, without the canvas alone (keeping the tools), then the workflow alone. Without hooks that is at most four joins, not three, a runtime that refuses
canvasesnow keeps the tools, and a join that never succeeds rethrows the first refusal rather than the last.
Commands, skills, and agents do not mention the tools. Those files are shared with Claude Code and opencode, which do not load Copilot extensions, and their CLI path keeps working unchanged in Copilot. The tools appear in Copilot’s tool list with their own descriptions; the README, this record, and the verification doc describe them.
Measured facts
Section titled “Measured facts”All on Copilot CLI 1.0.93 through a headless SDK host (CopilotClient and
createSession({ pluginDirectories, requestExtensions: true, workingDirectory })),
with no model calls: session.rpc.tools.getCurrentMetadata() lists a session’s
tools, and session.rpc.tools.execute({ name, arguments }) invokes one without
a model turn.
- A throwaway probe extension’s tool registered and executed. Its handler’s
invocation had exactly the keys
sessionId,toolCallId,toolName,arguments,availableTools,traceparent,tracestate, andsignal: no directory.process.cwd()was the session’s working directory. - After
session.rpc.metadata.setWorkingDirectory(what/cdcalls), the extension process was not restarted and itsprocess.cwd()stayed at the old directory, while it receivedsession.context_changedwith the newcwd. The tool list was empty untiltools.initializeAndValidate()ran again. - An unknown
joinSessionkey was accepted. A tool namedbad name!made the runtime refuse the whole join (session resume failed: … Tool names may only contain ASCII letters, digits, underscores, and hyphens); a secondjoinSessionfrom the same process then succeeded. A tool namedbashjoined, and thentools.initializeAndValidate()failed for the session (External tool "bash" conflicts with a built-in tool of the same name). Two tools with the same name in one join were accepted. - The shipped tools, from the plugin directory of this change, against a
scratch repository with two accepted ADRs governing
src/**andADRKIT_CLIpointing at the built 0.17.0 CLI: all three appeared in the tool list beside the canvas.adr_checkbypathsand bybase: "HEAD~1", andadr_explain, each named both records as governing;adr_lintchecked 2 records.adr_linton a corpus with a broken record returnedsuccesswithexitCode: 1; on a missing directory,failurewithexitCode: 2and the CLI’s message. An absolute path, a..path, an unknownclikey, and an unresolvable base each returned their fixed message. WithADRKIT_CLIset to a missing path, every tool returned the fixedcli-unresolvedmessage, which shows the variable reaches the extension. AftersetWorkingDirectoryto a second repository with one record,adr_lintchecked 1 record. Anadr-reviewrun with an unknown argument returnedusage-errorfrom the same session, so the workflow still registered beside the tools. - Session creation raised one
extension-permission-accesspermission request and no per-tool request.
Options considered
Section titled “Options considered”Option A: Register read-only tools from the existing extension (chosen)
Section titled “Option A: Register read-only tools from the existing extension (chosen)”One more guarded registration in a process that already exists and already runs in the right directory. No new process, no second extension directory (ADR-0046 measured one extension process per restored app session), and the same CLI resolution the workflow uses.
Option B: Ship the .mcp.json now
Section titled “Option B: Ship the .mcp.json now”Rejected, for ADR-0028’s measured reason, which has not changed: the MCP server would start outside any repository and fail every session.
Option C: Have the extension launch and proxy the MCP server
Section titled “Option C: Have the extension launch and proxy the MCP server”The extension knows the directory, so it could spawn @adrkit/mcp with the
right cwd and forward its tools. Rejected for now: a second long-lived process
per session for three calls the CLI already answers, a dependency the plugin
does not ship, and the MCP server’s own protocol surface to keep in step. The
tools here return the CLI’s JSON, which is what the MCP tools wrap.
Option D: Teach the skill and commands to prefer the tools
Section titled “Option D: Teach the skill and commands to prefer the tools”Rejected. Claude Code and opencode load the same files and never see the tools; a skill that prefers a tool that is not there adds a branch every host must read. The CLI path is correct everywhere, including in Copilot.
Option E: Prompt for permission on each call
Section titled “Option E: Prompt for permission on each call”Rejected as explained under the boundary: the workflow and canvas already run the same CLI without a prompt, and per-lookup prompts train blind approval.
Option F: Do nothing
Section titled “Option F: Do nothing”Leaves Copilot users on the CLI path, which works. The cost is that the model
shells out through a generic tool and parses CLI text, and a repository-local
adr there is whatever the shell finds rather than the gated resolution order.
Trade-offs
Section titled “Trade-offs”skipPermission means a model can run these commands without a prompt. They
are read-only and the executable is environment-chosen, but they do read the
repository, including the first 8 KiB of each named file for @adr markers.
Only a symlinked corpus directory was a
leak: a committed docs/adr (or an argument dir) pointing outside the
worktree made adr lint list the names of the .md files there (measured in
review). Corpus discovery keeps regular files only, so symlinked entries inside
the corpus are never read, and the marker reader refuses symlinked paths. The
tools therefore realpath-check the corpus directory alone and refuse an escape
before spawning (symlink-escape). The check and the spawn are separate steps,
so a symlink swapped between them is not covered; that needs write access to the
worktree, which the model already has through its own tools.
The directory tracking depends on an event the runtime emits today. If a later
runtime stops emitting session.context_changed, or starts restarting the
extension on /cd, the tools stay correct in the second case and go stale in
the first, without an error.
The retry chain trades a loud failure for a quieter one: a tool definition the
runtime refuses now costs the tools and leaves one logged line, where it would
have cost the workflow and the canvas too. On a runtime that refuses canvases,
the chain also drops the tools, because it keeps the workflow alone rather than
try every combination.
The workflow (ADR-0045) reviewed the extension’s process.cwd(), which this
record measured going stale after /cd. Since plugin 0.8.1 the tracker lives
in session-dir.mjs, and the workflow reads it when each run starts, as the
tools do; the canvas keeps ctx.session.workingDirectory and the hooks keep
each input’s workingDirectory. The tools’ half was re-measured headlessly on
Copilot CLI 1.0.93 against the 0.8.1 plugin directory (see
docs/reference-verification-agent-plugin.md, “0.8.1 hardening”); the
workflow’s half is unit-tested, not run live.
Consequences
Section titled “Consequences”- Easier: Copilot’s model gets typed, structured adrkit answers in the session’s repository without shelling out, with the gated CLI resolution order, and the MCP-wiring failure ADR-0028 avoided stays avoided.
- Harder: three more model-facing contracts (names, descriptions, schemas,
result shapes) to keep in step with the CLI’s
--jsonoutput; a third registration in the guarded join; and an experimental API to re-measure on each CLI and app upgrade. - How we would know this was wrong: a tool runs any executable other than
the environment-chosen CLI, or any
adrsubcommand that writes; a path that is absolute, escapes with.., or starts with-reaches argv; a tool result contains an exception message, a stack, or a writing command; a non-zeroadrexit with a report is reported as a failure, or a usage exit as a success; a tool registration failure takes the workflow or the canvas down; a tool name collides with a built-in and breaks the session’s tool initialization; the tools answer for the old repository after/cd; the extension starts a second process or opens a socket for the tools; or users read a tool’sexitCodeas a merge gate. - Revisit if: Copilot gives plugin-spawned MCP servers the workspace directory
(ADR-0028’s condition, which would make Option B viable); the SDK’s
Toolshape orToolInvocationchanges (for example, gains a directory); the runtime stops emittingsession.context_changed; or a fourth tool is proposed.
Evidence rung
Section titled “Evidence rung”Rung 1 under ADR-0014, and only that: unit and contract tests of the validation, result shaping, directory tracking, and registration seam, each observed failing first (ADR-0016), plus the headless Copilot CLI 1.0.93 measurements above. Unmeasured: the tools in the Copilot app (registration, invocation, and which directory they see there); a model choosing and calling them in a real turn; Copilot CLI versions other than 1.0.93; an install from GitHub; and any external validation.
Action items
Section titled “Action items”- Add
extensions/adrkit/tools.mjswith the three tools, argument validation, fixed failure messages, result scrubbing, and directory tracking; wire it throughregister.mjsandextension.mjs. - Add
test/extension-tools.test.ts, observed failing before passing: names, schemas, read-only descriptions, validation, CLI resolution, exit codes, fixed failure text, scrubbing, the invocation signal, directory tracking, and the registration seam (throwing factory, refused join, retry order, at most three joins). - Extend the packaging test’s sibling and required-file lists.
- Measure registration and invocation headlessly against the worktree’s plugin directory, with no model calls.
- Measure the tools in the Copilot app: registration, one invocation, and the directory they report.
- Bump the plugin version on every version-bearing surface in
docs/RELEASING.md, and add a reciprocal note to ADR-0028’s amendment block, when this record is ratified. - Decide separately whether the
adr-reviewworkflow should followsession.context_changedtoo. It does, since plugin 0.8.1, through the sharedsession-dir.mjstracker.