diff --git a/app/src/main/__tests__/mcp-bridge.test.ts b/app/src/main/__tests__/mcp-bridge.test.ts index 13f59d7..35f1da1 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,38 @@ 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 }); }); + + // 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); + 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' }); + }, 15_000); + + 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(); + }, 15_000); }); diff --git a/app/src/main/mcp-bridge.ts b/app/src/main/mcp-bridge.ts index f8eef72..c028e02 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 8f56d8c..65dd508 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -54,6 +54,7 @@ import type { McpClientId, McpServerStatus, McpConfigWriteResult, + McpSessionDoneEvent, GlobalErrorEvent, ProjectIdeaGenerateResult, } from '@sproutgit/types'; @@ -407,6 +408,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/hooks/useMcpSessionDoneListener.ts b/app/src/renderer/hooks/useMcpSessionDoneListener.ts new file mode 100644 index 0000000..d720975 --- /dev/null +++ b/app/src/renderer/hooks/useMcpSessionDoneListener.ts @@ -0,0 +1,24 @@ +import { useEffect } from "react"; +import { api } from "../api.js"; +import type { ToastFn } from "../toast-context.js"; + +/** Toasts the `report_session_done` MCP tool call so a user with this workspace open sees when an external agent finishes a session. */ +export function useMcpSessionDoneListener(toast: ToastFn) { + useEffect(() => { + const offMcpSessionDone = api.onMcpSessionDone((event) => { + // 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}` + : `Agent session finished in ${name}`, + "success", + ); + }); + + return () => { + offMcpSessionDone(); + }; + }, [toast]); +} diff --git a/app/src/renderer/routes/workspace.tsx b/app/src/renderer/routes/workspace.tsx index ae77357..c687bad 100644 --- a/app/src/renderer/routes/workspace.tsx +++ b/app/src/renderer/routes/workspace.tsx @@ -33,6 +33,7 @@ import { useWorktreeSelection } from "../hooks/useWorktreeSelection.js"; import { useCommitDiffState } from "../hooks/useCommitDiffState.js"; import { useTerminalManager } from "../hooks/useTerminalManager.js"; import { useAutoUpdateListeners } from "../hooks/useAutoUpdateListeners.js"; +import { useMcpSessionDoneListener } from "../hooks/useMcpSessionDoneListener.js"; import { useWorkspaceFileWatchers } from "../hooks/useWorkspaceFileWatchers.js"; import { useWorkspaceUiPersistence } from "../hooks/useWorkspaceUiPersistence.js"; import { useEditorTabsForWorktree } from "../hooks/useEditorTabsForWorktree.js"; @@ -86,6 +87,7 @@ function WorkspaceInner() { (s) => s.pendingCreationBranch, ); const updateState = useAutoUpdateListeners(); + useMcpSessionDoneListener(toast); // ── File editor tabs (Zustand) ───────────────────────────────────────── const { editorTabsForActiveWorktree, editorActiveTabKey, activeEditorTab } = diff --git a/app/src/renderer/settings/McpSection.tsx b/app/src/renderer/settings/McpSection.tsx index 3993174..7ab9f24 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 76f18dd..15e0372 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 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/__tests__/http-server.test.ts b/packages/mcp-server/src/__tests__/http-server.test.ts index cf1c714..dff983a 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 8769962..d41f8b5 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 cad6525..d27871a 100644 --- a/packages/mcp-server/src/context.ts +++ b/packages/mcp-server/src/context.ts @@ -31,4 +31,13 @@ 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 — 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/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 4c9746d..3fc0229 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 f7fd313..9f0c11b 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -172,6 +172,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 77277fb..78f8b8f 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; +}; diff --git a/website/src/content/docs/docs/guides/mcp-server.md b/website/src/content/docs/docs/guides/mcp-server.md index 601fa50..25d8823 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)