From 9fa0c1c3fc31bcfafc4d483126e3cc5da6da2ba3 Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:02:51 +1000 Subject: [PATCH 1/3] feat(mcp-server): add report_session_done and get_worktree_diff tools, document MCP server Closes #99 Expands the MCP tool set so external agents can review their own working-tree diff and report back that a session of work is done (surfaced as a toast in the workspace UI), plus adds a full reference doc for every tool the server exposes. --- app/src/main/__tests__/mcp-bridge.test.ts | 62 +++++- app/src/main/mcp-bridge.ts | 11 + app/src/preload/index.ts | 7 + app/src/renderer/routes/workspace.tsx | 11 + app/src/renderer/settings/McpSection.tsx | 2 +- docs/agent-instructions.md | 1 + docs/mcp-server.md | 196 ++++++++++++++++++ .../src/__tests__/http-server.test.ts | 4 +- .../mcp-server/src/__tests__/tools.test.ts | 51 ++++- packages/mcp-server/src/context.ts | 7 + packages/mcp-server/src/tools.ts | 80 +++++-- packages/types/src/ipc.ts | 2 + packages/types/src/mcp.ts | 8 + 13 files changed, 426 insertions(+), 16 deletions(-) create mode 100644 docs/mcp-server.md diff --git a/app/src/main/__tests__/mcp-bridge.test.ts b/app/src/main/__tests__/mcp-bridge.test.ts index 13f59d73..589fa9c0 100644 --- a/app/src/main/__tests__/mcp-bridge.test.ts +++ b/app/src/main/__tests__/mcp-bridge.test.ts @@ -1,10 +1,39 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { execSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { IPC } from '@sproutgit/types'; import { startMcpServer, stopMcpServer, getMcpStatus, deriveDefaultPort } from '../mcp-bridge.js'; +/** + * Parses a Streamable HTTP response body, which the SDK may send as either + * plain JSON or a single SSE `data:` event depending on internal content + * negotiation — either way there's exactly one JSON-RPC message in it. + * Same helper as packages/mcp-server/src/__tests__/http-server.test.ts. + */ +function parseJsonRpcBody(text: string): unknown { + const dataLine = text.split('\n').find(line => line.startsWith('data: ')); + return JSON.parse(dataLine ? dataLine.slice('data: '.length) : text); +} + +async function callTool(baseUrl: string, token: string, id: number, name: string, args: Record): Promise { + await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + jsonrpc: '2.0', id: 0, method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0' } }, + }), + }); + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }), + }); + return parseJsonRpcBody(await res.text()); +} + function initTestRepo(): string { const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'sg-mcp-bridge-test-'))); execSync('git init', { cwd: dir, stdio: 'ignore' }); @@ -29,6 +58,7 @@ function paramsFor(workspacePath: string, port = 0) { /** No workspace window is open in any of these tests — hooks are simply skipped, matching production behavior when a workspace has no open window. */ const NO_WINDOW = () => null; +const FAKE_WINDOW = { isDestroyed: () => false, webContents: { send: vi.fn() } } as unknown as Electron.BrowserWindow; // None of these tests exercise the MCP create_worktree/remove_worktree tools // (mutatingToolsEnabled is always false), so configDb is never actually touched. const FAKE_CONFIG_DB = {} as Parameters[2]; @@ -107,4 +137,34 @@ describe('mcp-bridge lifecycle', () => { it('getMcpStatus reports not running for a workspace that was never started', () => { expect(getMcpStatus('/never/started')).toEqual({ running: false, port: null }); }); + + it('report_session_done pushes an EVENT_MCP_SESSION_DONE event to the workspace window', async () => { + const repo = initTestRepo(); + repos.push(repo); + const params = paramsFor(repo); + const port = await startMcpServer(params, () => FAKE_WINDOW, FAKE_CONFIG_DB); + + const send = (FAKE_WINDOW as unknown as { webContents: { send: ReturnType } }).webContents.send; + send.mockClear(); + + const body = await callTool(`http://127.0.0.1:${port}`, params.token, 1, 'report_session_done', { + worktreePath: repo, + summary: 'Implemented the feature', + }) as { result?: { isError?: boolean } }; + expect(body.result?.isError).toBeUndefined(); + + expect(send).toHaveBeenCalledWith(IPC.EVENT_MCP_SESSION_DONE, { worktreePath: repo, summary: 'Implemented the feature' }); + }); + + it('report_session_done is a no-op (does not throw) when the workspace window is not open', async () => { + const repo = initTestRepo(); + repos.push(repo); + const params = paramsFor(repo); + const port = await startMcpServer(params, NO_WINDOW, FAKE_CONFIG_DB); + + const body = await callTool(`http://127.0.0.1:${port}`, params.token, 1, 'report_session_done', { + worktreePath: repo, + }) as { result?: { isError?: boolean } }; + expect(body.result?.isError).toBeUndefined(); + }); }); diff --git a/app/src/main/mcp-bridge.ts b/app/src/main/mcp-bridge.ts index f8eef72b..c028e023 100644 --- a/app/src/main/mcp-bridge.ts +++ b/app/src/main/mcp-bridge.ts @@ -17,6 +17,7 @@ import { createHash } from 'node:crypto'; import type { AddressInfo } from 'node:net'; import type { BrowserWindow } from 'electron'; import type { ConfigDb } from '@sproutgit/database'; +import { IPC } from '@sproutgit/types'; import { createHttpApp, type McpServerContext } from '@sproutgit/mcp-server'; import { log } from './telemetry.js'; import { createWorktreeWithHooks, removeWorktreeWithHooks } from './worktree-lifecycle.js'; @@ -103,6 +104,16 @@ export async function startMcpServer( initiatingWorktreePath: null, }, getWindow, configDb); }, + // Purely a UI notification — pushed straight to the renderer, nothing to + // persist. Silently a no-op if the workspace's window isn't open (e.g. + // it was closed after an agent started working), same as other + // best-effort main→renderer pushes in this codebase. + reportSessionDone: async args => { + const win = getWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send(IPC.EVENT_MCP_SESSION_DONE, { worktreePath: args.worktreePath, summary: args.summary }); + } + }, }; const app = createHttpApp(context, params.token); diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index 1311000b..bb746315 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -52,6 +52,7 @@ import type { McpClientId, McpServerStatus, McpConfigWriteResult, + McpSessionDoneEvent, GlobalErrorEvent, ProjectIdeaGenerateResult, } from '@sproutgit/types'; @@ -398,6 +399,12 @@ const api = { return () => ipcRenderer.off(IPC.EVENT_AGENT_TERMINAL_LAUNCH, handler); }, + onMcpSessionDone: (callback: (event: McpSessionDoneEvent) => void) => { + const handler = (_e: Electron.IpcRendererEvent, payload: McpSessionDoneEvent) => callback(payload); + ipcRenderer.on(IPC.EVENT_MCP_SESSION_DONE, handler); + return () => ipcRenderer.off(IPC.EVENT_MCP_SESSION_DONE, handler); + }, + // ── Chat (Integrated agent mode) ───────────────────────────────────────── chatStart: (args: { worktreePath: string; initialPrompt?: string }): Promise<{ sessionId: string; configOptions: ChatConfigOption[] }> => invoke(IPC.CHAT_START, args), diff --git a/app/src/renderer/routes/workspace.tsx b/app/src/renderer/routes/workspace.tsx index cf04acda..589f44b8 100644 --- a/app/src/renderer/routes/workspace.tsx +++ b/app/src/renderer/routes/workspace.tsx @@ -695,6 +695,17 @@ function WorkspaceInner() { return () => { offAgentTerminal(); }; }, []); + // ── MCP session-done listener ──────────────────────────────────────── + + useEffect(() => { + const offMcpSessionDone = api.onMcpSessionDone((event) => { + const name = event.worktreePath.split('/').pop() ?? event.worktreePath; + toast(event.summary ? `${name}: ${event.summary}` : `Agent session finished in ${name}`, 'success'); + }); + + return () => { offMcpSessionDone(); }; + }, [toast]); + // ── Auto-update listeners ───────────────────────────────────────────── useEffect(() => { diff --git a/app/src/renderer/settings/McpSection.tsx b/app/src/renderer/settings/McpSection.tsx index 39931743..7ab9f243 100644 --- a/app/src/renderer/settings/McpSection.tsx +++ b/app/src/renderer/settings/McpSection.tsx @@ -96,7 +96,7 @@ export function McpSection({ onToast, workspacePath }: Props) { MCP Server

- Expose this workspace to MCP-capable agents (list/create worktrees, check status) over a token-protected local HTTP endpoint. + Expose this workspace to MCP-capable agents (list/create worktrees, check status and diffs, report a session done) over a token-protected local HTTP endpoint.

diff --git a/docs/agent-instructions.md b/docs/agent-instructions.md index 76f18dd5..003b6164 100644 --- a/docs/agent-instructions.md +++ b/docs/agent-instructions.md @@ -51,6 +51,7 @@ packages/ types/ ← @sproutgit/types — shared types + ALL IPC channel constants ui/ ← @sproutgit/ui — shared React components providers/github/ ← @sproutgit/provider-github — GitHub OAuth + repo listing + mcp-server/ ← @sproutgit/mcp-server — MCP server exposing worktree tools to external agents, see docs/mcp-server.md ts-config/ ← shared tsconfig e2e/ ← WebdriverIO end-to-end tests website/ ← Astro marketing site diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 00000000..45c727b0 --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,196 @@ +# MCP Server (`packages/mcp-server`) + +SproutGit exposes each open workspace to [MCP](https://modelcontextprotocol.io)-capable +agents (Claude Code, Gemini CLI, Codex CLI, Cursor, Kiro, or any other MCP +client) over a local, token-protected HTTP endpoint. This lets an external +agent see and drive the same worktrees you're working in, instead of shelling +out to raw `git` and guessing at SproutGit's on-disk layout. + +## Enabling it + +In the app: **Settings → MCP Server** (per workspace). + +- Toggle **Enable for this workspace** — starts an HTTP server bound to + `127.0.0.1` on a port that's stable across app restarts (derived + deterministically from the workspace path, overridable in the same panel). +- **Connect an agent** writes the connection (URL + bearer token) straight + into a client's own MCP config file, or **Copy manual config** gives you a + snippet for any client not in that list. +- Endpoint: `http://127.0.0.1:/mcp`, `POST` only (Streamable HTTP + transport, stateless — one request in, one response out). Every request + needs `Authorization: Bearer `; the token and port are per-workspace + and shown in the Settings panel. + +Two layers of protection since this binds a loopback TCP port rather than a +Unix socket: the SDK validates the `Host` header (defeats DNS-rebinding from +a malicious webpage), and the bearer token defends against other local +processes on the same machine (a loopback port, unlike a Unix socket, is +reachable by any process regardless of which user owns it). + +## Tools + +All read-only tools and `report_session_done` are always available once the +server is enabled. `create_worktree` and `remove_worktree` are implemented +but **refuse every call by default** — there's no Settings UI yet to opt a +workspace into mutating tools, so they currently always return an error +explaining that. This is a temporary default, not a capability gap. + +### `list_worktrees` + +Lists every git worktree in the workspace. + +No parameters. + +Returns an array of: + +```ts +{ path: string; head: string | null; branch: string | null; detached: boolean; isExternal: boolean } +``` + +`isExternal` is true for a worktree registered outside SproutGit's managed +worktrees directory (e.g. one another tool created). + +### `get_workspace_info` + +Returns the workspace's root paths and worktree count — useful as a first +call to orient an agent that just connected. + +No parameters. + +```ts +{ workspacePath: string; gitRepoPath: string; managedWorktreesPath: string; worktreeCount: number } +``` + +### `get_worktree_status` + +Working-tree status (staged/unstaged/untracked files) for one worktree. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `worktreePath` | string | yes | Absolute path of the worktree, as returned by `list_worktrees`. | + +```ts +{ + worktreePath: string; + files: Array<{ + path: string; + originalPath: string | null; // set for renames + staged: boolean; + status: string; + indexStatus: string; // raw porcelain index-status character + workTreeStatus: string; // raw porcelain work-tree-status character + }>; +} +``` + +Rejects any `worktreePath` that isn't one of the paths `list_worktrees` +itself just reported for this workspace — call that first. + +### `get_worktree_diff` + +Unified diff of a worktree's current working tree (staged + unstaged changes) +against `HEAD`, optionally scoped to one file. Useful for an agent to review +its own changes before reporting a session done. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `worktreePath` | string | yes | Absolute path of the worktree, as returned by `list_worktrees`. | +| `filePath` | string | no | Restrict the diff to this file, relative to the worktree root. Omit for the full diff. | + +```ts +{ commit: 'WORKING'; base: 'HEAD'; filePath: string | null; diff: string } +``` + +Same `worktreePath` validation as `get_worktree_status`. + +### `report_session_done` + +Tells SproutGit that the calling agent has finished a session of work in a +worktree, so the app can surface it to the user — currently a toast in the +workspace UI (e.g. "feature-x: Implemented the feature"). Purely +informational: it doesn't touch git or the filesystem, so unlike +`create_worktree`/`remove_worktree` it is **not** gated behind the mutating- +tools permission and always runs. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `worktreePath` | string | yes | Absolute path of the worktree the session ran in, as returned by `list_worktrees`. | +| `summary` | string | no | Free-text summary of what the session did. | + +```ts +{ acknowledged: true; worktreePath: string } +``` + +If no SproutGit window is currently open for the workspace, the call still +succeeds — the notification is simply dropped, the same as other best-effort +main-process pushes to the renderer. + +### `create_worktree` — disabled by default + +Creates a new managed worktree branching from a ref. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `newBranch` | string | yes | Name of the new branch to create. | +| `fromRef` | string | no (default `HEAD`) | Ref to branch from, e.g. `main` or `HEAD`. | + +```ts +{ worktreePath: string; branch: string; fromRef: string } +``` + +Runs through the exact same code path as creating a worktree from the UI +(`app/src/main/worktree-lifecycle.ts`), so `before_worktree_create`/ +`after_worktree_create` hooks and provenance recording run identically. + +### `remove_worktree` — disabled by default + +Removes a managed worktree, optionally deleting its branch. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `worktreePath` | string | yes | Absolute path of the worktree to remove, as returned by `list_worktrees`. | +| `deleteBranch` | boolean | no (default `false`) | Also delete the worktree's branch. Requires `branchName`. | +| `branchName` | string | required if `deleteBranch` is true | Branch name to delete. | + +```ts +{ removed: string; deleteBranch: boolean; branchName: string | null } +``` + +Same hook/provenance behavior as `create_worktree`, via +`removeWorktreeWithHooks`. + +## Example: a Claude Code session + +Once the workspace's MCP server is enabled and Claude Code's config has been +written from Settings, a session in that workspace can: + +``` +> list_worktrees +[{ "path": "/repo/.sproutgit/worktrees/feat-x", "branch": "feat-x", ... }] + +> get_worktree_diff { "worktreePath": "/repo/.sproutgit/worktrees/feat-x" } +{ "commit": "WORKING", "base": "HEAD", "filePath": null, "diff": "diff --git a/..." } + +> report_session_done { "worktreePath": "/repo/.sproutgit/worktrees/feat-x", "summary": "Added the retry logic and tests" } +{ "acknowledged": true, "worktreePath": "/repo/.sproutgit/worktrees/feat-x" } +``` + +The last call surfaces a toast in the SproutGit UI for whoever has that +workspace open, without the agent needing any other channel back to the user. + +## Source layout + +| File | Purpose | +|---|---| +| `packages/mcp-server/src/context.ts` | `McpServerContext` — everything a tool handler needs (workspace paths, the mutating-tools gate, and the injected `createWorktree`/`removeWorktree`/`reportSessionDone` callbacks). | +| `packages/mcp-server/src/tools.ts` | Tool handlers (exported standalone for unit testing) and `registerTools()`, which wires them onto a real `McpServer`. | +| `packages/mcp-server/src/server.ts` | `createMcpServer()` — builds one `McpServer` per accepted connection. | +| `packages/mcp-server/src/http-server.ts` | `createHttpApp()` — Express app serving `/mcp`: Host-header validation, bearer-token auth, Streamable HTTP transport. | +| `app/src/main/mcp-bridge.ts` | Owns the per-workspace HTTP server lifecycle in the real app; supplies the real `McpServerContext` (wired to `worktree-lifecycle.ts` and to the renderer window for `report_session_done`). | +| `app/src/main/ipc/mcp.ts` | IPC handlers backing the Settings UI (status, enable/disable, port, write client config, manual snippet). | + +Adding a new tool: add a handler + Zod input schema in `tools.ts`, register it +in `registerTools()`, extend `McpServerContext` if it needs a new capability +from the host app, wire that capability in `mcp-bridge.ts`, and add tests in +`packages/mcp-server/src/__tests__/tools.test.ts` (handler-level) and/or +`app/src/main/__tests__/mcp-bridge.test.ts` (end-to-end over HTTP). diff --git a/packages/mcp-server/src/__tests__/http-server.test.ts b/packages/mcp-server/src/__tests__/http-server.test.ts index cf1c7141..dff983a3 100644 --- a/packages/mcp-server/src/__tests__/http-server.test.ts +++ b/packages/mcp-server/src/__tests__/http-server.test.ts @@ -80,6 +80,7 @@ describe('createHttpApp', () => { // the type. createWorktree: () => { throw new Error('not implemented in this test'); }, removeWorktree: () => { throw new Error('not implemented in this test'); }, + reportSessionDone: () => { throw new Error('not implemented in this test'); }, }; const app = createHttpApp(context, TOKEN); server = createServer(app); @@ -180,7 +181,8 @@ describe('createHttpApp', () => { const listBody = parseJsonRpcBody(await listTools.text()) as { result: { tools: Array<{ name: string }> } }; const names = listBody.result.tools.map(t => t.name); expect(names).toEqual(expect.arrayContaining([ - 'list_worktrees', 'get_worktree_status', 'get_workspace_info', 'create_worktree', 'remove_worktree', + 'list_worktrees', 'get_worktree_status', 'get_worktree_diff', 'get_workspace_info', + 'report_session_done', 'create_worktree', 'remove_worktree', ])); }); }); diff --git a/packages/mcp-server/src/__tests__/tools.test.ts b/packages/mcp-server/src/__tests__/tools.test.ts index 8769962b..d41f8b5e 100644 --- a/packages/mcp-server/src/__tests__/tools.test.ts +++ b/packages/mcp-server/src/__tests__/tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { execSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -9,8 +9,10 @@ import { listWorktreesHandler, getWorkspaceInfoHandler, getWorktreeStatusHandler, + getWorktreeDiffHandler, createWorktreeHandler, removeWorktreeHandler, + reportSessionDoneHandler, MUTATING_TOOLS_DISABLED_MESSAGE, } from '../tools.js'; @@ -37,12 +39,14 @@ describe('mcp tool handlers', () => { let managedWorktreesPath: string; let context: McpServerContext; let mutatingEnabled: boolean; + let reportSessionDoneMock: ReturnType>; beforeAll(() => { repoPath = initTestRepo(); managedWorktreesPath = join(repoPath, '.sproutgit', 'worktrees'); mkdirSync(managedWorktreesPath, { recursive: true }); mutatingEnabled = false; + reportSessionDoneMock = vi.fn().mockResolvedValue(undefined); context = { workspacePath: repoPath, gitRepoPath: repoPath, @@ -54,6 +58,7 @@ describe('mcp tool handlers', () => { // covered by its own tests instead. createWorktree: args => createManagedWorktree(repoPath, managedWorktreesPath, args.fromRef, args.newBranch), removeWorktree: async args => { await deleteManagedWorktree(repoPath, args.worktreePath, args.deleteBranch, args.branchName ?? null); }, + reportSessionDone: reportSessionDoneMock, }; }); @@ -93,6 +98,50 @@ describe('mcp tool handlers', () => { expect(firstText(result)).toMatch(/not a known worktree/); }); + it('get_worktree_diff returns the unified diff for uncommitted changes', async () => { + writeFileSync(join(repoPath, 'README.md'), '# Test Repo\n\nchanged.\n'); + const result = await getWorktreeDiffHandler(context, { worktreePath: repoPath }); + const parsed = JSON.parse(firstText(result)); + expect(parsed.filePath).toBeNull(); + expect(parsed.diff).toMatch(/README\.md/); + expect(parsed.diff).toMatch(/changed\./); + }); + + it('get_worktree_diff scopes to a single file when filePath is given', async () => { + const result = await getWorktreeDiffHandler(context, { worktreePath: repoPath, filePath: 'README.md' }); + const parsed = JSON.parse(firstText(result)); + expect(parsed.filePath).toBe('README.md'); + }); + + it('get_worktree_diff rejects a path that is not a known worktree', async () => { + const result = await getWorktreeDiffHandler(context, { worktreePath: '/not/a/worktree' }); + expect(result.isError).toBe(true); + expect(firstText(result)).toMatch(/not a known worktree/); + }); + + it('report_session_done forwards the call to context.reportSessionDone and acknowledges', async () => { + reportSessionDoneMock.mockClear(); + const result = await reportSessionDoneHandler(context, { worktreePath: repoPath, summary: 'Fixed the bug' }); + expect(result.isError).toBeUndefined(); + expect(reportSessionDoneMock).toHaveBeenCalledWith({ worktreePath: repoPath, summary: 'Fixed the bug' }); + const parsed = JSON.parse(firstText(result)); + expect(parsed).toEqual({ acknowledged: true, worktreePath: repoPath }); + }); + + it('report_session_done defaults summary to null when omitted', async () => { + reportSessionDoneMock.mockClear(); + await reportSessionDoneHandler(context, { worktreePath: repoPath }); + expect(reportSessionDoneMock).toHaveBeenCalledWith({ worktreePath: repoPath, summary: null }); + }); + + it('report_session_done rejects a path that is not a known worktree', async () => { + reportSessionDoneMock.mockClear(); + const result = await reportSessionDoneHandler(context, { worktreePath: '/not/a/worktree' }); + expect(result.isError).toBe(true); + expect(firstText(result)).toMatch(/not a known worktree/); + expect(reportSessionDoneMock).not.toHaveBeenCalled(); + }); + it('create_worktree refuses when mutating tools are disabled', async () => { const result = await createWorktreeHandler(context, { newBranch: 'feature-a', fromRef: 'HEAD' }); expect(result.isError).toBe(true); diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts index cad65250..087da2d6 100644 --- a/packages/mcp-server/src/context.ts +++ b/packages/mcp-server/src/context.ts @@ -31,4 +31,11 @@ export type McpServerContext = { createWorktree: (args: { fromRef: string; newBranch: string }) => Promise; /** Same as createWorktree, but for removal — see app/src/main/worktree-lifecycle.ts. Throws if worktreePath isn't a registered worktree of this repo. */ removeWorktree: (args: { worktreePath: string; deleteBranch: boolean; branchName?: string | null }) => Promise; + /** + * Notifies the host app that a calling agent has finished a session of + * work in a worktree, so the UI can surface it (e.g. a toast). Purely + * informational — doesn't touch git or the filesystem, so unlike + * createWorktree/removeWorktree it isn't gated by mutatingToolsEnabled. + */ + reportSessionDone: (args: { worktreePath: string; summary: string | null }) => Promise; }; diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 4c9746d5..3fc02298 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { listWorktrees, getWorktreeStatus } from '@sproutgit/git'; +import { listWorktrees, getWorktreeStatus, getWorkingDiff } from '@sproutgit/git'; import type { McpServerContext } from './context.js'; function textResult(value: unknown): CallToolResult { @@ -37,22 +37,51 @@ export async function getWorkspaceInfoHandler(context: McpServerContext): Promis }); } +/** + * Only ever operate against a path this workspace itself reported via + * list_worktrees — never an arbitrary path handed in by the calling agent, + * mirroring the IPC rule that file paths must be validated as within the + * workspace before touching the filesystem. Returns an error result if + * `worktreePath` isn't one of them, otherwise `undefined`. + */ +async function assertKnownWorktree(context: McpServerContext, worktreePath: string): Promise { + const { worktrees } = await listWorktrees(context.gitRepoPath, context.managedWorktreesPath); + if (!worktrees.some(w => w.path === worktreePath)) { + return errorResult(`"${worktreePath}" is not a known worktree of this workspace. Call list_worktrees first.`); + } + return undefined; +} + export async function getWorktreeStatusHandler( context: McpServerContext, args: { worktreePath: string }, ): Promise { - // Only ever run git status against a path this workspace itself reported - // via list_worktrees — never an arbitrary path handed in by the calling - // agent, mirroring the IPC rule that file paths must be validated as - // within the workspace before touching the filesystem. - const { worktrees } = await listWorktrees(context.gitRepoPath, context.managedWorktreesPath); - if (!worktrees.some(w => w.path === args.worktreePath)) { - return errorResult(`"${args.worktreePath}" is not a known worktree of this workspace. Call list_worktrees first.`); - } + const knownError = await assertKnownWorktree(context, args.worktreePath); + if (knownError) return knownError; const status = await getWorktreeStatus(args.worktreePath); return textResult(status); } +export async function getWorktreeDiffHandler( + context: McpServerContext, + args: { worktreePath: string; filePath?: string | undefined }, +): Promise { + const knownError = await assertKnownWorktree(context, args.worktreePath); + if (knownError) return knownError; + const diff = await getWorkingDiff(args.worktreePath, args.filePath ?? null); + return textResult(diff); +} + +export async function reportSessionDoneHandler( + context: McpServerContext, + args: { worktreePath: string; summary?: string | undefined }, +): Promise { + const knownError = await assertKnownWorktree(context, args.worktreePath); + if (knownError) return knownError; + await context.reportSessionDone({ worktreePath: args.worktreePath, summary: args.summary ?? null }); + return textResult({ acknowledged: true, worktreePath: args.worktreePath }); +} + export async function createWorktreeHandler( context: McpServerContext, args: { newBranch: string; fromRef: string }, @@ -86,9 +115,10 @@ export async function removeWorktreeHandler( } /** - * Registers the full first-cut MCP tool set against `server`, scoped to a - * single workspace via `context`. Read-only tools (`list_worktrees`, - * `get_worktree_status`, `get_workspace_info`) always run; the mutating + * Registers the full MCP tool set against `server`, scoped to a single + * workspace via `context`. Read-only tools (`list_worktrees`, + * `get_worktree_status`, `get_worktree_diff`, `get_workspace_info`) and the + * purely informational `report_session_done` always run; the mutating * tools (`create_worktree`, `remove_worktree`) check * `context.mutatingToolsEnabled()` first and refuse otherwise. */ @@ -123,6 +153,32 @@ export function registerTools(server: McpServer, context: McpServerContext): voi args => getWorktreeStatusHandler(context, args), ); + server.registerTool( + 'get_worktree_diff', + { + title: 'Get worktree diff', + description: 'Returns the unified diff of a worktree\'s current working tree (staged + unstaged changes) against HEAD, optionally scoped to one file.', + inputSchema: { + worktreePath: z.string().describe('Absolute path of the worktree, as returned by list_worktrees.'), + filePath: z.string().optional().describe('Restrict the diff to this file, relative to the worktree root. Omit for the full diff.'), + }, + }, + args => getWorktreeDiffHandler(context, args), + ); + + server.registerTool( + 'report_session_done', + { + title: 'Report session done', + description: 'Tells SproutGit that the calling agent has finished a session of work in a worktree, so the app can surface it to the user (e.g. a toast).', + inputSchema: { + worktreePath: z.string().describe('Absolute path of the worktree the session ran in, as returned by list_worktrees.'), + summary: z.string().optional().describe('Optional free-text summary of what the session did.'), + }, + }, + args => reportSessionDoneHandler(context, args), + ); + server.registerTool( 'create_worktree', { diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index f3e190e7..bfbee1cd 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -170,6 +170,8 @@ export const IPC = { MCP_SET_PORT: 'mcp:setPort', MCP_WRITE_CLIENT_CONFIG: 'mcp:writeClientConfig', MCP_GET_MANUAL_SNIPPET: 'mcp:getManualSnippet', + // Fired by main when the report_session_done MCP tool is called, so the renderer can toast it. + EVENT_MCP_SESSION_DONE: 'event:mcpSessionDone', // ── Global error reporting (main → renderer push events) ────────────────── EVENT_GLOBAL_ERROR: 'event:globalError', } as const; diff --git a/packages/types/src/mcp.ts b/packages/types/src/mcp.ts index 77277fb6..78f8b8ff 100644 --- a/packages/types/src/mcp.ts +++ b/packages/types/src/mcp.ts @@ -14,3 +14,11 @@ export type McpConfigWriteResult = { /** Absolute path of the config file that was created or merged into. */ configPath: string; }; + +/** Pushed to the renderer when an MCP client calls the `report_session_done` tool, so the UI can surface it (e.g. a toast). */ +export type McpSessionDoneEvent = { + /** Absolute path of the worktree the reporting agent was working in. */ + worktreePath: string; + /** Optional free-text summary the agent provided of what it did. */ + summary: string | null; +}; From b92e193ee09fdb64dadec3d0533b420c65fdeb3d Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:17:17 +1000 Subject: [PATCH 2/3] fix: address review feedback on report_session_done wording and path handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split worktreePath on both / and \ when deriving the toast's short name, so Windows paths show a worktree name instead of the full path. - Fix docs/comment wording that claimed report_session_done "doesn't touch git or the filesystem" — its known-worktree check does read via listWorktrees, same as every other read-only tool. The real guarantee is that it never mutates anything, which is what actually matters for the mutating-tools gate. - Also picks up two doc edits from the previous merge commit that were made to the working tree but never staged (agent-instructions.md's pointer to the Starlight guide, and the guide's new-tools table). --- .../renderer/hooks/useMcpSessionDoneListener.ts | 4 +++- docs/agent-instructions.md | 2 +- packages/mcp-server/src/context.ts | 4 +++- website/src/content/docs/docs/guides/mcp-server.md | 14 +++++++++----- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/renderer/hooks/useMcpSessionDoneListener.ts b/app/src/renderer/hooks/useMcpSessionDoneListener.ts index 927d6073..d7209752 100644 --- a/app/src/renderer/hooks/useMcpSessionDoneListener.ts +++ b/app/src/renderer/hooks/useMcpSessionDoneListener.ts @@ -6,7 +6,9 @@ import type { ToastFn } from "../toast-context.js"; export function useMcpSessionDoneListener(toast: ToastFn) { useEffect(() => { const offMcpSessionDone = api.onMcpSessionDone((event) => { - const name = event.worktreePath.split("/").pop() ?? event.worktreePath; + // Split on both separators — worktreePath comes from the main process + // and may be a Windows path (backslash-separated). + const name = event.worktreePath.split(/[/\\]/).pop() || event.worktreePath; toast( event.summary ? `${name}: ${event.summary}` diff --git a/docs/agent-instructions.md b/docs/agent-instructions.md index 003b6164..15e03720 100644 --- a/docs/agent-instructions.md +++ b/docs/agent-instructions.md @@ -51,7 +51,7 @@ packages/ types/ ← @sproutgit/types — shared types + ALL IPC channel constants ui/ ← @sproutgit/ui — shared React components providers/github/ ← @sproutgit/provider-github — GitHub OAuth + repo listing - mcp-server/ ← @sproutgit/mcp-server — MCP server exposing worktree tools to external agents, see docs/mcp-server.md + mcp-server/ ← @sproutgit/mcp-server — MCP server exposing worktree tools to external agents, see website/src/content/docs/docs/guides/mcp-server.md ts-config/ ← shared tsconfig e2e/ ← WebdriverIO end-to-end tests website/ ← Astro marketing site diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts index 087da2d6..d27871a6 100644 --- a/packages/mcp-server/src/context.ts +++ b/packages/mcp-server/src/context.ts @@ -34,7 +34,9 @@ export type McpServerContext = { /** * Notifies the host app that a calling agent has finished a session of * work in a worktree, so the UI can surface it (e.g. a toast). Purely - * informational — doesn't touch git or the filesystem, so unlike + * informational — it never mutates git or filesystem state (the handler's + * own known-worktree check does read via listWorktrees, but that's the + * same read every other read-only tool does), so unlike * createWorktree/removeWorktree it isn't gated by mutatingToolsEnabled. */ reportSessionDone: (args: { worktreePath: string; summary: string | null }) => Promise; diff --git a/website/src/content/docs/docs/guides/mcp-server.md b/website/src/content/docs/docs/guides/mcp-server.md index 601fa506..25d88238 100644 --- a/website/src/content/docs/docs/guides/mcp-server.md +++ b/website/src/content/docs/docs/guides/mcp-server.md @@ -6,8 +6,8 @@ description: Expose a workspace to MCP-capable agents over a token-protected loc Every open workspace can run its own [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server, letting any MCP-capable agent — not just the one you've configured as your default coding agent — list worktrees, -check status, and (once enabled) create or remove worktrees, directly -through its own tool-calling interface. +check status and diffs, report a finished session, and (once enabled) +create or remove worktrees, directly through its own tool-calling interface. ## Enabling it @@ -60,6 +60,8 @@ already filled in, and paste it into that client's own config file. | `list_worktrees` | Lists every git worktree in the workspace, including branch, HEAD, and whether it's externally managed. | | `get_workspace_info` | Returns the workspace root, git repo path, managed worktrees path, and worktree count. | | `get_worktree_status` | Returns staged/unstaged/untracked files for one worktree (must be one already returned by `list_worktrees`). | +| `get_worktree_diff` | Returns the unified diff of a worktree's working tree (staged + unstaged) against `HEAD`, optionally scoped to one file. | +| `report_session_done` | Tells SproutGit the agent finished a session of work in a worktree, with an optional summary — shown to the user as a toast in that workspace. | | `create_worktree` | Creates a new managed worktree from a ref. | | `remove_worktree` | Removes a managed worktree, optionally deleting its branch. | @@ -70,9 +72,11 @@ as creating/removing a worktree from the UI — including running any :::note `create_worktree` and `remove_worktree` are **currently disabled for every workspace, with no way to turn them on yet** — there's no permission-gate -setting for them in the app yet, so they unconditionally refuse. The -read-only tools (`list_worktrees`, `get_workspace_info`, -`get_worktree_status`) always work once the server is enabled. +setting for them in the app yet, so they unconditionally refuse. Every other +tool (`list_worktrees`, `get_workspace_info`, `get_worktree_status`, +`get_worktree_diff`, `report_session_done`) always works once the server is +enabled — `report_session_done` only pushes a toast, it never mutates git or +filesystem state, so it isn't gated behind that same permission. ::: ![MCP server settings](../../../../assets/screenshots/mac/settings/preferences-light.png) From 8ddf3d5823bcc98f8de50e519012a4a8c5582636 Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:22:41 +1000 Subject: [PATCH 3/3] fix(test): raise timeout on report_session_done mcp-bridge tests These do a real repo init (several git subprocess spawns) plus a real HTTP server plus two sequential fetches (initialize + tools/call), which pushed past the default 5000ms test timeout on windows-latest CI. Bump to 15s to match the headroom other slow integration-style tests in this repo use. --- app/src/main/__tests__/mcp-bridge.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/__tests__/mcp-bridge.test.ts b/app/src/main/__tests__/mcp-bridge.test.ts index 589fa9c0..35f1da1e 100644 --- a/app/src/main/__tests__/mcp-bridge.test.ts +++ b/app/src/main/__tests__/mcp-bridge.test.ts @@ -138,6 +138,10 @@ describe('mcp-bridge lifecycle', () => { expect(getMcpStatus('/never/started')).toEqual({ running: false, port: null }); }); + // Slower than the other tests in this file — a real repo init (several git + // subprocess spawns) plus a real HTTP server plus two sequential fetches + // (initialize + tools/call), which on Windows CI runners can push past the + // default 5000ms test timeout. it('report_session_done pushes an EVENT_MCP_SESSION_DONE event to the workspace window', async () => { const repo = initTestRepo(); repos.push(repo); @@ -154,7 +158,7 @@ describe('mcp-bridge lifecycle', () => { expect(body.result?.isError).toBeUndefined(); expect(send).toHaveBeenCalledWith(IPC.EVENT_MCP_SESSION_DONE, { worktreePath: repo, summary: 'Implemented the feature' }); - }); + }, 15_000); it('report_session_done is a no-op (does not throw) when the workspace window is not open', async () => { const repo = initTestRepo(); @@ -166,5 +170,5 @@ describe('mcp-bridge lifecycle', () => { worktreePath: repo, }) as { result?: { isError?: boolean } }; expect(body.result?.isError).toBeUndefined(); - }); + }, 15_000); });