diff --git a/src/server.test.ts b/src/server.test.ts index cb29d11c..ab7010ca 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -7,7 +7,7 @@ import test, { type TestContext } from "node:test"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { loadConfig, type ServerConfig } from "./config.js"; +import { loadConfig, type ServerConfig, type ToolMode, type WidgetMode } from "./config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; import type { SubagentsConfig } from "./local-agent-config.js"; @@ -19,6 +19,63 @@ import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); +test("tool modes expose the expected host-facing tool surface", async (t) => { + const cases: Array<{ + mode: ToolMode; + expected: string[]; + }> = [ + { + mode: "minimal", + expected: ["open_workspace", "read", "write", "edit", "bash"], + }, + { + mode: "full", + expected: ["open_workspace", "read", "write", "edit", "bash", "grep", "glob", "ls"], + }, + { + mode: "codex", + expected: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin"], + }, + ]; + + for (const { mode, expected } of cases) { + await t.test(mode, async (nested) => { + const context = await fixture(nested, { toolMode: mode, widgets: "off" }); + const tools = await context.client.listTools(); + + assert.deepEqual( + tools.tools.map((tool) => tool.name).sort(), + expected.sort(), + ); + }); + } +}); + +test("widget modes compose independently from tool modes", async (t) => { + const cases: Array<{ + widgets: WidgetMode; + showChanges: boolean; + workspaceCard: boolean; + }> = [ + { widgets: "off", showChanges: false, workspaceCard: false }, + { widgets: "changes", showChanges: true, workspaceCard: true }, + { widgets: "full", showChanges: false, workspaceCard: true }, + ]; + + for (const { widgets, showChanges, workspaceCard } of cases) { + await t.test(widgets, async (nested) => { + const context = await fixture(nested, { toolMode: "full", widgets }); + const tools = await context.client.listTools(); + const workspace = tools.tools.find((tool) => tool.name === "open_workspace"); + const changes = tools.tools.find((tool) => tool.name === "show_changes"); + const workspaceMeta = workspace?._meta as { ui?: unknown } | undefined; + + assert.equal(Boolean(changes), showChanges); + assert.equal(Boolean(workspaceMeta?.ui), workspaceCard); + }); + } +}); + test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { const providerNote = "available"; const context = await fixture(t, { @@ -247,6 +304,8 @@ async function fixture( git?: boolean; localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; + toolMode?: ToolMode; + widgets?: WidgetMode; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -284,8 +343,8 @@ async function fixture( DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_WIDGETS: "full", - DEVSPACE_TOOL_MODE: "full", + DEVSPACE_WIDGETS: options.widgets ?? "full", + DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", diff --git a/src/server.ts b/src/server.ts index 16c2010d..768608d6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,12 +17,11 @@ import { import express from "express"; import type { Request, Response } from "express"; import * as z from "zod/v4"; -import { applyPatch } from "./apply-patch.js"; import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js"; -import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import { createOpenAIIncomingArtifactAdapter, type IncomingArtifactAdapter, @@ -31,24 +30,15 @@ import { logEvent, requestIp, requestPath, - commandPreview, sessionIdPrefix, } from "./logger.js"; -import { - editFileTool, - findFilesTool, - grepFilesTool, - listDirectoryTool, - readFileTool, - runShellTool, - writeFileTool, -} from "./pi-tools.js"; +import { readFileTool } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { McpSessionRegistry, type McpSessionCloseResult, } from "./mcp-sessions.js"; -import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; +import { ProcessSessionManager } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { openAiConversationScopeId } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; @@ -64,32 +54,30 @@ import { formatLocalAgentProviderStatusSummary, type LocalAgentProviderStatus, } from "./local-agent-catalog.js"; +import { getToolSurface } from "./tool-surfaces/index.js"; +import { + contentText, + logFailedToolResponse, + logToolCall, + resultOutputSchema, + textBlock, + textSummary, + toolWidgetDescriptorMeta, +} from "./tool-surfaces/shared.js"; +import { + WORKSPACE_APP_URI, + toolNames, + workspaceIdDescription, + type ToolContent, + type ToolSurface, +} from "./tool-surfaces/types.js"; type Transport = StreamableHTTPServerTransport; // MCP clients can reconnect without closing the previous transport. Bound stale // session retention so abandoned MCP servers do not accumulate for the life of the process. const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000; const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000; -const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html"; -const WRITE_TOOL_ANNOTATIONS = { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, -}; -const EDIT_TOOL_ANNOTATIONS = { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, -}; -const SHELL_TOOL_ANNOTATIONS = { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: true, -}; interface RunningServer { app: ReturnType; @@ -98,10 +86,6 @@ interface RunningServer { close(): Promise; } -type ToolContent = - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string }; - interface WorkspaceAppManifestEntry { file: string; css?: string[]; @@ -110,113 +94,25 @@ interface WorkspaceAppManifestEntry { type WorkspaceAppManifest = Record; -interface DiffStats { - additions: number; - removals: number; -} - -type ToolWidgetKind = - | "workspace" - | "read" - | "write" - | "edit" - | "search" - | "directory" - | "shell" - | "show_changes"; - -interface ToolDefinitionMeta extends Record { - ui: { - resourceUri: string; - visibility: ["model"]; - }; -} - -type EmptyToolDefinitionMeta = Record & { - "ui/resourceUri"?: string; -}; - -interface ToolWidgetDescriptorMeta { - _meta: ToolDefinitionMeta | EmptyToolDefinitionMeta; -} - -function shouldAttachWidget(mode: WidgetMode, kind: ToolWidgetKind): boolean { - switch (mode) { - case "off": - return false; - case "changes": - return kind === "workspace" || kind === "show_changes"; - case "full": - return true; - } -} - -function toolWidgetDescriptorMeta( +function serverInstructions( config: ServerConfig, - kind: ToolWidgetKind, -): ToolWidgetDescriptorMeta { - if (!shouldAttachWidget(config.widgets, kind)) return { _meta: {} }; - - return { - _meta: { - ui: { - resourceUri: WORKSPACE_APP_URI, - visibility: ["model"], - }, - }, - }; -} - -const toolNames = { - openWorkspace: "open_workspace", - read: "read", - write: "write", - edit: "edit", - grep: "grep", - glob: "glob", - ls: "ls", - shell: "bash", -} as const; - -const workspaceIdDescription = - "Workspace to use. Reuse the current project's workspaceId."; - -interface ToolLogFields { - tool: string; - workspaceId?: string; - path?: string; - workingDirectory?: string; - command?: string; - commandLength?: number; - success: boolean; - durationMs: number; - error?: string; -} - -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." - : ""; + toolSurface: ToolSurface, +): 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." + : ""; 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." : ""; - - if (config.toolMode === "codex") { - return `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. 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}`; - } - - 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. `; - 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. ` : ""; + const agents = `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. `; + const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected.`; - 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 for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. ${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}`; + return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -244,17 +140,6 @@ function formatAvailableAgentProvider(provider: { return `${provider.id}${details ? ` (${details})` : ""}`; } -function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { - return { - result: z - .string() - .describe( - "Model-readable result text for follow-up reasoning and plain MCP hosts.", - ), - ...extra, - }; -} - const workspaceSkillOutputSchema = z.object({ name: z.string(), description: z.string(), @@ -323,105 +208,6 @@ function requestLogFields(req: Request, config: ServerConfig): Record item.type === "text", - ) - .map((item) => item.text) - .join("\n"); -} - -function toolErrorPreview(content: ToolContent[]): string | undefined { - const text = contentText(content).replace(/\s+/g, " ").trim(); - if (!text) return undefined; - return text.length > 240 ? `${text.slice(0, 237)}...` : text; -} - -function logFailedToolResponse( - config: ServerConfig, - fields: Omit, - content: ToolContent[], - startedAt: number, -): void { - logToolCall(config, { - ...fields, - success: false, - durationMs: Math.round(performance.now() - startedAt), - error: toolErrorPreview(content), - }); -} - -function textBlock(text: string): ToolContent { - return { type: "text", text }; -} - -function textSummary(content: ToolContent[]): { - lines: number; - characters: number; -} { - const text = contentText(content); - return { - lines: text.length === 0 ? 0 : text.split("\n").length, - characters: text.length, - }; -} - -function contentLineCount(content: string): number { - if (content.length === 0) return 0; - return content.endsWith("\n") - ? content.slice(0, -1).split("\n").length - : content.split("\n").length; -} - -function countDiffStats(diff: string | undefined): DiffStats { - if (!diff) return { additions: 0, removals: 0 }; - - let additions = 0; - let removals = 0; - - for (const line of diff.split("\n")) { - if (line.startsWith("+") && !line.startsWith("+++")) additions++; - if (line.startsWith("-") && !line.startsWith("---")) removals++; - } - - return { additions, removals }; -} - -function newFilePatch(path: string, content: string): string { - const lines = - content.length === 0 - ? [] - : content.endsWith("\n") - ? content.slice(0, -1).split("\n") - : content.split("\n"); - const hunkLength = lines.length; - const hunkRange = hunkLength === 0 ? "+0,0" : `+1,${hunkLength}`; - const body = lines.map((line) => `+${line}`).join("\n"); - - return [ - `diff --git a/${path} b/${path}`, - "new file mode 100644", - "index 0000000..0000000", - "--- /dev/null", - `+++ b/${path}`, - `@@ -0,0 ${hunkRange} @@`, - body, - ] - .filter((line) => line.length > 0) - .join("\n"); -} - function assetBaseUrl(config: ServerConfig): string { return `${config.publicBaseUrl.replace(/\/+$/, "")}/mcp-app-assets`; } @@ -509,201 +295,6 @@ async function assertWorkspaceAppAssets(): Promise { } } -function processResult(snapshot: ProcessSnapshot): string { - const status = snapshot.running - ? `Process running with session ID ${snapshot.sessionId}.` - : 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; -} - -function processOutputSchema(): z.ZodRawShape { - return resultOutputSchema({ - sessionId: z.number().optional(), - running: z.boolean(), - exitCode: z.number().int().optional(), - signal: z.string().optional(), - wallTimeMs: z.number().nonnegative(), - outputTruncated: z.boolean(), - }); -} - -function processToolResponse( - tool: "exec_command" | "write_stdin", - workspaceId: string, - snapshot: ProcessSnapshot, - summary: Record, -) { - const result = processResult(snapshot); - const content = [textBlock(result)]; - const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []); - return { - content, - _meta: { - tool, - card: { - workspaceId, - summary: { ...summary, ...outputSummary }, - payload: { content }, - }, - }, - structuredContent: { - result, - sessionId: snapshot.sessionId, - running: snapshot.running, - exitCode: snapshot.exitCode, - signal: snapshot.signal, - wallTimeMs: snapshot.wallTimeMs, - outputTruncated: snapshot.outputTruncated, - }, - }; -} - -function registerCodexProcessTools( - server: McpServer, - config: ServerConfig, - workspaces: WorkspaceRegistry, - processSessions: ProcessSessionManager, -): void { - registerAppTool( - server, - "exec_command", - { - title: "Execute command", - description: - "Run a command in a 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.", - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - cmd: z.string().min(1).describe("Shell command to execute."), - 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."), - 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."), - }, - outputSchema: processOutputSchema(), - ...toolWidgetDescriptorMeta(config, "shell"), - annotations: SHELL_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); - const snapshot = await processSessions.start({ - workspaceId, - command: cmd, - cwd, - workspaceRoot: workspace.root, - tty, - columns, - rows, - yieldTimeMs, - maxOutputTokens, - }); - - logToolCall(config, { - tool: "exec_command", - workspaceId, - workingDirectory: workingDirectory ?? ".", - command: cmd, - commandLength: cmd.length, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return processToolResponse("exec_command", workspaceId, snapshot, { - command: cmd, - workingDirectory: workingDirectory ?? ".", - running: snapshot.running, - exitCode: snapshot.exitCode, - wallTimeMs: snapshot.wallTimeMs, - }); - }, - ); - - registerAppTool( - server, - "write_stdin", - { - 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.", - 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."), - }, - outputSchema: processOutputSchema(), - ...toolWidgetDescriptorMeta(config, "shell"), - annotations: SHELL_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => { - const startedAt = performance.now(); - workspaces.getWorkspace(workspaceId); - const snapshot = await processSessions.write({ - workspaceId, - sessionId, - chars, - columns, - rows, - yieldTimeMs, - maxOutputTokens, - }); - - logToolCall(config, { - tool: "write_stdin", - workspaceId, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return processToolResponse("write_stdin", workspaceId, snapshot, { - sessionId, - charactersWritten: chars?.length ?? 0, - running: snapshot.running, - exitCode: snapshot.exitCode, - wallTimeMs: snapshot.wallTimeMs, - }); - }, - ); -} - export function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, @@ -712,6 +303,7 @@ export function createMcpServer( resolveLocalAgentProviders: () => LocalAgentProviderStatus[], incomingArtifactAdapters: readonly IncomingArtifactAdapter[], ): McpServer { + const toolSurface = getToolSurface(config.toolMode); const server = new McpServer( { name: "devspace", @@ -721,7 +313,7 @@ export function createMcpServer( "Coding tools for project workspaces. Open each project or worktree once, then reuse its workspaceId.", }, { - instructions: serverInstructions(config), + instructions: serverInstructions(config, toolSurface), }, ); @@ -1055,246 +647,12 @@ export function createMcpServer( }, ); - if (config.toolMode !== "codex") { - registerAppTool( + toolSurface.register({ server, - toolNames.write, - { - title: "Write file", - description: - `Create or completely overwrite a file in a workspace. Prefer ${toolNames.edit} for targeted changes to existing files.`, - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - path: z - .string() - .describe("File path to write, relative to the workspace root."), - content: z.string().describe("Complete new file content."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "write"), - annotations: WRITE_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); - const response = await writeFileTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse(config, { - tool: toolNames.write, - workspaceId, - path: input.path, - }, response.content, startedAt); - return response; - } - - const patch = newFilePatch(input.path, input.content); - const stats = countDiffStats(patch); - const summary = { - ...stats, - lines: contentLineCount(input.content), - characters: input.content.length, - }; - logToolCall(config, { - tool: toolNames.write, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.write, - card: { - workspaceId, - path: input.path, - summary, - payload: { - content: response.content, - patch, - }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - - registerAppTool( - server, - toolNames.edit, - { - title: "Edit file", - description: - `Edit one file in a 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.`, - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - path: z - .string() - .describe("File path to edit, relative to the workspace root."), - edits: z - .array( - z.object({ - oldText: z - .string() - .describe( - "Exact text to replace. Must match uniquely in the original file.", - ), - newText: z.string().describe("Replacement text."), - }), - ) - .min(1), - }, - outputSchema: resultOutputSchema({ - status: z.literal("applied"), - }), - ...toolWidgetDescriptorMeta(config, "edit"), - annotations: EDIT_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); - const response = await editFileTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse(config, { - tool: toolNames.edit, - workspaceId, - path: input.path, - }, response.content, startedAt); - return response; - } - - const stats = countDiffStats( - response.details?.patch ?? response.details?.diff, - ); - const summary = { - ...stats, - editCount: input.edits.length, - }; - const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; - const editContent = [textBlock(editResultText)]; - logToolCall(config, { - tool: toolNames.edit, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - content: editContent, - _meta: { - tool: toolNames.edit, - card: { - workspaceId, - path: input.path, - summary, - payload: { - diff: response.details?.diff, - patch: response.details?.patch, - }, - }, - }, - structuredContent: { - status: "applied", - result: contentText(editContent), - }, - }; - }, - ); - } - - if (config.toolMode === "codex") { - registerAppTool( - server, - "apply_patch", - { - title: "Apply patch", - description: - "Apply one Codex-style patch in a workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace.", - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - patch: z - .string() - .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."), - }, - outputSchema: resultOutputSchema({ - additions: z.number(), - removals: z.number(), - files: z.array( - z.object({ - path: z.string(), - previousPath: z.string().optional(), - operation: z.enum(["add", "update", "delete", "move"]), - }), - ), - }), - ...toolWidgetDescriptorMeta(config, "edit"), - annotations: EDIT_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, patch }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - const applied = await applyPatch(workspace.root, patch); - const paths = applied.files.map((file) => file.path).join(", "); - const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; - const content = [textBlock(result)]; - const displayPath = applied.files.length === 1 - ? applied.files[0]?.path - : `${applied.files.length} files`; - - logToolCall(config, { - tool: "apply_patch", - workspaceId, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - content, - _meta: { - tool: "apply_patch", - card: { - workspaceId, - path: displayPath, - summary: { - files: applied.files.length, - additions: applied.additions, - removals: applied.removals, - }, - files: applied.files, - payload: { patch: applied.patch }, - }, - }, - structuredContent: { - result, - additions: applied.additions, - removals: applied.removals, - files: applied.files, - }, - }; - }, - ); - } + config, + workspaces, + processSessions, + }); if (config.widgets === "changes") { registerAppTool( @@ -1305,9 +663,7 @@ export function createMcpServer( 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.", inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), + workspaceId: z.string().describe(workspaceIdDescription), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "show_changes"), @@ -1351,313 +707,6 @@ export function createMcpServer( ); } - if (config.toolMode === "full") { - registerAppTool( - server, - toolNames.grep, - { - title: "Grep", - description: - "Search file contents in a workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules.", - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - pattern: z.string().describe("Search pattern."), - path: z - .string() - .optional() - .describe( - "Optional path or glob scope relative to the workspace root.", - ), - include: z.string().optional().describe("Optional include glob."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "search"), - annotations: { readOnlyHint: true }, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - if (input.path) workspaces.resolvePath(workspace, input.path); - const response = await grepFilesTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse(config, { - tool: toolNames.grep, - workspaceId, - path: input.path, - }, response.content, startedAt); - return response; - } - - const summary = { - pattern: input.pattern, - scope: input.path ?? ".", - ...textSummary(response.content), - }; - logToolCall(config, { - tool: toolNames.grep, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.grep, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - - registerAppTool( - server, - toolNames.glob, - { - title: "Glob", - description: - "Find files by glob pattern in a workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules.", - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - pattern: z.string().describe("File glob pattern."), - path: z - .string() - .optional() - .describe("Optional path scope relative to the workspace root."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "search"), - annotations: { readOnlyHint: true }, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - if (input.path) workspaces.resolvePath(workspace, input.path); - const response = await findFilesTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse(config, { - tool: toolNames.glob, - workspaceId, - path: input.path, - }, response.content, startedAt); - return response; - } - - const summary = { - pattern: input.pattern, - scope: input.path ?? ".", - ...textSummary(response.content), - }; - logToolCall(config, { - tool: toolNames.glob, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.glob, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - - registerAppTool( - server, - toolNames.ls, - { - title: "Ls", - description: - "List a directory in a workspace. Use this for directory inspection before reading files.", - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - path: z - .string() - .describe( - "Directory path to list, relative to the workspace root.", - ), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "directory"), - annotations: { readOnlyHint: true }, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); - const response = await listDirectoryTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse(config, { - tool: toolNames.ls, - workspaceId, - path: input.path, - }, response.content, startedAt); - return response; - } - - const summary = textSummary(response.content); - logToolCall(config, { - tool: toolNames.ls, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.ls, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - } - - if (config.toolMode !== "codex") { - registerAppTool( - server, - toolNames.shell, - { - title: "Bash", - description: config.toolMode !== "full" - ? `Run a shell command in a 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. This is powerful execution and should only be exposed behind strong authentication.` - : `Run a shell command in a 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. This is powerful execution and should only be exposed behind strong authentication.`, - inputSchema: { - workspaceId: z - .string() - .describe(workspaceIdDescription), - command: z - .string() - .describe( - `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, - ), - workingDirectory: z - .string() - .optional() - .describe( - "Optional working directory relative to the workspace root. Defaults 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 }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - const cwd = workspaces.resolveWorkingDirectory( - workspace, - workingDirectory, - ); - const response = await runShellTool(input, { - cwd, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse(config, { - tool: toolNames.shell, - workspaceId, - workingDirectory: workingDirectory ?? ".", - command: input.command, - commandLength: input.command.length, - }, response.content, startedAt); - return response; - } - - const summary = { - command: input.command, - workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), - }; - logToolCall(config, { - tool: toolNames.shell, - workspaceId, - workingDirectory: workingDirectory ?? ".", - command: input.command, - commandLength: input.command.length, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.shell, - card: { - workspaceId, - path: workingDirectory, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - } - - if (config.toolMode === "codex") { - registerCodexProcessTools(server, config, workspaces, processSessions); - } - if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { registerArtifactTools(server, { config, diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts new file mode 100644 index 00000000..42d2ae12 --- /dev/null +++ b/src/tool-surfaces/codex.ts @@ -0,0 +1,376 @@ +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import * as z from "zod/v4"; +import { applyPatch } from "../apply-patch.js"; +import type { ProcessSnapshot } from "../process-sessions.js"; +import { + EDIT_TOOL_ANNOTATIONS, + SHELL_TOOL_ANNOTATIONS, + toolNames, + workspaceIdDescription, + type ToolRegistrationContext, +} from "./types.js"; +import { + contentText, + resultOutputSchema, + runLoggedToolOperation, + textBlock, + textSummary, + toolWidgetDescriptorMeta, +} from "./shared.js"; + +type CodexRegistration = (context: ToolRegistrationContext) => void; + +const CODEX_INSTRUCTIONS = `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.`; + +export function codexInstructions(): string { + return CODEX_INSTRUCTIONS; +} + +export function registerCodexTools(context: ToolRegistrationContext): void { + for (const register of CODEX_REGISTRATIONS) { + register(context); + } +} + +const CODEX_REGISTRATIONS: readonly CodexRegistration[] = [ + registerApplyPatchTool, + registerCodexProcessTools, +]; + +function processResult(snapshot: ProcessSnapshot): string { + const status = snapshot.running + ? `Process running with session ID ${snapshot.sessionId}.` + : 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; +} + +function processOutputSchema(): z.ZodRawShape { + return resultOutputSchema({ + sessionId: z.number().optional(), + running: z.boolean(), + exitCode: z.number().int().optional(), + signal: z.string().optional(), + wallTimeMs: z.number().nonnegative(), + outputTruncated: z.boolean(), + }); +} + +function processToolResponse( + tool: "exec_command" | "write_stdin", + workspaceId: string, + snapshot: ProcessSnapshot, + summary: Record, +) { + const result = processResult(snapshot); + const content = [textBlock(result)]; + const outputSummary = textSummary( + snapshot.output ? [textBlock(snapshot.output)] : [], + ); + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary: { ...summary, ...outputSummary }, + payload: { content }, + }, + }, + structuredContent: { + result, + sessionId: snapshot.sessionId, + running: snapshot.running, + exitCode: snapshot.exitCode, + signal: snapshot.signal, + wallTimeMs: snapshot.wallTimeMs, + outputTruncated: snapshot.outputTruncated, + }, + }; +} + +function registerApplyPatchTool(context: ToolRegistrationContext): void { + const { server, config, workspaces } = context; + + registerAppTool( + server, + "apply_patch", + { + title: "Apply patch", + description: + "Apply one Codex-style patch in a workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + patch: z + .string() + .describe( + "Patch text enclosed by *** Begin Patch and *** End Patch markers.", + ), + }, + outputSchema: resultOutputSchema({ + additions: z.number(), + removals: z.number(), + files: z.array( + z.object({ + path: z.string(), + previousPath: z.string().optional(), + operation: z.enum(["add", "update", "delete", "move"]), + }), + ), + }), + ...toolWidgetDescriptorMeta(config, "edit"), + annotations: EDIT_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, patch }) => { + const startedAt = performance.now(); + const applied = await runLoggedToolOperation( + config, + { tool: "apply_patch", workspaceId }, + startedAt, + async () => { + const workspace = workspaces.getWorkspace(workspaceId); + return applyPatch(workspace.root, patch); + }, + ); + const paths = applied.files.map((file) => file.path).join(", "); + const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; + const content = [textBlock(result)]; + const displayPath = + applied.files.length === 1 + ? applied.files[0]?.path + : `${applied.files.length} files`; + + return { + content, + _meta: { + tool: "apply_patch", + card: { + workspaceId, + path: displayPath, + summary: { + files: applied.files.length, + additions: applied.additions, + removals: applied.removals, + }, + files: applied.files, + payload: { patch: applied.patch }, + }, + }, + structuredContent: { + result, + additions: applied.additions, + removals: applied.removals, + files: applied.files, + }, + }; + }, + ); +} + +function registerCodexProcessTools(context: ToolRegistrationContext): void { + const { server, config, workspaces, processSessions } = context; + + registerAppTool( + server, + "exec_command", + { + title: "Execute command", + description: + "Run a command in a 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.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + cmd: z.string().min(1).describe("Shell command to execute."), + 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."), + 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."), + }, + outputSchema: processOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ + workspaceId, + cmd, + tty, + columns, + rows, + workingDirectory, + yieldTimeMs, + maxOutputTokens, + }) => { + const startedAt = performance.now(); + const snapshot = await runLoggedToolOperation( + config, + { + tool: "exec_command", + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: cmd, + commandLength: cmd.length, + }, + startedAt, + async () => { + const workspace = workspaces.getWorkspace(workspaceId); + const cwd = workspaces.resolveWorkingDirectory( + workspace, + workingDirectory, + ); + return processSessions.start({ + workspaceId, + command: cmd, + cwd, + workspaceRoot: workspace.root, + tty, + columns, + rows, + yieldTimeMs, + maxOutputTokens, + }); + }, + ); + + return processToolResponse("exec_command", workspaceId, snapshot, { + command: cmd, + workingDirectory: workingDirectory ?? ".", + running: snapshot.running, + exitCode: snapshot.exitCode, + wallTimeMs: snapshot.wallTimeMs, + }); + }, + ); + + registerAppTool( + server, + "write_stdin", + { + 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.", + 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."), + }, + outputSchema: processOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ + workspaceId, + sessionId, + chars, + columns, + rows, + yieldTimeMs, + maxOutputTokens, + }) => { + const startedAt = performance.now(); + const snapshot = await runLoggedToolOperation( + config, + { tool: "write_stdin", workspaceId }, + startedAt, + async () => { + workspaces.getWorkspace(workspaceId); + return processSessions.write({ + workspaceId, + sessionId, + chars, + columns, + rows, + yieldTimeMs, + maxOutputTokens, + }); + }, + ); + + return processToolResponse("write_stdin", workspaceId, snapshot, { + sessionId, + charactersWritten: chars?.length ?? 0, + running: snapshot.running, + exitCode: snapshot.exitCode, + wallTimeMs: snapshot.wallTimeMs, + }); + }, + ); +} diff --git a/src/tool-surfaces/index.ts b/src/tool-surfaces/index.ts new file mode 100644 index 00000000..b2114097 --- /dev/null +++ b/src/tool-surfaces/index.ts @@ -0,0 +1,23 @@ +import type { ToolMode } from "../config.js"; +import { codexInstructions, registerCodexTools } from "./codex.js"; +import { registerStandardTools, standardInstructions } from "./standard.js"; +import { type ToolSurface } from "./types.js"; + +const TOOL_SURFACES: Record = { + minimal: { + register: (context) => registerStandardTools(context, "minimal"), + instructions: standardInstructions("minimal"), + }, + full: { + register: (context) => registerStandardTools(context, "full"), + instructions: standardInstructions("full"), + }, + codex: { + register: registerCodexTools, + instructions: codexInstructions, + }, +}; + +export function getToolSurface(mode: ToolMode): ToolSurface { + return TOOL_SURFACES[mode]; +} diff --git a/src/tool-surfaces/shared.ts b/src/tool-surfaces/shared.ts new file mode 100644 index 00000000..c2bb22bd --- /dev/null +++ b/src/tool-surfaces/shared.ts @@ -0,0 +1,176 @@ +import * as z from "zod/v4"; +import { logEvent, commandPreview } from "../logger.js"; +import type { ServerConfig, WidgetMode } from "../config.js"; +import { + WORKSPACE_APP_URI, + type DiffStats, + type ToolContent, + type ToolLogFields, + type ToolWidgetDescriptorMeta, + type ToolWidgetKind, +} from "./types.js"; + +export function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { + return { + result: z + .string() + .describe( + "Model-readable result text for follow-up reasoning and plain MCP hosts.", + ), + ...extra, + }; +} + +export function toolWidgetDescriptorMeta( + config: ServerConfig, + kind: ToolWidgetKind, +): ToolWidgetDescriptorMeta { + if (!shouldAttachWidget(config.widgets, kind)) return { _meta: {} }; + + return { + _meta: { + ui: { + resourceUri: WORKSPACE_APP_URI, + visibility: ["model"], + }, + }, + }; +} + +export function logToolCall(config: ServerConfig, fields: ToolLogFields): void { + if (!config.logging.toolCalls) return; + + const { command, ...safeFields } = fields; + logEvent(config.logging, fields.success ? "info" : "warn", "tool_call", { + ...safeFields, + commandPreview: + config.logging.shellCommands && command + ? commandPreview(command) + : undefined, + }); +} + +export async function runLoggedToolOperation( + config: ServerConfig, + fields: Omit, + startedAt: number, + operation: () => Promise, +): Promise { + try { + const result = await operation(); + logToolCall(config, { + ...fields, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return result; + } catch (error) { + logToolCall(config, { + ...fields, + success: false, + durationMs: Math.round(performance.now() - startedAt), + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export function contentText(content: ToolContent[]): string { + return content + .filter( + (item): item is { type: "text"; text: string } => item.type === "text", + ) + .map((item) => item.text) + .join("\n"); +} + +function toolErrorPreview(content: ToolContent[]): string | undefined { + const text = contentText(content).replace(/\s+/g, " ").trim(); + if (!text) return undefined; + return text.length > 240 ? `${text.slice(0, 237)}...` : text; +} + +export function logFailedToolResponse( + config: ServerConfig, + fields: Omit, + content: ToolContent[], + startedAt: number, +): void { + logToolCall(config, { + ...fields, + success: false, + durationMs: Math.round(performance.now() - startedAt), + error: toolErrorPreview(content), + }); +} + +export function textBlock(text: string): ToolContent { + return { type: "text", text }; +} + +export function textSummary(content: ToolContent[]): { + lines: number; + characters: number; +} { + const text = contentText(content); + return { + lines: contentLineCount(text), + characters: text.length, + }; +} + +export function contentLineCount(content: string): number { + if (content.length === 0) return 0; + return content.endsWith("\n") + ? content.slice(0, -1).split("\n").length + : content.split("\n").length; +} + +export function countDiffStats(diff: string | undefined): DiffStats { + if (!diff) return { additions: 0, removals: 0 }; + + let additions = 0; + let removals = 0; + + for (const line of diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions++; + if (line.startsWith("-") && !line.startsWith("---")) removals++; + } + + return { additions, removals }; +} + +export function newFilePatch(path: string, content: string): string { + const lines = + content.length === 0 + ? [] + : content.endsWith("\n") + ? content.slice(0, -1).split("\n") + : content.split("\n"); + const hunkLength = lines.length; + const hunkRange = hunkLength === 0 ? "+0,0" : `+1,${hunkLength}`; + const body = lines.map((line) => `+${line}`).join("\n"); + + return [ + `diff --git a/${path} b/${path}`, + "new file mode 100644", + "index 0000000..0000000", + "--- /dev/null", + `+++ b/${path}`, + `@@ -0,0 ${hunkRange} @@`, + body, + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +function shouldAttachWidget(mode: WidgetMode, kind: ToolWidgetKind): boolean { + switch (mode) { + case "off": + return false; + case "changes": + return kind === "workspace" || kind === "show_changes"; + case "full": + return true; + } +} diff --git a/src/tool-surfaces/standard.ts b/src/tool-surfaces/standard.ts new file mode 100644 index 00000000..f18f29ea --- /dev/null +++ b/src/tool-surfaces/standard.ts @@ -0,0 +1,573 @@ +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import { existsSync } from "node:fs"; +import * as z from "zod/v4"; +import { + editFileTool, + findFilesTool, + grepFilesTool, + listDirectoryTool, + runShellTool, + writeFileTool, +} from "../pi-tools.js"; +import { + EDIT_TOOL_ANNOTATIONS, + SHELL_TOOL_ANNOTATIONS, + WRITE_TOOL_ANNOTATIONS, + toolNames, + workspaceIdDescription, + type ToolInstructionContext, + type ToolRegistrationContext, +} from "./types.js"; +import { + contentLineCount, + contentText, + countDiffStats, + logFailedToolResponse, + logToolCall, + newFilePatch, + resultOutputSchema, + textBlock, + textSummary, + toolWidgetDescriptorMeta, +} from "./shared.js"; + +type StandardRegistration = (context: ToolRegistrationContext) => void; + +const MINIMAL_INSPECTION = `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. `; + +const FULL_INSPECTION = `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; + +const STANDARD_EDITING = `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.`; + +export function standardInstructions(mode: "minimal" | "full") { + const inspection = mode === "minimal" ? MINIMAL_INSPECTION : FULL_INSPECTION; + return ({ agents, skills }: ToolInstructionContext): string => + `${agents}${skills}${inspection}${STANDARD_EDITING}`; +} + +export function registerStandardTools( + context: ToolRegistrationContext, + mode: "minimal" | "full", +): void { + for (const register of STANDARD_REGISTRATIONS[mode]) { + register(context); + } +} + +const STANDARD_REGISTRATIONS: Record< + "minimal" | "full", + readonly StandardRegistration[] +> = { + minimal: [registerStandardMutationTools, registerMinimalShellTool], + full: [ + registerStandardMutationTools, + registerSearchTools, + registerFullShellTool, + ], +}; + +const MINIMAL_SHELL_DESCRIPTION = `Run a shell command in a 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. This is powerful execution and should only be exposed behind strong authentication.`; +const FULL_SHELL_DESCRIPTION = `Run a shell command in a 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. This is powerful execution and should only be exposed behind strong authentication.`; + +function registerMinimalShellTool(context: ToolRegistrationContext): void { + registerShellTool(context, MINIMAL_SHELL_DESCRIPTION); +} + +function registerFullShellTool(context: ToolRegistrationContext): void { + registerShellTool(context, FULL_SHELL_DESCRIPTION); +} + +function registerStandardMutationTools(context: ToolRegistrationContext): void { + const { server, config, workspaces } = context; + + registerAppTool( + server, + toolNames.write, + { + title: "Write file", + description: `Create or completely overwrite a file in a workspace. Prefer ${toolNames.edit} for targeted changes to existing files.`, + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + path: z + .string() + .describe("File path to write, relative to the workspace root."), + content: z.string().describe("Complete new file content."), + }, + outputSchema: resultOutputSchema(), + ...toolWidgetDescriptorMeta(config, "write"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const absolutePath = workspaces.resolvePath(workspace, input.path); + const overwritesExistingFile = existsSync(absolutePath); + const response = await writeFileTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.write, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + // An aggregate review can show the real replacement diff. A new-file + // patch would misrepresent an overwrite as additions with no removals. + const patch = overwritesExistingFile + ? undefined + : newFilePatch(input.path, input.content); + const stats = countDiffStats(patch); + const summary = { + ...stats, + lines: contentLineCount(input.content), + characters: input.content.length, + }; + logToolCall(config, { + tool: toolNames.write, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.write, + card: { + workspaceId, + path: input.path, + summary, + payload: { + content: response.content, + patch, + }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); + + registerAppTool( + server, + toolNames.edit, + { + title: "Edit file", + description: `Edit one file in a 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.`, + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + path: z + .string() + .describe("File path to edit, relative to the workspace root."), + edits: z + .array( + z.object({ + oldText: z + .string() + .describe( + "Exact text to replace. Must match uniquely in the original file.", + ), + newText: z.string().describe("Replacement text."), + }), + ) + .min(1), + }, + outputSchema: resultOutputSchema({ + status: z.literal("applied"), + }), + ...toolWidgetDescriptorMeta(config, "edit"), + annotations: EDIT_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + workspaces.resolvePath(workspace, input.path); + const response = await editFileTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.edit, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + const stats = countDiffStats( + response.details?.patch ?? response.details?.diff, + ); + const summary = { + ...stats, + editCount: input.edits.length, + }; + const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; + const editContent = [textBlock(editResultText)]; + logToolCall(config, { + tool: toolNames.edit, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content: editContent, + _meta: { + tool: toolNames.edit, + card: { + workspaceId, + path: input.path, + summary, + payload: { + diff: response.details?.diff, + patch: response.details?.patch, + }, + }, + }, + structuredContent: { + status: "applied", + result: contentText(editContent), + }, + }; + }, + ); +} + +function registerSearchTools(context: ToolRegistrationContext): void { + const { server, config, workspaces } = context; + + registerAppTool( + server, + toolNames.grep, + { + title: "Grep", + description: + "Search file contents in a workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + pattern: z.string().describe("Search pattern."), + path: z + .string() + .optional() + .describe( + "Optional path or glob scope relative to the workspace root.", + ), + include: z.string().optional().describe("Optional include glob."), + }, + outputSchema: resultOutputSchema(), + ...toolWidgetDescriptorMeta(config, "search"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + if (input.path) workspaces.resolvePath(workspace, input.path); + const response = await grepFilesTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.grep, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + const summary = { + pattern: input.pattern, + scope: input.path ?? ".", + ...textSummary(response.content), + }; + logToolCall(config, { + tool: toolNames.grep, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.grep, + card: { + workspaceId, + path: input.path, + summary, + payload: { content: response.content }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); + + registerAppTool( + server, + toolNames.glob, + { + title: "Glob", + description: + "Find files by glob pattern in a workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + pattern: z.string().describe("File glob pattern."), + path: z + .string() + .optional() + .describe("Optional path scope relative to the workspace root."), + }, + outputSchema: resultOutputSchema(), + ...toolWidgetDescriptorMeta(config, "search"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + if (input.path) workspaces.resolvePath(workspace, input.path); + const response = await findFilesTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.glob, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + const summary = { + pattern: input.pattern, + scope: input.path ?? ".", + ...textSummary(response.content), + }; + logToolCall(config, { + tool: toolNames.glob, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.glob, + card: { + workspaceId, + path: input.path, + summary, + payload: { content: response.content }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); + + registerAppTool( + server, + toolNames.ls, + { + title: "Ls", + description: + "List a directory in a workspace. Use this for directory inspection before reading files.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + path: z + .string() + .describe("Directory path to list, relative to the workspace root."), + }, + outputSchema: resultOutputSchema(), + ...toolWidgetDescriptorMeta(config, "directory"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + workspaces.resolvePath(workspace, input.path); + const response = await listDirectoryTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.ls, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + const summary = textSummary(response.content); + logToolCall(config, { + tool: toolNames.ls, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.ls, + card: { + workspaceId, + path: input.path, + summary, + payload: { content: response.content }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); +} + +function registerShellTool( + context: ToolRegistrationContext, + shellDescription: string, +): void { + const { server, config, workspaces } = context; + + registerAppTool( + server, + toolNames.shell, + { + title: "Bash", + description: shellDescription, + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + command: z + .string() + .describe( + `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, + ), + workingDirectory: z + .string() + .optional() + .describe( + "Optional working directory relative to the workspace root. Defaults 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 }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const cwd = workspaces.resolveWorkingDirectory( + workspace, + workingDirectory, + ); + const response = await runShellTool(input, { + cwd, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.shell, + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: input.command, + commandLength: input.command.length, + }, + response.content, + startedAt, + ); + return response; + } + + const summary = { + command: input.command, + workingDirectory: workingDirectory ?? ".", + ...textSummary(response.content), + }; + logToolCall(config, { + tool: toolNames.shell, + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: input.command, + commandLength: input.command.length, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.shell, + card: { + workspaceId, + path: workingDirectory, + summary, + payload: { content: response.content }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); +} diff --git a/src/tool-surfaces/types.ts b/src/tool-surfaces/types.ts new file mode 100644 index 00000000..c1fc13fd --- /dev/null +++ b/src/tool-surfaces/types.ts @@ -0,0 +1,104 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ProcessSessionManager } from "../process-sessions.js"; +import type { ServerConfig } from "../config.js"; +import type { WorkspaceRegistry } from "../workspaces.js"; + +export const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; + +export const toolNames = { + openWorkspace: "open_workspace", + read: "read", + write: "write", + edit: "edit", + grep: "grep", + glob: "glob", + ls: "ls", + shell: "bash", +} as const; + +export const workspaceIdDescription = + "Workspace to use. Reuse the current project's workspaceId."; + +export const WRITE_TOOL_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, +}; + +export const EDIT_TOOL_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, +}; + +export const SHELL_TOOL_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, +}; + +export type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +export interface ToolLogFields { + tool: string; + workspaceId?: string; + path?: string; + workingDirectory?: string; + command?: string; + commandLength?: number; + success: boolean; + durationMs: number; + error?: string; +} + +export interface DiffStats { + additions: number; + removals: number; +} + +export type ToolWidgetKind = + | "workspace" + | "read" + | "write" + | "edit" + | "search" + | "directory" + | "shell" + | "show_changes"; + +export interface ToolDefinitionMeta extends Record { + ui: { + resourceUri: string; + visibility: ["model"]; + }; +} + +export type EmptyToolDefinitionMeta = Record & { + "ui/resourceUri"?: string; +}; + +export interface ToolWidgetDescriptorMeta { + _meta: ToolDefinitionMeta | EmptyToolDefinitionMeta; +} + +export interface ToolRegistrationContext { + server: McpServer; + config: ServerConfig; + workspaces: WorkspaceRegistry; + processSessions: ProcessSessionManager; +} + +export interface ToolInstructionContext { + agents: string; + skills: string; +} + +export interface ToolSurface { + register(context: ToolRegistrationContext): void; + instructions(context: ToolInstructionContext): string; +}