Skip to content

Commit 1eae5b9

Browse files
committed
Add MCP client, edit preview/rollback, and test/lint/format quick actions
- MCP: connect external Model Context Protocol servers over stdio or HTTP; their tools show up in Agent mode's tool list alongside the built-ins, through the same Allow/Deny approval flow. Configured in Settings, with auto-reconnect for enabled servers on launch. - Agent mode write_file calls now show a real line diff against the file's current content instead of a raw argument dump, and the last applied edit can be undone with a new Undo button. - Run Tests / Lint / Format buttons appear when the workspace has the matching npm script, running it directly without going through the model.
1 parent 93a9c2a commit 1eae5b9

14 files changed

Lines changed: 1100 additions & 32 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,18 @@ Click **Agent** in the chat toolbar and pick a folder — that becomes the model
137137
- A per-turn step limit (25 tool-result → model-continuation round trips) stops a model from looping indefinitely without producing a final answer.
138138
- The trust list for "always allow" is in-memory only — closing and reopening a chat resets it.
139139

140+
**Preview & Rollback:**
141+
- A pending `write_file` call shows a real **line-by-line diff** against the file's current content (or a "new file" badge if it doesn't exist yet) instead of a raw argument dump, so you can see exactly what would change before clicking Allow.
142+
- **Undo last edit** reverts the most recent applied `write_file` — restoring the previous content, or deleting the file if the edit created it. Undo history is per-workspace, capped at the last 20 writes, and lives only in memory for the running session (not a durable version history).
143+
144+
**Quick actions:** if the workspace has `test`/`lint`/`format` scripts in its `package.json`, **Run Tests**, **Lint**, and **Format** buttons appear in the toolbar — they run the corresponding `npm` script directly (reusing the same sandboxing as `run_command`) and drop the output into the chat, without going through the model.
145+
146+
**MCP (Model Context Protocol) servers:** add external MCP servers in Settings to give Agent mode extra tools — anything from a database query tool to a browser-automation server. Two transports are supported:
147+
- **stdio** — launches a local command (e.g. `npx -y @modelcontextprotocol/server-filesystem /some/path`) and speaks JSON-RPC over its stdin/stdout.
148+
- **HTTP** — connects to a remote MCP server's "Streamable HTTP" endpoint.
149+
150+
Enabled servers reconnect automatically on launch; each server's tools appear in Agent mode's tool list prefixed with the server's name, going through the exact same Allow/Deny approval flow as built-in tools. (SSE and plain WebSocket transports aren't implemented — SSE is the legacy MCP HTTP transport, now superseded by Streamable HTTP, and WebSocket isn't part of the MCP spec itself.)
151+
140152
**Model choice matters.** Agent mode works with whatever model you point it at, but only actually produces tool calls if that model was trained for function/tool calling — a model without that training will just chat normally and never call a tool. The Settings model browser flags models with reliable tool-calling support with a 🔧 **Tool calling** badge (e.g. the Qwen3 family, Llama 3.1+, Mistral Nemo, Qwen2.5-Coder, Devstral).
141153

142154
## Building from source

‎app/src/agent-tools.test.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ import { describe, it, expect, beforeEach } from "vitest";
22
import * as fs from "node:fs";
33
import * as os from "node:os";
44
import * as path from "node:path";
5-
import { readFile, writeFile, listDir, searchFiles, executeTool, runCommand } from "./agent-tools";
5+
import {
6+
readFile,
7+
writeFile,
8+
listDir,
9+
searchFiles,
10+
executeTool,
11+
runCommand,
12+
rollbackLastWrite,
13+
detectProjectScripts,
14+
} from "./agent-tools";
615

716
describe("agent-tools", () => {
817
let workspace: string;
@@ -71,6 +80,61 @@ describe("agent-tools", () => {
7180
});
7281
});
7382

83+
describe("rollbackLastWrite", () => {
84+
it("returns null when there is nothing to roll back", () => {
85+
expect(rollbackLastWrite(workspace)).toBeNull();
86+
});
87+
88+
it("restores the previous content of an overwritten file", () => {
89+
writeFile(workspace, "x.txt", "first");
90+
writeFile(workspace, "x.txt", "second");
91+
const result = rollbackLastWrite(workspace);
92+
expect(result).toEqual({ path: "x.txt", restoredContent: true });
93+
expect(fs.readFileSync(path.join(workspace, "x.txt"), "utf-8")).toBe("first");
94+
});
95+
96+
it("deletes a file that was newly created by the last write", () => {
97+
writeFile(workspace, "new.txt", "content");
98+
const result = rollbackLastWrite(workspace);
99+
expect(result).toEqual({ path: "new.txt", restoredContent: false });
100+
expect(fs.existsSync(path.join(workspace, "new.txt"))).toBe(false);
101+
});
102+
103+
it("only rolls back one write at a time, most recent first", () => {
104+
writeFile(workspace, "a.txt", "a1");
105+
writeFile(workspace, "b.txt", "b1");
106+
rollbackLastWrite(workspace);
107+
expect(fs.existsSync(path.join(workspace, "b.txt"))).toBe(false);
108+
expect(fs.readFileSync(path.join(workspace, "a.txt"), "utf-8")).toBe("a1");
109+
});
110+
});
111+
112+
describe("detectProjectScripts", () => {
113+
it("returns an empty object when there is no package.json", () => {
114+
expect(detectProjectScripts(workspace)).toEqual({});
115+
});
116+
117+
it("only reports scripts that are actually defined", () => {
118+
fs.writeFileSync(
119+
path.join(workspace, "package.json"),
120+
JSON.stringify({ scripts: { test: "vitest", build: "tsc" } })
121+
);
122+
expect(detectProjectScripts(workspace)).toEqual({ test: "npm test", lint: undefined, format: undefined });
123+
});
124+
125+
it("reports test, lint, and format scripts together when all are present", () => {
126+
fs.writeFileSync(
127+
path.join(workspace, "package.json"),
128+
JSON.stringify({ scripts: { test: "vitest", lint: "eslint .", format: "prettier --write ." } })
129+
);
130+
expect(detectProjectScripts(workspace)).toEqual({
131+
test: "npm test",
132+
lint: "npm run lint",
133+
format: "npm run format",
134+
});
135+
});
136+
});
137+
74138
describe("listDir", () => {
75139
it("lists files and marks directories with a trailing slash", () => {
76140
fs.writeFileSync(path.join(workspace, "file.txt"), "");

‎app/src/agent-tools.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,65 @@ export function readFile(workspaceRoot: string, relativePath: string): string {
9898
: content;
9999
}
100100

101+
interface WriteBackup {
102+
relativePath: string;
103+
// null means the file didn't exist before this write — rollback deletes it.
104+
previousContent: string | null;
105+
}
106+
107+
// Undo history is kept in memory only, per workspace, capped so a long agent
108+
// session doesn't grow this unboundedly. It's intentionally session-scoped
109+
// (not written to disk) — Rollback is a quick "oops" safety net for the
110+
// current run, not a durable version history.
111+
const MAX_BACKUPS_PER_WORKSPACE = 20;
112+
const writeBackups = new Map<string, WriteBackup[]>();
113+
114+
function normalizeWorkspaceKey(workspaceRoot: string): string {
115+
return path.resolve(workspaceRoot);
116+
}
117+
118+
function recordBackup(workspaceRoot: string, relativePath: string, previousContent: string | null): void {
119+
const key = normalizeWorkspaceKey(workspaceRoot);
120+
const stack = writeBackups.get(key) ?? [];
121+
stack.push({ relativePath, previousContent });
122+
while (stack.length > MAX_BACKUPS_PER_WORKSPACE) stack.shift();
123+
writeBackups.set(key, stack);
124+
}
125+
101126
export function writeFile(workspaceRoot: string, relativePath: string, content: string): { bytesWritten: number } {
102127
const target = resolveSafePath(workspaceRoot, relativePath);
128+
let previousContent: string | null = null;
129+
try {
130+
previousContent = fs.readFileSync(target, "utf-8");
131+
} catch {
132+
previousContent = null; // file doesn't exist yet — this write creates it
133+
}
103134
fs.mkdirSync(path.dirname(target), { recursive: true });
104135
fs.writeFileSync(target, content);
136+
recordBackup(workspaceRoot, relativePath, previousContent);
105137
return { bytesWritten: Buffer.byteLength(content) };
106138
}
107139

140+
export interface RollbackResult {
141+
path: string;
142+
restoredContent: boolean; // true = previous content restored, false = newly-created file was deleted
143+
}
144+
145+
export function rollbackLastWrite(workspaceRoot: string): RollbackResult | null {
146+
const key = normalizeWorkspaceKey(workspaceRoot);
147+
const stack = writeBackups.get(key);
148+
const backup = stack?.pop();
149+
if (!backup) return null;
150+
const target = resolveSafePath(workspaceRoot, backup.relativePath);
151+
if (backup.previousContent === null) {
152+
fs.rmSync(target, { force: true });
153+
return { path: backup.relativePath, restoredContent: false };
154+
}
155+
fs.mkdirSync(path.dirname(target), { recursive: true });
156+
fs.writeFileSync(target, backup.previousContent);
157+
return { path: backup.relativePath, restoredContent: true };
158+
}
159+
108160
export function listDir(workspaceRoot: string, relativePath: string): string[] {
109161
const target = resolveSafePath(workspaceRoot, relativePath || ".");
110162
const entries = fs.readdirSync(target, { withFileTypes: true });
@@ -232,6 +284,31 @@ export async function runCommand(workspaceRoot: string, command: string, relativ
232284
}
233285
}
234286

287+
export interface ProjectScripts {
288+
test?: string;
289+
lint?: string;
290+
format?: string;
291+
}
292+
293+
// Backs the Test/Lint/Format quick-action buttons — only npm-style
294+
// package.json scripts are recognized, which covers the JS/TS projects this
295+
// app's Agent mode is primarily used against.
296+
export function detectProjectScripts(workspaceRoot: string): ProjectScripts {
297+
const pkgPath = resolveSafePath(workspaceRoot, "package.json");
298+
let scripts: Record<string, string> = {};
299+
try {
300+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { scripts?: Record<string, string> };
301+
scripts = pkg.scripts ?? {};
302+
} catch {
303+
return {};
304+
}
305+
return {
306+
test: scripts.test ? "npm test" : undefined,
307+
lint: scripts.lint ? "npm run lint" : undefined,
308+
format: scripts.format ? "npm run format" : undefined,
309+
};
310+
}
311+
235312
export async function executeTool(workspaceRoot: string, name: string, args: Record<string, unknown>): Promise<unknown> {
236313
switch (name) {
237314
case "read_file":

‎app/src/main.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import * as secretsStore from "./secrets-store";
1111
import * as dataTransfer from "./data-transfer";
1212
import * as rag from "./rag";
1313
import * as agentTools from "./agent-tools";
14+
import * as mcpClient from "./mcp-client";
15+
import type { McpServerConfig } from "./mcp-client";
1416
import type { AttachedFile } from "./file-reader";
1517
import * as openaiProvider from "./providers/openai";
1618
import * as anthropicProvider from "./providers/anthropic";
@@ -190,7 +192,7 @@ function registerIpcHandlers(): void {
190192
const onToken = (chunk: ChatChunk) => event.sender.send(channel, chunk);
191193
const controller = new AbortController();
192194
activeChatRequests.set(requestId, controller);
193-
const tools = agentMode ? agentTools.AGENT_TOOLS : undefined;
195+
const tools = agentMode ? [...agentTools.AGENT_TOOLS, ...mcpClient.getConnectedTools()] : undefined;
194196
try {
195197
if (provider === "ollama") {
196198
await ollama.chat(model, messages, options, onToken, controller.signal, tools);
@@ -345,14 +347,60 @@ function registerIpcHandlers(): void {
345347
requireString(workspaceRoot, "workspace root");
346348
requireString(name, "tool name");
347349
try {
348-
return { result: await agentTools.executeTool(workspaceRoot, name, args ?? {}) };
350+
const result = mcpClient.isMcpTool(name)
351+
? await mcpClient.callMcpTool(name, args ?? {})
352+
: await agentTools.executeTool(workspaceRoot, name, args ?? {});
353+
return { result };
349354
} catch (err) {
350355
const error = err as Error;
351356
logger.error(`Tool execution failed (tool=${name}): ${error.message}`);
352357
return { error: error.message };
353358
}
354359
}
355360
);
361+
362+
ipcMain.handle("agent:rollbackLastWrite", (_event: IpcMainInvokeEvent, workspaceRoot: string) => {
363+
requireString(workspaceRoot, "workspace root");
364+
return agentTools.rollbackLastWrite(workspaceRoot);
365+
});
366+
367+
ipcMain.handle("agent:detectScripts", (_event: IpcMainInvokeEvent, workspaceRoot: string) => {
368+
requireString(workspaceRoot, "workspace root");
369+
return agentTools.detectProjectScripts(workspaceRoot);
370+
});
371+
372+
ipcMain.handle("mcp:connect", async (_event: IpcMainInvokeEvent, config: McpServerConfig) => {
373+
try {
374+
const { tools } = await mcpClient.connectServer(config);
375+
return { tools };
376+
} catch (err) {
377+
const error = err as Error;
378+
logger.error(`MCP connect failed (server=${config?.name}): ${error.message}`);
379+
return { error: error.message };
380+
}
381+
});
382+
383+
ipcMain.handle("mcp:disconnect", (_event: IpcMainInvokeEvent, id: string) => {
384+
requireString(id, "server id");
385+
mcpClient.disconnectServer(id);
386+
});
387+
388+
ipcMain.handle("mcp:status", () => mcpClient.getServerStatuses());
389+
}
390+
391+
// Best-effort: connect every enabled MCP server on launch so its tools are
392+
// available in Agent mode without the user having to manually reconnect each
393+
// session. A server that fails to start (bad command, unreachable URL) just
394+
// logs and stays disconnected — it doesn't block app startup.
395+
async function connectEnabledMcpServers(): Promise<void> {
396+
const servers = settingsStore.getSettings().mcpServers ?? [];
397+
for (const server of servers.filter((s) => s.enabled)) {
398+
try {
399+
await mcpClient.connectServer(server);
400+
} catch (err) {
401+
logger.error(`MCP server "${server.name}" failed to connect on launch: ${(err as Error).message}`);
402+
}
403+
}
356404
}
357405

358406
app.whenReady().then(async () => {
@@ -362,6 +410,7 @@ app.whenReady().then(async () => {
362410
ollama.setHost(settingsStore.getSettings().ollamaHost);
363411
await ollama.start();
364412
setupAutoUpdater(() => mainWindow);
413+
void connectEnabledMcpServers();
365414

366415
app.on("activate", () => {
367416
if (BrowserWindow.getAllWindows().length === 0) createWindow();
@@ -370,6 +419,7 @@ app.whenReady().then(async () => {
370419

371420
app.on("window-all-closed", () => {
372421
ollama.stop();
422+
mcpClient.disconnectAll();
373423
if (process.platform !== "darwin") app.quit();
374424
});
375425

0 commit comments

Comments
 (0)