Skip to content

MCP server for the sessions Drydock hosts: review-comment loop, bounded worktree fan-out, workspace awareness - #4

Merged
jbachorik merged 36 commits into
mainfrom
feat/mcp
Jul 27, 2026
Merged

MCP server for the sessions Drydock hosts: review-comment loop, bounded worktree fan-out, workspace awareness#4
jbachorik merged 36 commits into
mainfrom
feat/mcp

Conversation

@jbachorik

Copy link
Copy Markdown
Contributor

MCP server for the sessions Drydock hosts

Drydock owns state the claude sessions inside it cannot reach: line-anchored review annotations, the worktree graph, the cross-repo registry, the session/tab graph. This PR exposes that over an MCP server hosted inside the app, which the sessions Drydock spawns connect back into over localhost HTTP.

Direction matters: Drydock is the server, the hosted sessions are the clients. Drydock as an MCP client is explicitly out of scope.

The six tools

  • review_comments / review_reply — the loop this feature exists for. A human marks up a diff in the Review tab; the agent reads the threads on demand and reports back what it did. Comments carry base_branch and a source excerpt, because an annotation anchored to a line of a diff the agent cannot see is unusable, and the excerpt keeps later comments aligned as the agent's own edits shift line numbers.
  • worktree_create / session_start — bounded fan-out into visible sidebar entries and terminal tabs, rather than git worktree add into directories nobody sees.
  • repos_list / sessions_list — read-only workspace awareness, the cross-repo picture a cwd-bound harness structurally lacks.

This is not net-new surface: the review loop already existed as keystroke injection (ReviewView.sendToClaude). What MCP adds is re-reading on demand mid-task and writing back — and discovery deliberately stays with the existing Send button.

Bounding fan-out, and what the token is not

A session started via session_start gets Spawn.FORBIDDEN, so it cannot spawn again — depth 1 — plus a per-session budget of 4 worktrees and 4 sessions, charged before the operation and refunded on failure so the limit can never be exceeded and a failure costs nothing. Without this, one instruction fans out into a dozen billable claude processes that MCP has no tool to clean up.

The per-session token is attribution, not isolation, and the design says so in four places. Every session's config file is readable by any process running as the user, so a sibling agent can read another's token. That is accepted — an agent with a shell can already reach any repo — but the spec names what it actually grants rather than calling it negligible.

Three prerequisite refactors

  • WorktreeNaming moved from app.drydock.ui to app.drydock.git, so the router need not depend on the UI package.
  • AnnotationStore gained change notification, closing a latent lost-update hazard: ReviewView cached cards whose handlers captured the annotation value they were built from, so any second writer meant a human's click could silently discard it. Later replaced outright by an atomic mutate(id, transform); the unconditional update is gone.
  • AnnotationStatus gained ADDRESSED — the agent's claim about its own work, distinct from the human's RESOLVED. This respects the "honest state" rule that retired FIXED: the app never claims a fix on the agent's behalf, but an agent reporting its own work can.

How it was built

Design → adversarial review before implementation → 12-task plan → subagent-driven TDD with a per-task review → whole-branch review → this code review. Both review docs record their own findings tables.

The adversarial pass changed 14 things before a line was written, including two claims that were simply false: per-session isolation, and "create-only means the worst outcome is clutter". It verified against real git that worktree_create(branch="origin/main") would create refs/heads/origin/main and silently shadow the remote-tracking ref — git worktree add exits 0 and warns only on stderr.

Reviews caught what inspection did not:

  • A capital letter defeated a security rule. The remote-shadowing check was case-sensitive; because loose ref lookup is a plain open() on a case-insensitive filesystem, Origin/main shadows origin/main identically. Verified against real git 2.49.
  • Repeated vacuous tests, all mine. A refund-floor test proven empty by mutation; seven branch-name rules that stayed green when deleted; a loopback assertion that would have passed against a 0.0.0.0 bind; control-character bytes that vanished from a markdown code block. Each is now mutation-proved.
  • Three cross-task defects no task-scoped review could see: a self-exiting session kept a live token forever; review_reply could discard a human's RESOLVED; session_start accepted the repository's main checkout.
  • The final code review found a close-ordering bug in a fix from the wave before it — AnnotationStore closed ahead of the listener socket, contradicting the comment sitting between them.

Testing

605 tests green on Gradle 9.6.1 / JDK 26, JUnit 5 with hand-rolled fakes (no mocking library — which is why the router takes a narrow, JavaFX-free McpSessionContext seam and is fully headless-testable).

Covered: transport, auth, Origin/Host (via raw sockets, since HttpClient refuses to set Host), the notification path that decides whether the handshake completes at all, protocol-version negotiation, every tool's error cases, symlink resolution on both sides of the worktree membership test, prompt safety, remote-shadowing branch names, spawn grants, and budget exhaustion.

Not verified — please run before merging

Two checks are unverifiable by automation and are steps 1 and 14 of docs/manual-terminal-checklist.md:

  1. A real claude completing the handshake. The --mcp-config document shape and the negotiated protocol version are untested against an actual client. If either is wrong, all 605 tests stay green and the feature is inert.
  2. The packaged app. jdk.httpserver is in the jlink --add-modules list, verified by reading only. :app:test and :app:run resolve com.sun.net.httpserver from the full JDK, so a missing module is silent everywhere except the shipped app.

ReviewView's FX wiring also has no automated coverage — there is no TestFX harness in this repo — so the change-routing decision was extracted to a tested static method and the rest is verified by reading.

Known follow-ups, documented in the spec

  • Pass caller to McpSessionContext.startSession, removing a repository scan that re-derives what the router already validated and the shared-deadline plumbing it forced.
  • Persist an agentStarted flag: depth 1 is a property of the launch, not the session, so a human resuming restores spawn rights.
  • The remote-session banner promised by an earlier draft and withdrawn rather than built as unreviewed UI in the final task.

🤖 Generated with Claude Code

jbachorik and others added 29 commits July 27, 2026 12:51
Exposes review annotations, worktree/session creation, and read-only
workspace awareness to the claude sessions Drydock spawns, over a
localhost HTTP MCP endpoint with per-session token identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight TDD tasks from registry to session wiring. Also corrects two spec
premises found while grounding the plan: GitStatusService.createWorktree
is already public (so no consolidation is needed), and the shared
--settings file cannot carry a per-session token (so MCP config is a
separate per-session --mcp-config file, capability-gated).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three adversarial reviews (security, implementation correctness, design)
found 16 substantiated issues, all verified against source before acting:

Would have broken the build or the packaged app:
- ReviewView's exhaustive AnnotationStatus switch expression
- WorktreeNaming package-private in app.drydock.ui
- buildResumeCommand dereferences its session argument immediately
- a third build*Command call site, plus 14 SessionManagerTest calls
- jdk.httpserver absent from the jlink module list

Would have corrupted data or shipped inert:
- AnnotationStore has no observers, so a stale ReviewView card's
  read-modify-write silently discards an MCP write
- notifications/initialized answered with -32601 risks the handshake
  never completing

Design fixes: branch-name validation (origin/main shadows the
remote-tracking ref), toRealPath membership, prompt sanitization,
depth-1 fan-out with per-session budgets, repos_list without ssh probes,
review_comments with base_branch and excerpt, review_reply replacing
review_mark_addressed.

Corrected two false claims in the spec: per-session isolation (the
boundary is the user account) and "create-only means the worst outcome
is clutter".

Plan grows from 8 to 12 tasks, reordered so each lands compile-clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pre-check-then-charge pair needed a non-throwing query the registry API
did not expose. Charge-then-refund holds the limit exactly and keeps
failures free, with no extra query.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AnnotationStore is about to gain a second writer (the MCP tool router
on its own executor). ReviewView cached built annotation cards and its
toggle/reply handlers captured the ReviewAnnotation value they were
built from, so a second writer's change would be silently discarded by
a stale read-modify-write. Add AnnotationStore.addChangeListener,
firing outside the store's monitor after add/update/remove/
removeSession; make ReviewView's card handlers re-read by id before
writing, and subscribe to rebuild affected rows. MainWorkspace tracks
each Review view per tab so removeTab can unsubscribe it (lifecycle
symmetry).
…Claude

Review findings on the annotation-change-notification work:

- sendToClaude() computed the SENT write from a captured ReviewAnnotation
  read before promptSender.accept() -- a synchronous hand-off wide enough
  for another writer to append a reply in between. Re-read via byId first,
  same as the toggle/reply handlers.

- AnnotationStore is shared by every open session tab, so most change
  notifications are not about a given ReviewView at all (e.g. deleting
  session A fired a bulk null change every open tab's view reacted to,
  including session B's, wiping an in-progress reply draft). Extracted a
  pure routeSingleAnnotationChange(sessionId, scope, selectedFile,
  rendered, current) decision (IGNORE / REPLACE_ROW / REBUILD_FILE) that
  ignores changes to another session/scope, and a bulk-change check that
  only rebuilds when a currently-rendered card actually vanished.

- A present-but-not-yet-rendered annotation (e.g. a fresh add from another
  writer, on the file already on screen) now routes to REBUILD_FILE
  instead of silently being dropped by replaceCardRow, which can only
  swap an existing row, never insert one.

Added ReviewViewChangeRoutingTest for the pure routing decision (headless,
mirrors ReviewRowModelsTest) and an AnnotationStoreTest documenting the
store-level read-before-write contract sendToClaude's fix relies on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the test asserted nothing: refunding with no prior charge
returns early on the absent counter, so the clamp was never reached and
deleting it left the test green. Charge-then-double-refund fails without
the floor. Split the absent-counter branch into its own test.
Split the weak aRefundNeverDropsTheCounterBelowZero test into two:
- aRefundNeverDropsTheCounterBelowZero now charges, refunds twice, and
  verifies the floor prevents counter underflow (exercises the clamp)
- refundingWithNoPriorChargeIsSilent covers the absent-counter early return

The original test called refund() before charge(), so no counter existed
and the clamp was never reached. The mutation proof (removing the clamp)
confirms the new tests actually exercise both branches of refund().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review verified empirically that Origin/main shadows origin/main on a
case-insensitive filesystem — the macOS default, and this is a macOS app —
so a Set.contains check on the first component is bypassed by one capital
letter. One regionMatches rule covers case, remotes containing slashes, and
the plain hijack, while still accepting originals/x.

Also renamed a test whose name asserted the inverse of its body, and added
one case per previously unpinned rule so deleting any rule turns the suite
red.
…lash-safe

git branch Origin/main (case) and a remote whose own name contains a slash
both bypassed the exact Set.contains(firstComponent) shadow check, because
.git/refs/heads/<name> is a plain open() on the macOS default
case-insensitive filesystem and git permits remote.foo/bar.url. Replace the
check with a case-insensitive regionMatches against "<remote>/" for every
configured remote. Also reject a null remoteNames with McpToolException
instead of letting an NPE escape, and close test-coverage gaps (@{, //,
trailing slash, leading slash/empty component, backslash, control
character, a true isBlank()-only case). Drop the misleading/duplicate
"...IsFine" test whose body asserted rejection.
The fake returned every annotation regardless of caller, so a
cross-session test could only pass if the router filtered again — putting
session ownership back into the adapter the design keeps free of domain
logic. Scoping the fake makes the test pin the interface contract.
FakeMcpSessionContext.annotations() ignored its caller argument, violating
the documented "the calling session's annotations" contract. review_reply
was compensating by re-filtering on sessionId itself, which put session
ownership -- domain logic -- into the router, and let
anotherSessionsAnnotationIsNotAddressable pass for the wrong reason.
Scope the fake instead and drop the router-side filter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds jdk.httpserver to the jlink module list: test and run resolve
com.sun.net.httpserver from the full JDK toolchain, so omitting it would
break only the packaged app, at the first tool call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tool name

Review found three defects in the plan's own test class:
- bindsOnLoopbackOnly asserted on endpointUrl(), a string built from a
  literal, so it would pass if start() bound 0.0.0.0. Now asserts on the
  socket's actual bound address via a new boundAddress() accessor.
- neitherPortNorTokenIsEverLogged compared a 256-bit random token against
  the constant "McpServer" and could not fail. Now also asserts the port
  is absent, which is the half that could realistically regress.
- No test ever passed a Host value, so the Host branch had zero coverage
  and the X-Forwarded-Host stand-in was dead. Replaced with two raw-socket
  tests, since HttpClient refuses to set Host.

Also: a tools/call with a missing or non-string name reached the router's
String switch and NPE'd into a -32603. The server now returns an isError
result instead.
… loopback test

- toolsCall now rejects a missing/non-string tool name with an isError
  result instead of passing null into McpToolRouter's String switch,
  which threw NPE and surfaced as -32603.
- McpServerTest gains a boundAddress()-based rawPost/Socket helper so
  aForeignHostHeaderIsRejected/aLoopbackHostHeaderIsAccepted exercise
  the real Host header (HttpClient cannot set it); the old test used a
  stand-in X-Forwarded-Host header the server never reads.
- Added McpServer.boundAddress() so bindsOnLoopbackOnly asserts on the
  socket's actual bound address rather than a string built from a
  literal; replaced neitherPortNorTokenIsEverLogged (compared a token
  against the constant toString()) with a test that also checks the
  port is absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ory case

A fresh install has no mcp/ directory yet, and Files.list throws
NoSuchFileException for it -- an expected, common case, not a genuine
failure worth a WARNING log line. Split it from the generic IOException
catch, which still warns on real failures. Pinned by a test that installs
a logging Handler and asserts no WARNING-or-above record is published.
Wires the MCP subsystem built in tasks 1-11 into running sessions: every
locally launched claude now gets a per-session --mcp-config file pointing at
a loopback McpServer, and WorkspaceMcpSessionContext answers the router's
questions from the live workspace.

Sessions an agent starts through session_start launch with Spawn.FORBIDDEN,
so they cannot create worktrees or start further sessions -- agent-driven
fan-out is depth 1. Remote SSH sessions get no config at all: claude runs on
the host and cannot reach this machine's loopback address. A failed MCP
startup or config write degrades to a session without Drydock tools rather
than one that fails to launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found checklist item 11 asserting UI that no task implements: the
spec promised a remote session's launch banner would say Drydock tools are
unavailable, and nothing builds it. Rather than add unreviewed UI in the
final task, the promise is withdrawn and recorded as a known legibility
gap with follow-up work named.
jbachorik and others added 7 commits July 27, 2026 12:51
…nused tokens

The session-start path bounded each candidate repository's `git worktree
list` separately while the waiting MCP call bounded the whole thing, so with
enough registered repositories the outer wait timed out, McpToolRouter
refunded the session charge -- and the tab opened anyway, letting an agent
exceed the 4-session limit that bounds real spend. There is now ONE deadline
across the owner lookup AND the FX hop that opens the tab, derived from
WorkspaceMcpSessionContext.START_SESSION_TIMEOUT_SECONDS so the two bounds
cannot drift apart, and the FX side refuses to open a tab once it has passed.

Also:
- mcpConfigFor now takes the detected capabilities and returns empty when
  claude lacks --mcp-config, instead of minting a token and writing a config
  file that mcpConfigFlag then discards. The resume path hit this on every
  capability-detection failure, since NO_CAPABILITIES has the flag false.
- finalizeCreate releases the token and config file on the failure branch: a
  launch that never got a surface never reaches onSurfaceClosed, so its file
  outlived the app.
- McpServer.server/executor/port are volatile; this task made them cross from
  the startup virtual thread to the FX shutdown path, where a stale null read
  would have leaked the bound listener socket.
- sessions_list keys its branch lookup by real path on both sides. A session
  records the path it was opened with while `git worktree list` reports
  realpaths, so on macOS (/var -> /private/var) it reported a null branch for
  essentially every session.
- The `../` excerpt test now writes its bait where the escape resolves to, so
  it fails on containment rather than passing on file absence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re reimplemented

Final whole-branch review found two overstatements. "The escalation is
negligible" understated a sibling token: it can write annotations
attributed as Claude into the human's open Review tab, spend another
session's budget, and open real tabs — acting as the app, not just on
files. And the spec described a git check-ref-format invocation that does
not happen; the rules are reimplemented in Java on purpose.
The fixes touch the same three files (McpToolRouter, McpSessionContext,
WorkspaceMcpSessionContext) from different directions, so they land as one
commit rather than as splits that would not compile in between.

Token lifecycle. markSessionExited is a fourth session-ending path and the
most common one -- the user types `exit`, or claude finishes -- and it
deliberately leaves the surface open, so onSurfaceClosed never runs and the
<base>/mcp/ file kept a live bearer token on disk for as long as the tab
stayed open. It now releases in its success branch (idempotent via the
Optional), through a shared releaseMcpConfigAsync that onSurfaceClosed also
uses. requireLiveSession additionally refuses a caller that is not RUNNING.

Annotation writes. review_reply read a snapshot, checked for RESOLVED, then
replaced by id, so a human clicking Resolve inside that window had their
verdict (and any reply) overwritten with ADDRESSED. AnnotationStore gains
mutate(id, transform) -- re-read, apply and store under the monitor,
listeners fired after releasing it -- and the RESOLVED/FIXED refusal moves
inside the transform, carried out as a checked McpToolException by a private
Refusal wrapper. ReviewView's three read-then-update sites move over too,
and update() is deleted now that nothing calls it.

session_start no longer accepts the repository's main checkout:
realWorktreesOf drops mainCheckout entries, so an agent cannot start a second
claude in the tree the human is working in, and the router names why.

repos_list and sessions_list now share ONE deadline per call instead of
giving every registered repository its own 20s slice, in the same idiom as
MainWorkspace.findWorktreeOwner; an expiry fails the call rather than letting
the next repository start a fresh slice.

initialize echoes the client's requested protocolVersion when it is one this
server supports, and falls back to 2024-11-05 otherwise, instead of always
naming one version and ignoring params.

Tool descriptors declare their required arguments, so the model is told what
review_reply, worktree_create and session_start cannot run without.

Smaller items: session_start catches InvalidPathException alongside
IOException; the annotation change-listener log passes the throwable;
PromptSafety's provably dead second blank check and McpServer's dead
null-result guard are deleted; <base>/mcp/ is created rwx------ (and
tightened if it already exists); review_reply calls requireLiveSession like
the other five tools; McpServer latches a `closed` flag that start() honours,
so a shutdown racing startup cannot leak the listener socket -- the comment
in DrydockApplication claimed that property without the code having it.

The manual checklist gains the self-exit-without-closing-the-tab step, which
item 12 did not cover. One test that could not fail is renamed to what it
actually checks.

605 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… work

The three defects the final review found all lived in seams between tasks,
which is what a task-scoped review cannot see. Also names the four
follow-ups and three accepted residuals so they survive the scratch ledger.
1. DrydockApplication.stop() closed AnnotationStore before McpServer, so an
   in-flight review_reply could reach AnnotationStore.persistAsync after its
   save executor had shut down — a RejectedExecutionException surfacing to the
   agent as a bare "Internal error". The comment between the two closes
   claimed exactly the property the ordering did not provide. Two independent
   review lenses found this; introduced by c58c636.

2. McpServer.close() called shutdownNow() without awaiting termination.
   stop(0) closes the listener but does not wait for exchanges already being
   handled, so a handler blocked in WorkspaceMcpSessionContext.join could still
   be touching SessionManager while stop() closed it on the next line. Now a
   bounded drain, mirroring SessionManager.close.

3. McpToolRouter's class Javadoc claimed it "owns no domain logic", unchanged
   since the stub-only commit while spawn grants, budgets, and argument
   validation were added to it. Reworded to state what is actually true: no
   domain *resolution*, but cross-cutting policy is enforced at the boundary
   deliberately, with the reason.

Suite: 605 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rebase onto origin/main could not carry this: it lived in the merge
commit the rebase discards, and the SPI files it touches were never
conflicted (the branch had not modified them pre-merge), so they replayed
at main's version without it.

- AgentProvider gains supportsMcpConfig(), a cheap static fact modelled on
  supportsRemote(). Claude true; Codex and Pi decline -- --mcp-config is a
  Claude flag. Distinct from the probed ClaudeCapabilities.supportsMcpConfig,
  which stays provider-internal per the SPI's own rule.
- CreateContext/ResumeContext carry Optional<Path> mcpConfig.
- ClaudeAgentProvider appends the flag in mcpConfigFlag, after each remote
  early-return, so a remote session never gets it. No --strict-mcp-config.
- SessionManagerMcpFlagTest is replaced by ClaudeAgentProviderMcpFlagTest:
  the flag is no longer assembled in SessionManager.

Also fixes conflict markers this rebase committed into DrydockApplication:
a 'git add -A' during the Task 12 replay staged that file while it was
still conflicted. Caught by diffing the rebased tree against the verified
pre-rebase merge.
…agTest

The flag is assembled in ClaudeAgentProvider now, not SessionManager, so
the coverage note named a test that no longer exists.
@jbachorik
jbachorik merged commit 8946401 into main Jul 27, 2026
1 check passed
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