Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@

/** 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 @@
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 () => {

Check failure on line 141 in app/src/main/__tests__/mcp-bridge.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (windows-latest)

src/main/__tests__/mcp-bridge.test.ts > mcp-bridge lifecycle > report_session_done pushes an EVENT_MCP_SESSION_DONE event to the workspace window

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ src/main/__tests__/mcp-bridge.test.ts:141:3
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 @@ -54,6 +54,7 @@ import type {
McpClientId,
McpServerStatus,
McpConfigWriteResult,
McpSessionDoneEvent,
GlobalErrorEvent,
ProjectIdeaGenerateResult,
} from '@sproutgit/types';
Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions app/src/renderer/hooks/useMcpSessionDoneListener.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
2 changes: 2 additions & 0 deletions app/src/renderer/routes/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,6 +87,7 @@ function WorkspaceInner() {
(s) => s.pendingCreationBranch,
);
const updateState = useAutoUpdateListeners();
useMcpSessionDoneListener(toast);

// ── File editor tabs (Zustand) ─────────────────────────────────────────
const { editorTabsForActiveWorktree, editorActiveTabKey, activeEditorTab } =
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 website/src/content/docs/docs/guides/mcp-server.md
ts-config/ ← shared tsconfig
e2e/ ← WebdriverIO end-to-end tests
website/ ← Astro marketing site
Expand Down
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',
]));
});
});
51 changes: 50 additions & 1 deletion packages/mcp-server/src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,8 +9,10 @@ import {
listWorktreesHandler,
getWorkspaceInfoHandler,
getWorktreeStatusHandler,
getWorktreeDiffHandler,
createWorktreeHandler,
removeWorktreeHandler,
reportSessionDoneHandler,
MUTATING_TOOLS_DISABLED_MESSAGE,
} from '../tools.js';

Expand All @@ -37,12 +39,14 @@ describe('mcp tool handlers', () => {
let managedWorktreesPath: string;
let context: McpServerContext;
let mutatingEnabled: boolean;
let reportSessionDoneMock: ReturnType<typeof vi.fn<McpServerContext['reportSessionDone']>>;

beforeAll(() => {
repoPath = initTestRepo();
managedWorktreesPath = join(repoPath, '.sproutgit', 'worktrees');
mkdirSync(managedWorktreesPath, { recursive: true });
mutatingEnabled = false;
reportSessionDoneMock = vi.fn().mockResolvedValue(undefined);
context = {
workspacePath: repoPath,
gitRepoPath: repoPath,
Expand All @@ -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,
};
});

Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions packages/mcp-server/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,13 @@ export type McpServerContext = {
createWorktree: (args: { fromRef: string; newBranch: string }) => Promise<CreateWorktreeResult>;
/** 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<void>;
/**
* 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<void>;
};
Loading
Loading