Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9d0b3b6
Design: Drydock as an MCP server for its hosted sessions
jbachorik Jul 25, 2026
3f720d9
Plan: Drydock MCP server implementation
jbachorik Jul 25, 2026
603791c
Revise MCP design and plan after adversarial review
jbachorik Jul 25, 2026
915c71d
Plan: charge budget before the operation and refund on failure
jbachorik Jul 25, 2026
1b08244
refactor: move WorktreeNaming from ui to git and make it public
jbachorik Jul 25, 2026
14eafdc
fix(review): notify on annotation changes and re-read before write
jbachorik Jul 25, 2026
3509997
fix(review): filter change routing to this view and re-read in sendTo…
jbachorik Jul 25, 2026
beeb6d7
feat(review): add ADDRESSED status with full Review-view handling
jbachorik Jul 25, 2026
f19d7be
Plan: correct Task 4 expected test count after the refund addition
jbachorik Jul 25, 2026
2b86fa6
feat(mcp): session tokens with spawn grants and creation budgets
jbachorik Jul 25, 2026
44417e8
Plan: make the refund-floor test actually exercise the floor
jbachorik Jul 25, 2026
6c3fc06
test(mcp): fix refund-floor test to actually exercise the clamp
jbachorik Jul 25, 2026
616512f
Plan: correct Task 5 expected test count (22, not 24)
jbachorik Jul 25, 2026
8e4eb11
feat(mcp): line-key, branch-name, and prompt-safety validators
jbachorik Jul 25, 2026
f766999
Plan: make the remote-shadowing rule case-insensitive and slash-aware
jbachorik Jul 25, 2026
00a08a1
fix(mcp): make BranchNames remote-shadow check case-insensitive and s…
jbachorik Jul 25, 2026
a8c5d14
feat(mcp): read-only tools for review comments, repos, and sessions
jbachorik Jul 25, 2026
b0f023f
feat(mcp): let an agent reply to review threads and claim them addressed
jbachorik Jul 25, 2026
11b0b90
Plan: make FakeMcpSessionContext honor its session-scoping contract
jbachorik Jul 25, 2026
0ecfd04
fix(mcp): scope the annotations fake by session instead of the router
jbachorik Jul 25, 2026
828f9e0
feat(mcp): worktree_create with branch validation and a budget
jbachorik Jul 26, 2026
d9f7650
feat(mcp): session_start with realpath membership and prompt safety
jbachorik Jul 26, 2026
78913a8
feat(mcp): localhost HTTP transport with MCP handshake and token auth
jbachorik Jul 26, 2026
114428d
Plan: give Task 10 real Host and loopback coverage, and guard a null …
jbachorik Jul 26, 2026
a62dd84
fix(mcp): actionable tool-name error, real Host coverage, non-vacuous…
jbachorik Jul 26, 2026
7b4cfd3
feat(mcp): per-session --mcp-config file and capability gate
jbachorik Jul 26, 2026
85588eb
fix(mcp): purgeStale() no longer warns on the expected missing-direct…
jbachorik Jul 26, 2026
053d42d
feat(mcp): inject per-session MCP config into local claude sessions
jbachorik Jul 26, 2026
fd2e9f3
Withdraw the remote-session banner promise the plan never built
jbachorik Jul 26, 2026
15717c8
fix(mcp): one deadline for an agent session start, and stop minting u…
jbachorik Jul 26, 2026
038fb30
Spec: name what a sibling token actually grants, and that ref rules a…
jbachorik Jul 26, 2026
26d821d
Close the eight findings of the MCP branch's final review
jbachorik Jul 26, 2026
3f24678
Spec: record the final review's cross-task findings and the follow-up…
jbachorik Jul 26, 2026
4009bef
Fix the three findings from the branch code review
jbachorik Jul 26, 2026
0001f7e
Re-home the MCP injection onto the agent-provider SPI
jbachorik Jul 27, 2026
1c993e1
Point the MCP checklist at the test that replaced SessionManagerMcpFl…
jbachorik Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ activity, Git status, and your changes without leaving the app.
terminal tabs, one per session, with session activity surfaced in the UI.
- **Git & GitHub awareness** — repository status, review, and search surfaces
built around the repos you register.
- **MCP tools for your sessions** — sessions Drydock starts can call back into
the app: read the review comments you left on a diff and reply to them,
create worktrees and open sessions in them (bounded, and a session an agent
started cannot spawn further ones), and list your registered repositories
and running sessions. Local sessions only; remote SSH sessions do not get
these tools.
- **Persistent state** — registered repositories and window layout persist
across restarts.
- **Remote repositories over SSH** — register a repo on a remote host via
Expand Down
72 changes: 71 additions & 1 deletion app/src/main/java/app/drydock/DrydockApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
import app.drydock.git.WorktreeService;
import app.drydock.github.GitHubService;
import app.drydock.launcher.DockIcon;
import app.drydock.mcp.McpConfigWriter;
import app.drydock.mcp.McpServer;
import app.drydock.mcp.McpSessionRegistry;
import app.drydock.mcp.McpToolRouter;
import app.drydock.mcp.WorkspaceMcpSessionContext;
import app.drydock.review.AnnotationStore;
import app.drydock.search.SessionSearchService;
import app.drydock.state.JsonApplicationStateRepository;
Expand Down Expand Up @@ -61,7 +66,6 @@
import java.util.Base64;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.concurrent.TimeUnit;

/**
* The main window: a {@link SplitPane} with the repository sidebar (plan
Expand Down Expand Up @@ -121,6 +125,7 @@ public final class DrydockApplication extends Application {
private AppShell appShell;
private GitHubService gitHubService;
private AnnotationStore annotationStore;
private McpServer mcpServer;

private boolean shutdownConfirmed;

Expand Down Expand Up @@ -200,6 +205,7 @@ private void startOnFxThread(Stage primaryStage) {
mainWorkspace, viewModel);

installSessionActivityHooks(activityDir);
startMcpServer(stateDir);

appShell = new AppShell(primaryStage, WINDOW_TITLE, sidebar, mainWorkspace,
repositoryManager.state().ui().sidebarWidth(),
Expand Down Expand Up @@ -800,6 +806,17 @@ public void stop() {
if (gitHubService != null) {
closeQuietly("GitHubService", gitHubService::close);
}
// Before EVERY service a tool call can reach -- AnnotationStore and
// SessionManager both are -- because once the listener socket is gone
// and the in-flight handlers have drained, no tool call can arrive
// mid-teardown and touch a half-closed service. Closing
// AnnotationStore first (as an earlier revision did) let an in-flight
// review_reply reach AnnotationStore.persistAsync after its save
// executor had shut down, surfacing to the agent as a bare
// RejectedExecutionException wrapped in "Internal error".
if (mcpServer != null) {
closeQuietly("McpServer", mcpServer::close);
}
if (annotationStore != null) {
closeQuietly("AnnotationStore", annotationStore::close);
}
Expand Down Expand Up @@ -869,6 +886,59 @@ private void installSessionActivityHooks(Path activityDirectory) {
});
}

/**
* Brings up the localhost MCP server and points subsequently launched
* sessions at a per-session {@code --mcp-config} file, so the sessions
* Drydock hosts can call back into it (see {@code app.drydock.mcp}).
*
* <p>Runs off the FX thread: {@link McpConfigWriter#purgeStale()} and
* {@link McpServer#start()} both do I/O. A failure is logged and swallowed
* -- exactly like {@link #installSessionActivityHooks} -- because a
* session without Drydock tools is strictly better than one that will not
* launch. {@link SessionManager#useMcpConfig} is called inside {@link
* Platform#runLater} for the same reason the hook install's
* {@code useActivitySettings} is.</p>
*
* <p>Never logs the port or a token: the endpoint URL carries the port,
* and the per-session token is written only into its owner-readable
* config file.</p>
*/
private void startMcpServer(Path stateDirectory) {
McpSessionRegistry registry = new McpSessionRegistry();
McpConfigWriter configWriter = new McpConfigWriter(stateDirectory);
WorkspaceMcpSessionContext context = new WorkspaceMcpSessionContext(
sessionManager::sessions,
repositoryManager::repositories,
annotationStore,
gitStatusService,
worktreeService,
UserConfig::load,
(worktree, prompt) -> mainWorkspace.startAgentSession(worktree, prompt));
McpServer server = new McpServer(registry, new McpToolRouter(context, registry));
// Published before start() so a shutdown racing startup still reaches
// it. Publication alone would not be enough -- a close() that wins the
// race would find nothing bound yet and no-op -- which is why
// McpServer.close() also latches a flag that start() honours.
mcpServer = server;

Thread.ofVirtual().name("drydock-mcp-start").start(() -> {
try {
// Every config file left by a previous run is stale by
// definition: no terminal process survives a restart.
configWriter.purgeStale();
server.start();
} catch (IOException | RuntimeException e) {
LOG.log(Level.WARNING, "MCP server unavailable; sessions will run without Drydock tools", e);
server.close();
return;
}
Platform.runLater(() -> {
sessionManager.useMcpConfig(configWriter, registry, server.endpointUrl());
LOG.log(Level.INFO, "MCP server started on loopback");
});
});
}

/**
* Waits briefly for {@link #installSessionActivityHooks}'s file writes, so
* shutdown cannot race a half-written hook script (AGENTS.md: background
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/app/drydock/agent/api/CreateContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@
* Inputs a provider needs to build a create command. {@code sessionId} is the
* app-generated id for {@code PRESET} providers; {@code DISCOVERED} providers
* ignore it. {@code remote}, when present, means launch over SSH.
*
* <p>{@code mcpConfig} is the per-session MCP config file when one was minted
* for this launch, empty otherwise; providers that do not support it (see
* {@code AgentProvider.supportsMcpConfig}) ignore it.</p>
*/
public record CreateContext(String displayName, String sessionId, Path workingDirectory,
Optional<SshRemote> remote) {
Optional<SshRemote> remote, Optional<Path> mcpConfig) {
public CreateContext {
Objects.requireNonNull(displayName, "displayName");
Objects.requireNonNull(sessionId, "sessionId");
Objects.requireNonNull(workingDirectory, "workingDirectory");
Objects.requireNonNull(remote, "remote");
Objects.requireNonNull(mcpConfig, "mcpConfig");
}
}
11 changes: 9 additions & 2 deletions app/src/main/java/app/drydock/agent/api/ResumeContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
import java.util.Objects;
import java.util.Optional;

/** Inputs a provider needs to build a resume command. */
/**
* Inputs a provider needs to build a resume command.
*
* <p>{@code mcpConfig} is the per-session MCP config file when one was minted
* for this launch, empty otherwise; providers that do not support it (see
* {@code AgentProvider.supportsMcpConfig}) ignore it.</p>
*/
public record ResumeContext(Optional<String> agentSessionId, Optional<String> agentSessionName,
Path workingDirectory, Optional<SshRemote> remote) {
Path workingDirectory, Optional<SshRemote> remote, Optional<Path> mcpConfig) {
public ResumeContext {
Objects.requireNonNull(agentSessionId, "agentSessionId");
Objects.requireNonNull(agentSessionName, "agentSessionName");
Objects.requireNonNull(workingDirectory, "workingDirectory");
Objects.requireNonNull(remote, "remote");
Objects.requireNonNull(mcpConfig, "mcpConfig");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ public boolean supportsRemote() {
return true;
}

@Override
public boolean supportsMcpConfig() {
return true;
}

@Override
public LaunchPlan buildCreateCommand(CreateContext c) {
if (c.remote().isPresent()) {
Expand All @@ -102,6 +107,7 @@ public LaunchPlan buildCreateCommand(CreateContext c) {
sessionIdUsed = true;
}
command.append(activitySettingsFlag(caps));
command.append(mcpConfigFlag(caps, c.mcpConfig()));
return LaunchPlan.of(command.toString(), sessionIdUsed);
}

Expand All @@ -116,7 +122,8 @@ public LaunchPlan buildResumeCommand(ResumeContext r) {
}
return LaunchPlan.of(SshCommandBuilder.interactiveSessionCommand(r.remote().get(), exec), false);
}
String suffix = activitySettingsFlag(detectCaps());
ClaudeCapabilities caps = detectCaps();
String suffix = activitySettingsFlag(caps) + mcpConfigFlag(caps, r.mcpConfig());
if (r.agentSessionId().isPresent()) {
return LaunchPlan.of(ENV_CLEANUP_PREFIX + "claude --resume " + AgentCommands.shellQuote(r.agentSessionId().get()) + suffix, false);
}
Expand Down Expand Up @@ -152,7 +159,7 @@ private ClaudeCapabilities detectCaps() {
return capabilityService.detectCapabilitiesBlocking();
} catch (RuntimeException e) {
// Fail conservatively: no name/session-id/settings support (matches NO_CAPABILITIES semantics).
return new ClaudeCapabilities(false, true, false, false, false, "unknown");
return new ClaudeCapabilities(false, true, false, false, false, false, "unknown");
}
}

Expand All @@ -164,4 +171,26 @@ private String activitySettingsFlag(ClaudeCapabilities caps) {
return " --settings " + AgentCommands.shellQuote(settings.get().toString());
}

/**
* Adds {@code --mcp-config <file>} so the session can call back into this
* app (see {@code app.drydock.mcp.McpServer}). Empty whenever the installed
* {@code claude} does not advertise the flag, or no per-session config file
* was minted for this launch -- a session without Drydock tools is strictly
* better than one that fails to launch.
*
* <p>No {@code --strict-mcp-config}: that would suppress the user's own MCP
* servers, and Drydock's tools are an addition to their setup, not a
* replacement.</p>
*
* <p>Only reached after each builder's remote early-return: {@code claude}
* runs on the remote host and cannot reach this machine's loopback
* address, so a remote session never receives a local config path.</p>
*/
private static String mcpConfigFlag(ClaudeCapabilities caps, Optional<Path> mcpConfig) {
if (!caps.supportsMcpConfig() || mcpConfig.isEmpty()) {
return "";
}
return " --mcp-config " + AgentCommands.shellQuote(mcpConfig.get().toString());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public record ClaudeCapabilities(
boolean supportsForkSession,
boolean supportsSessionId,
boolean supportsSettings,
boolean supportsMcpConfig,
String version
) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public final class ClaudeCapabilityService implements AutoCloseable {
private static final Pattern FORK_SESSION_FLAG = Pattern.compile("--fork-session\\b");
private static final Pattern SESSION_ID_FLAG = Pattern.compile("--session-id\\b");
private static final Pattern SETTINGS_FLAG = Pattern.compile("--settings\\b");
// Not "--mcp-config\\b": '-' is a non-word character, so \b would also
// match "--mcp-config-verbose". Require a non-flag character after it.
private static final Pattern MCP_CONFIG_FLAG = Pattern.compile("--mcp-config(?![\\w-])");

private final ClaudeExecutableLocator locator;
private final ExecutorService executor;
Expand Down Expand Up @@ -94,9 +97,15 @@ public ClaudeCapabilities detectCapabilitiesBlocking() {
boolean supportsForkSession = FORK_SESSION_FLAG.matcher(help).find();
boolean supportsSessionId = SESSION_ID_FLAG.matcher(help).find();
boolean supportsSettings = SETTINGS_FLAG.matcher(help).find();
boolean supportsMcpConfig = helpMentionsMcpConfig(help);

return new ClaudeCapabilities(supportsName, supportsResume, supportsForkSession, supportsSessionId,
supportsSettings, version);
supportsSettings, supportsMcpConfig, version);
}

/** Package-private for tests: conservative presence check for {@code --mcp-config}. */
static boolean helpMentionsMcpConfig(String helpOutput) {
return MCP_CONFIG_FLAG.matcher(helpOutput).find();
}

private ProcessResult runAndRequireSuccess(Path claude, String arg) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ public boolean supportsRemote() {
return false;
}

/** No {@code --mcp-config} equivalent: Drydock's per-session MCP file is Claude-specific. */
@Override
public boolean supportsMcpConfig() {
return false;
}

@Override
public LaunchPlan buildCreateCommand(CreateContext c) {
if (c.remote().isPresent()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ public boolean supportsRemote() {
return false;
}

/** No {@code --mcp-config} equivalent: Drydock's per-session MCP file is Claude-specific. */
@Override
public boolean supportsMcpConfig() {
return false;
}

@Override
public LaunchPlan buildCreateCommand(CreateContext c) {
if (c.remote().isPresent()) {
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/app/drydock/agent/spi/AgentProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ public interface AgentProvider {
*/
boolean supportsRemote();

/**
* Whether this integration knows what to do with a per-session MCP config
* file (Claude's {@code --mcp-config}). A static fact about the integration,
* like {@link #supportsRemote()}, so implementations MUST make this CHEAP and
* non-blocking: no process spawns, no I/O. Safe on the JavaFX thread.
*
* <p>Distinct from any probed CLI flag: this says the integration can consume
* such a file at all, not that the installed binary accepts the option. The
* binary check stays provider-internal (Claude's {@code
* ClaudeCapabilities.supportsMcpConfig}), per this interface's rule that
* provider-internal flag detail is not exposed here.</p>
*/
boolean supportsMcpConfig();

LaunchPlan buildCreateCommand(CreateContext c);

LaunchPlan buildResumeCommand(ResumeContext r);
Expand Down
Loading
Loading