From dcfffd466276ae2949187c90b49df14c383adc56 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 9 Aug 2026 21:25:55 +0530 Subject: [PATCH] refactor: simplify model-facing coding surface --- src/artifact-tools.ts | 8 +- src/server.test.ts | 69 +++++++--- src/server.ts | 293 +++++++++++++----------------------------- 3 files changed, 145 insertions(+), 225 deletions(-) diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index 4734f095..3829003d 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -96,18 +96,18 @@ export function registerArtifactTools( server, "download_artifact", { - title: "Download attached or generated file", + title: "Download file", description: - "Stream one MCP-host-provided native file to a requested relative path inside an already-open workspace. Existing destinations, arbitrary URLs, absolute paths, traversal, symlinked parents, local source paths, and malformed file objects are rejected.", + "Stream one host-provided file to a relative path inside an open workspace. Existing destinations, arbitrary URLs, absolute paths, traversal, symlinked parents, source paths, and malformed file objects are rejected.", inputSchema: { file: openAIFileReferenceInputSchema.describe( "Native file value authorized and supplied by the MCP host.", ), workspaceId: z.string().min(1).describe( - "Workspace identifier returned by open_workspace.", + "Workspace handle returned by open_workspace.", ), path: z.string().min(1).describe( - "Relative destination path inside the selected workspace. The destination must not already exist.", + "Relative destination path inside the workspace. It must not already exist.", ), }, outputSchema: { diff --git a/src/server.test.ts b/src/server.test.ts index c2f659d1..f5c5dd2a 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -18,6 +18,14 @@ const execFileAsync = promisify(execFile); test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { const context = await fixture(t); + assert.equal( + context.client.getServerVersion()?.description, + "Workspace-scoped coding tools.", + ); + const instructions = context.client.getInstructions(); + assert.ok(instructions); + assert.ok(instructions.length < 700); + assert.doesNotMatch(instructions, /\blocal\b/i); const first = await callOpen(context.client, context.project, "chat-1"); const repeated = await callOpen(context.client, context.project, "chat-1"); @@ -26,15 +34,19 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com const outputProperties = (openTool?.outputSchema as { properties?: Record } | undefined)?.properties; assert.equal(outputProperties && "workspaceReused" in outputProperties, false); assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); + assert.equal(outputProperties && "agentProviders" in outputProperties, false); + assert.equal(outputProperties && "agents" in outputProperties, true); + assert.equal(outputProperties && "skillDiagnostics" in outputProperties, false); const firstStructured = structuredContent(first); assert.equal(firstStructured.workspaceId, structuredContent(repeated).workspaceId); assert.ok(Array.isArray(firstStructured.agentsFiles)); assert.ok(Array.isArray(firstStructured.availableAgentsFiles)); assert.ok(Array.isArray(firstStructured.skills)); - assert.ok(Array.isArray(firstStructured.agentProviders)); assert.ok(Array.isArray(firstStructured.agents)); - assert.ok(Array.isArray(firstStructured.skillDiagnostics)); + assert.equal(firstStructured.agentProviders, undefined); + assert.equal(firstStructured.skillDiagnostics, undefined); + assert.equal((firstStructured.agents as Array>)[0]?.provider, undefined); assert.equal("workspaceReused" in firstStructured, false); assert.equal("includeBootstrapContext" in firstStructured, false); @@ -42,18 +54,17 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.equal(repeatedStructured.agentsFiles, undefined); assert.equal(repeatedStructured.availableAgentsFiles, undefined); assert.equal(repeatedStructured.skills, undefined); - assert.equal(repeatedStructured.agentProviders, undefined); assert.equal(repeatedStructured.agents, undefined); + assert.equal(repeatedStructured.agentProviders, undefined); assert.equal(repeatedStructured.skillDiagnostics, undefined); assert.equal("workspaceReused" in repeatedStructured, false); assert.equal("includeBootstrapContext" in repeatedStructured, false); const repeatedText = responseText(repeated); - assert.match(repeatedText, /Workspace already open as/); - assert.match(repeatedText, /same checkout previously opened/); - assert.match(repeatedText, /Reuse this workspaceId for subsequent tool calls/); - assert.match(repeatedText, /previously provided for this workspace/); - assert.match(repeatedText, /not repeated here/); + assert.match(repeatedText, /Project already open/); + assert.match(repeatedText, /Continue using it/); + assert.match(repeatedText, /instructions and skills remain active/); + assert.doesNotMatch(repeatedText, /workspaceId|previously provided|not repeated here/i); const card = responseCard(repeated); assert.equal(card.workspaceReused, true); @@ -65,6 +76,26 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.agents)); }); +test("codex process tools keep terminal and timing defaults inside DevSpace", async (t) => { + const context = await fixture(t, { toolMode: "codex" }); + const tools = await context.client.listTools(); + const execTool = tools.tools.find((tool) => tool.name === "exec_command"); + const writeTool = tools.tools.find((tool) => tool.name === "write_stdin"); + assert.ok(execTool); + assert.ok(writeTool); + + const execProperties = (execTool.inputSchema as { properties?: Record }).properties; + const writeProperties = (writeTool.inputSchema as { properties?: Record }).properties; + for (const property of ["columns", "rows", "yieldTimeMs", "maxOutputTokens"]) { + assert.equal(property in (execProperties ?? {}), false); + assert.equal(property in (writeProperties ?? {}), false); + } + assert.equal("tty" in (execProperties ?? {}), true); + assert.equal("workingDirectory" in (execProperties ?? {}), true); + assert.equal("chars" in (writeProperties ?? {}), true); + assert.doesNotMatch(JSON.stringify([execTool, writeTool]), /\blocal\b/i); +}); + test("concurrent checkout opens return one full context and one reuse instruction", async (t) => { const context = await fixture(t); const [first, second] = await Promise.all([ @@ -78,7 +109,7 @@ test("concurrent checkout opens return one full context and one reuse instructio 1, ); assert.equal( - [first, second].filter((result) => responseText(result).includes("Workspace already open as")).length, + [first, second].filter((result) => responseText(result).includes("Project already open")).length, 1, ); }); @@ -98,13 +129,14 @@ test("new worktrees always receive a fresh workspace and complete worktree conte assert.ok(Array.isArray(structured.agentsFiles)); assert.ok(Array.isArray(structured.availableAgentsFiles)); assert.ok(Array.isArray(structured.skills)); - assert.ok(Array.isArray(structured.agentProviders)); assert.ok(Array.isArray(structured.agents)); - assert.ok(Array.isArray(structured.skillDiagnostics)); - assert.match(responseText(result), /Opened isolated worktree workspace/); + assert.equal(structured.agentProviders, undefined); + assert.equal(structured.skillDiagnostics, undefined); + assert.equal((structured.agents as Array>)[0]?.provider, undefined); + assert.match(responseText(result), /Isolated worktree ready/); } assert.equal(structuredContent(checkoutAgain).agentsFiles, undefined); - assert.match(responseText(checkoutAgain), /same checkout previously opened/); + assert.match(responseText(checkoutAgain), /Project already open/); }); test("checkout opened after a worktree receives its own complete context", async (t) => { @@ -119,7 +151,7 @@ test("checkout opened after a worktree receives its own complete context", async assert.ok(Array.isArray(structuredContent(checkout).agentsFiles)); assert.equal(structuredContent(checkoutAgain).workspaceId, structuredContent(checkout).workspaceId); assert.equal(structuredContent(checkoutAgain).agentsFiles, undefined); - assert.match(responseText(checkoutAgain), /same checkout previously opened/); + assert.match(responseText(checkoutAgain), /Project already open/); }); test("a host without conversation metadata receives normal explicit-workspace behavior", async (t) => { @@ -171,7 +203,7 @@ test("checkout reuse and context suppression survive a registry restart", async const restored = await callOpen(restoredClient, context.project, "chat-1"); assert.equal(structuredContent(restored).workspaceId, firstWorkspaceId); assert.equal(structuredContent(restored).agentsFiles, undefined); - assert.match(responseText(restored), /same checkout previously opened/); + assert.match(responseText(restored), /Project already open/); } finally { await closeRestored(); } @@ -185,7 +217,10 @@ interface ServerFixture { close: () => Promise; } -async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise { +async function fixture( + t: TestContext, + options: { git?: boolean; toolMode?: ServerConfig["toolMode"] } = {}, +): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); const project = join(root, "project"); const agentDir = join(root, "agent"); @@ -219,7 +254,7 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, DEVSPACE_WIDGETS: "full", - DEVSPACE_TOOL_MODE: "full", + DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); diff --git a/src/server.ts b/src/server.ts index 37ec4165..d97d025d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -189,57 +189,33 @@ interface ToolLogFields { function serverInstructions(config: ServerConfig): string { const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() - ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." + ? " When the user supplies a file that is not in the workspace, use download_artifact with the native file and a relative destination. Do not recreate binary content or pass URLs, base64, or host paths through commands." : ""; const showChangesInstruction = config.widgets === "changes" - ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." + ? " After the final successful file change, call show_changes once before replying so the user can review the combined diff." : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; + return `Use DevSpace for coding work. Open one project, then reuse its workspace handle. Follow project instructions and read matching skills before proceeding. Use ${toolNames.read} for inspection, apply_patch for changes, exec_command for tests, builds, Git, and other commands, and write_stdin for running commands.${artifactInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" - ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` - : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; + ? `Use ${toolNames.shell} for search and directory inspection in this mode. ` + : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for inspection. `; const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` + ? `Read a returned instruction file or matching skill before working in its scope. ` : ""; - const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching to a different project folder, changing checkout/worktree mode, the workspaceId is rejected as unknown, or a new isolated worktree is requested. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${artifactInstruction}${showChangesInstruction}`; -} - -function formatVisibleAgent(agent: { - name: string; - provider: string; - model?: string; - thinking?: string; - providerAvailable?: boolean; - providerUnavailableReason?: string; -}): string { - const model = agent.model ? `, model ${agent.model}` : ""; - const thinking = agent.thinking ? `, thinking ${agent.thinking}` : ""; - const availability = agent.providerAvailable === false - ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` - : ""; - return `${agent.name} (${agent.provider}${model}${thinking}${availability})`; -} - -function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { - return `${provider.name} (${provider.reason ?? "unavailable"})`; + return `Use DevSpace for coding work. Open one project, then reuse its workspace handle. Follow project instructions. ${skills}${inspection}Use ${toolNames.edit} for targeted changes, ${toolNames.write} for new or complete files, and ${toolNames.shell} for tests, builds, Git, and other commands. Use file tools for edits.${artifactInstruction}${showChangesInstruction}`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z .string() - .describe( - "Model-readable result text for follow-up reasoning and plain MCP hosts.", - ), + .describe("Outcome to use for the next action."), ...extra, }; } @@ -255,20 +231,9 @@ const workspaceAgentsFileOutputSchema = z.object({ content: z.string(), }); -const workspaceLocalAgentOutputSchema = z.object({ +const workspaceAgentOutputSchema = z.object({ name: z.string(), description: z.string(), - provider: z.string(), - model: z.string().optional(), - thinking: z.string().optional(), - providerAvailable: z.boolean().optional(), - providerUnavailableReason: z.string().optional(), -}); - -const workspaceLocalAgentProviderOutputSchema = z.object({ - name: z.string(), - available: z.boolean(), - reason: z.string().optional(), }); const workspaceAvailableAgentsFileOutputSchema = z.object({ @@ -501,11 +466,16 @@ async function assertWorkspaceAppAssets(): Promise { function processResult(snapshot: ProcessSnapshot): string { const status = snapshot.running - ? `Process running with session ID ${snapshot.sessionId}.` + ? "Command is still running. Continue with write_stdin." : snapshot.signal - ? `Process exited after signal ${snapshot.signal}.` - : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`; - return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status; + ? `Command stopped after signal ${snapshot.signal}.` + : `Command finished with exit code ${snapshot.exitCode ?? "unknown"}.`; + const outputNote = snapshot.outputTruncated + ? " Output is partial; continue with write_stdin for more." + : ""; + return snapshot.output + ? `${snapshot.output.replace(/\n$/, "")}\n${status}${outputNote}` + : `${status}${outputNote}`; } function processOutputSchema(): z.ZodRawShape { @@ -514,8 +484,6 @@ function processOutputSchema(): z.ZodRawShape { running: z.boolean(), exitCode: z.number().int().optional(), signal: z.string().optional(), - wallTimeMs: z.number().nonnegative(), - outputTruncated: z.boolean(), }); } @@ -544,8 +512,6 @@ function processToolResponse( running: snapshot.running, exitCode: snapshot.exitCode, signal: snapshot.signal, - wallTimeMs: snapshot.wallTimeMs, - outputTruncated: snapshot.outputTruncated, }, }; } @@ -562,40 +528,24 @@ function registerCodexProcessTools( { title: "Execute command", description: - "Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes. Call open_workspace first and pass workspaceId.", + "Run a command in the current workspace. Use it for inspection, tests, builds, Git, and other project commands. If it keeps running, continue with write_stdin.", inputSchema: { - workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), - cmd: z.string().min(1).describe("Shell command to execute."), + workspaceId: z.string().describe("Workspace handle returned by open_workspace."), + cmd: z.string().min(1).describe("Command to run."), tty: z .boolean() .optional() - .describe("Allocate a pseudo-terminal for interactive commands. Defaults to false."), - columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width. Defaults to 80."), - rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height. Defaults to 24."), + .describe("Set true for an interactive terminal program."), workingDirectory: z .string() .optional() - .describe("Working directory relative to the workspace root. Defaults to the workspace root."), - yieldTimeMs: z - .number() - .int() - .min(0) - .max(30_000) - .optional() - .describe("Milliseconds to wait before returning a running session. Defaults to 10000."), - maxOutputTokens: z - .number() - .int() - .positive() - .max(100_000) - .optional() - .describe("Approximate output token budget. Defaults to 10000."), + .describe("Optional path relative to the workspace root."), }, outputSchema: processOutputSchema(), ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => { + async ({ workspaceId, cmd, tty, workingDirectory }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); @@ -605,10 +555,6 @@ function registerCodexProcessTools( cwd, workspaceRoot: workspace.root, tty, - columns, - rows, - yieldTimeMs, - maxOutputTokens, }); logToolCall(config, { @@ -637,43 +583,23 @@ function registerCodexProcessTools( { title: "Write to process", description: - "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", + "Check a running command or send input. Omit chars to check; use Ctrl-C to stop it.", inputSchema: { - workspaceId: z.string().describe("Workspace identifier used to start the process."), - sessionId: z.number().describe("Process session identifier returned by exec_command."), - chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."), - columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."), - rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."), - yieldTimeMs: z - .number() - .int() - .min(0) - .max(30_000) - .optional() - .describe("Milliseconds to wait for process output or completion. Defaults to 10000."), - maxOutputTokens: z - .number() - .int() - .positive() - .max(100_000) - .optional() - .describe("Approximate output token budget. Defaults to 10000."), + workspaceId: z.string().describe("Workspace handle used to start the command."), + sessionId: z.number().describe("Command handle returned when exec_command keeps running."), + chars: z.string().optional().describe("Optional input to send."), }, outputSchema: processOutputSchema(), ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => { + async ({ workspaceId, sessionId, chars }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); const snapshot = await processSessions.write({ workspaceId, sessionId, chars, - columns, - rows, - yieldTimeMs, - maxOutputTokens, }); logToolCall(config, { @@ -707,8 +633,7 @@ export function createMcpServer( name: "devspace", title: "DevSpace", version: "0.1.0", - description: - "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.", + description: "Workspace-scoped coding tools.", }, { instructions: serverInstructions(config), @@ -752,23 +677,23 @@ export function createMcpServer( { title: "Open workspace", description: - "Open a local project directory as a coding workspace. Call this once before working in a project or worktree, then reuse the returned workspaceId for later file, search, edit, show-changes, and shell calls. By default this opens the actual checkout; set mode=\"worktree\" when you need isolated or parallel work. Open another workspace when changing projects, switching modes, or starting another isolated worktree.", + "Open a project as a coding workspace. Use once before working in a project or worktree, then reuse the returned workspace handle. The default is the current checkout; choose worktree for isolated or parallel work. Open another workspace only when changing projects, changing mode, or starting another isolated worktree.", inputSchema: { path: z .string() .describe( - "Absolute path, or a leading-tilde home path such as ~/project, to a local project directory inside an allowed root.", + "Project directory inside an allowed root. Accepts an absolute path or ~/project.", ), mode: z .enum(["checkout", "worktree"]) .optional() .describe( - "Defaults to checkout, which works in the actual directory. Use worktree for isolated or parallel Git work.", + "Defaults to checkout. Choose worktree for isolated or parallel Git work.", ), baseRef: z .string() .optional() - .describe("Git ref to base a worktree on. Only used with mode=\"worktree\". Defaults to HEAD."), + .describe("Git ref for a worktree; defaults to HEAD."), }, outputSchema: { workspaceId: z.string(), @@ -779,18 +704,13 @@ export function createMcpServer( .object({ path: z.string(), baseRef: z.string(), - baseSha: z.string(), dirtySource: z.boolean(), - detached: z.boolean(), - managed: z.boolean(), }) .optional(), agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), skills: z.array(workspaceSkillOutputSchema).optional(), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), - agents: z.array(workspaceLocalAgentOutputSchema).optional(), - skillDiagnostics: z.array(z.unknown()).optional(), + agents: z.array(workspaceAgentOutputSchema).optional(), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), @@ -843,49 +763,33 @@ export function createMcpServer( const visibleAgents = includeBootstrapContext ? cardAgents : []; const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; + const modelWorktree = workspace.worktree + ? { + path: workspace.worktree.path, + baseRef: workspace.worktree.baseRef, + dirtySource: workspace.worktree.dirtySource, + } + : undefined; const cardInstruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, you switch to a different project folder or checkout/worktree mode, or the user requests a new isolated worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, you switch to a different project folder or checkout/worktree mode, or the user requests a new isolated worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + ? "Reuse this workspace for subsequent actions. Follow project instructions; read nested instruction files and matching skills before working in their directories." + : "Reuse this workspace for subsequent actions. Follow project instructions and read nested instruction files before working in their directories."; const instruction = workspaceReused - ? [ - `Workspace already open as ${workspace.id}.`, - "Reuse this workspaceId for subsequent tool calls. This is the same checkout previously opened for this project in this conversation.", - "Continue following the project instructions, nested instruction files, skills, agent profiles, and diagnostics previously provided for this workspace. They remain the active workspace context and are not repeated here.", - ].join("\n\n") + ? "The project is already open. Continue using it. Its instructions and skills remain active." : workspace.mode === "worktree" - ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree." + ? "The isolated worktree is ready. Follow its project instructions and read matching skills before proceeding." : cardInstruction; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ workspaceReused - ? `Workspace already open as ${workspace.id}.` + ? "Project already open. Continue working in it." : workspace.mode === "worktree" - ? `Opened isolated worktree workspace ${workspace.id}.` - : `Opened workspace ${workspace.id}.`, + ? "Isolated worktree ready." + : "Project workspace ready.", `Root: ${workspace.root}`, - `Mode: ${workspace.mode}`, - loadedAgentsFiles.length > 0 - ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` - : undefined, - availableAgentsFileOutputs.length > 0 - ? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` - : undefined, - visibleSkills.length > 0 - ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` - : undefined, - visibleAgentProviders.some((provider) => provider.available) - ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}` - : undefined, - visibleAgentProviders.some((provider) => !provider.available) - ? `Unavailable subagent providers: ${visibleAgentProviders.filter((provider) => !provider.available).map(formatUnavailableAgentProvider).join(", ")}` - : undefined, - visibleAgents.length > 0 - ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` - : undefined, instruction, - ].filter(Boolean).join("\n"), + ].join("\n"), }, ]; logToolCall(config, { @@ -930,15 +834,13 @@ export function createMcpServer( root: workspace.root, mode: workspace.mode, sourceRoot: workspace.sourceRoot, - worktree: workspace.worktree, + worktree: modelWorktree, ...(includeBootstrapContext ? { agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, + agents: visibleAgents.map(({ name, description }) => ({ name, description })), } : {}), instruction, @@ -952,26 +854,19 @@ export function createMcpServer( toolNames.read, { title: "Read file", - description: - [ - "Read a file inside an open workspace. Use this for file inspection instead of shell commands like cat or sed. Call open_workspace first and pass workspaceId.", - "Use this tool to inspect relevant AGENTS.md or CLAUDE.md files listed by open_workspace before working in nested directories.", - config.skillsEnabled - ? "If available skills were returned and a task matches one, read that skill's path before proceeding. Skill paths may be outside the workspace; only advertised SKILL.md files and files under already-loaded skill directories are readable." - : "", - ] - .filter(Boolean) - .join(" "), + description: config.skillsEnabled + ? "Read a file in the current workspace. Use it for inspection, project instructions, and skills." + : "Read a file in the current workspace. Use it for inspection and project instructions.", inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), path: z .string() .describe( config.skillsEnabled - ? "File path to read, relative to the workspace root. May also be an advertised skill path from open_workspace skills." - : "File path to read, relative to the workspace root.", + ? "Path relative to the workspace root, or an advertised skill path." + : "Path relative to the workspace root.", ), offset: z .number() @@ -1050,12 +945,11 @@ export function createMcpServer( toolNames.write, { title: "Write file", - description: - `Create or completely overwrite a file inside an open workspace. Prefer ${toolNames.edit} for targeted changes to existing files. Call open_workspace first and pass workspaceId.`, + description: `Create a new file or replace a complete file. Use ${toolNames.edit} for targeted changes.`, inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), path: z .string() .describe("File path to write, relative to the workspace root."), @@ -1124,12 +1018,11 @@ export function createMcpServer( toolNames.edit, { title: "Edit file", - description: - `Edit one file inside an open workspace by replacing exact text blocks. Prefer this over ${toolNames.write} for targeted changes. Each oldText must match a unique, non-overlapping region of the original file; merge nearby changes into one edit and keep oldText as small as possible while still unique. Call open_workspace first and pass workspaceId.`, + description: `Apply targeted text replacements to one file. Use ${toolNames.write} for a new or complete file.`, inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), path: z .string() .describe("File path to edit, relative to the workspace root."), @@ -1138,9 +1031,7 @@ export function createMcpServer( z.object({ oldText: z .string() - .describe( - "Exact text to replace. Must match uniquely in the original file.", - ), + .describe("Exact text that must match once."), newText: z.string().describe("Replacement text."), }), ) @@ -1217,11 +1108,11 @@ export function createMcpServer( { title: "Apply patch", description: - "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + "Apply a patch for file additions, edits, moves, or deletions. Use it for file changes in this mode.", inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), patch: z .string() .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."), @@ -1292,11 +1183,11 @@ export function createMcpServer( { title: "Show changes", description: - "Show the changes made in this turn for an open workspace. Call this once after the final related file change and before your final response so the user can review the combined diff. Do not call it after each individual file change.", + "Show the combined changes from this turn. Call once after the final file change.", inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "show_changes"), @@ -1347,17 +1238,17 @@ export function createMcpServer( { title: "Grep", description: - "Search file contents inside an open workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules. Call open_workspace first and pass workspaceId.", + "Search project files for text or symbols. Narrow with path or include.", inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), - pattern: z.string().describe("Search pattern."), + .describe("Workspace handle returned by open_workspace."), + pattern: z.string().describe("Text or regex to find."), path: z .string() .optional() .describe( - "Optional path or glob scope relative to the workspace root.", + "Optional path or glob scope.", ), include: z.string().optional().describe("Optional include glob."), }, @@ -1420,16 +1311,16 @@ export function createMcpServer( { title: "Glob", description: - "Find files by glob pattern inside an open workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules. Call open_workspace first and pass workspaceId.", + "Find project files by glob. Use it to discover or narrow files before reading.", inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), pattern: z.string().describe("File glob pattern."), path: z .string() .optional() - .describe("Optional path scope relative to the workspace root."), + .describe("Optional path scope."), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "search"), @@ -1490,15 +1381,15 @@ export function createMcpServer( { title: "Ls", description: - "List a directory inside an open workspace. Use this for directory inspection before reading files. Call open_workspace first and pass workspaceId.", + "List a directory in the workspace.", inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), path: z .string() .describe( - "Directory path to list, relative to the workspace root.", + "Directory path relative to the workspace root.", ), }, outputSchema: resultOutputSchema(), @@ -1558,42 +1449,36 @@ export function createMcpServer( { title: "Bash", description: config.toolMode !== "full" - ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` - : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, + ? `Run a project command in the workspace. Use it for tests, builds, Git, and search or discovery in this mode. Use file tools for edits.` + : `Run a project command in the workspace. Use it for tests, builds, Git, and commands better handled by a shell. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for inspection; use file tools for edits.`, inputSchema: { workspaceId: z .string() - .describe("Workspace identifier returned by open_workspace."), + .describe("Workspace handle returned by open_workspace."), command: z .string() .describe( - `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, + "Command to run. Use file tools to edit project files.", ), workingDirectory: z .string() .optional() .describe( - "Optional working directory relative to the workspace root. Defaults to the workspace root.", + "Optional path relative to the workspace root.", ), - timeout: z - .number() - .positive() - .max(300) - .optional() - .describe("Timeout in seconds. Defaults to 30, max 300."), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, workingDirectory, ...input }) => { + async ({ workspaceId, workingDirectory, command }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory( workspace, workingDirectory, ); - const response = await runShellTool(input, { + const response = await runShellTool({ command }, { cwd, root: workspace.root, }); @@ -1603,14 +1488,14 @@ export function createMcpServer( tool: toolNames.shell, workspaceId, workingDirectory: workingDirectory ?? ".", - command: input.command, - commandLength: input.command.length, + command, + commandLength: command.length, }, response.content, startedAt); return response; } const summary = { - command: input.command, + command, workingDirectory: workingDirectory ?? ".", ...textSummary(response.content), }; @@ -1618,8 +1503,8 @@ export function createMcpServer( tool: toolNames.shell, workspaceId, workingDirectory: workingDirectory ?? ".", - command: input.command, - commandLength: input.command.length, + command, + commandLength: command.length, success: true, durationMs: Math.round(performance.now() - startedAt), });