Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
62 changes: 61 additions & 1 deletion app/src/main/__tests__/mcp-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<unknown> {
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' });
Expand All @@ -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<typeof startMcpServer>[2];
Expand Down Expand Up @@ -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<typeof vi.fn> } }).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();
});
});
11 changes: 11 additions & 0 deletions app/src/main/mcp-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
McpClientId,
McpServerStatus,
McpConfigWriteResult,
McpSessionDoneEvent,
GlobalErrorEvent,
ProjectIdeaGenerateResult,
} from '@sproutgit/types';
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions app/src/renderer/routes/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
liam-russell marked this conversation as resolved.
Outdated
toast(event.summary ? `${name}: ${event.summary}` : `Agent session finished in ${name}`, 'success');
});

return () => { offMcpSessionDone(); };
}, [toast]);

// ── Auto-update listeners ─────────────────────────────────────────────

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion app/src/renderer/settings/McpSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function McpSection({ onToast, workspacePath }: Props) {
<Plug size={15} /> MCP Server
</h2>
<p className="mt-1 text-xs text-(--sg-text-faint)">
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.
</p>
</div>

Expand Down
1 change: 1 addition & 0 deletions docs/agent-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
196 changes: 196 additions & 0 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
@@ -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:<port>/mcp`, `POST` only (Streamable HTTP
transport, stateless — one request in, one response out). Every request
needs `Authorization: Bearer <token>`; 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.
Comment thread
liam-russell marked this conversation as resolved.
Outdated

| 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).
4 changes: 3 additions & 1 deletion packages/mcp-server/src/__tests__/http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
]));
});
});
Loading