From dc0afd9c72f3b6902264ec171eb2b065e561ba6f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 31 Jan 2026 12:48:00 -0800 Subject: [PATCH 1/2] feat(mcp,desktop): convert workspace tools to bulk operations Convert create_workspace, delete_workspace to accept arrays (1-5 items) and add new update_workspace tool. Follows the bulk pattern established by task operations, with sequential processing and partial failure support. --- .../tools/create-worktree.ts | 68 ++++++++++++----- .../tools/delete-workspace.ts | 60 +++++++++++---- .../hooks/useCommandWatcher/tools/index.ts | 2 + .../hooks/useCommandWatcher/tools/types.ts | 34 +++++++++ .../tools/update-workspace.ts | 74 +++++++++++++++++++ .../useCommandWatcher/useCommandWatcher.ts | 4 + .../create-workspace/create-workspace.ts | 48 ++++++------ .../delete-workspace/delete-workspace.ts | 12 ++- .../tools/devices/update-workspace/index.ts | 1 + .../update-workspace/update-workspace.ts | 45 +++++++++++ packages/mcp/src/tools/index.ts | 2 + 11 files changed, 287 insertions(+), 63 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts create mode 100644 packages/mcp/src/tools/devices/update-workspace/index.ts create mode 100644 packages/mcp/src/tools/devices/update-workspace/update-workspace.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts index 6a7fead8074..4c2b5757371 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts @@ -1,12 +1,28 @@ import { z } from "zod"; -import type { CommandResult, ToolContext, ToolDefinition } from "./types"; +import type { + BulkItemError, + CommandResult, + ToolContext, + ToolDefinition, +} from "./types"; +import { buildBulkResult } from "./types"; -const schema = z.object({ +const workspaceInputSchema = z.object({ name: z.string().optional(), branchName: z.string().optional(), baseBranch: z.string().optional(), }); +const schema = z.object({ + workspaces: z.array(workspaceInputSchema).min(1).max(5), +}); + +interface CreatedWorkspace { + workspaceId: string; + workspaceName: string; + branch: string; +} + async function execute( params: z.infer, ctx: ToolContext, @@ -37,29 +53,41 @@ async function execute( projectId = sorted[0].projectId; } - try { - const result = await ctx.createWorktree.mutateAsync({ - projectId, - name: params.name, - branchName: params.branchName, - baseBranch: params.baseBranch, - }); + const created: CreatedWorkspace[] = []; + const errors: BulkItemError[] = []; + + for (const [i, input] of params.workspaces.entries()) { + try { + const result = await ctx.createWorktree.mutateAsync({ + projectId, + name: input.name, + branchName: input.branchName, + baseBranch: input.baseBranch, + }); - return { - success: true, - data: { + created.push({ workspaceId: result.workspace.id, workspaceName: result.workspace.name, branch: result.workspace.branch, - }, - }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to create workspace", - }; + }); + } catch (error) { + errors.push({ + index: i, + name: input.name, + branchName: input.branchName, + error: + error instanceof Error ? error.message : "Failed to create workspace", + }); + } } + + return buildBulkResult({ + items: created, + errors, + itemKey: "created", + allFailedMessage: "All workspace creations failed", + total: params.workspaces.length, + }); } export const createWorkspace: ToolDefinition = { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts index faaeb8b77a2..e34a1c5fc3c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts @@ -1,31 +1,59 @@ import { z } from "zod"; -import type { CommandResult, ToolContext, ToolDefinition } from "./types"; +import type { + BulkItemError, + CommandResult, + ToolContext, + ToolDefinition, +} from "./types"; +import { buildBulkResult } from "./types"; const schema = z.object({ - workspaceId: z.string(), + workspaceIds: z.array(z.string().uuid()).min(1).max(5), }); +interface DeletedWorkspace { + workspaceId: string; +} + async function execute( params: z.infer, ctx: ToolContext, ): Promise { - try { - const result = await ctx.deleteWorkspace.mutateAsync({ - id: params.workspaceId, - }); + const deleted: DeletedWorkspace[] = []; + const errors: BulkItemError[] = []; - if (!result.success) { - return { success: false, error: result.error ?? "Delete failed" }; - } + for (const [i, workspaceId] of params.workspaceIds.entries()) { + try { + const result = await ctx.deleteWorkspace.mutateAsync({ + id: workspaceId, + }); - return { success: true, data: { workspaceId: params.workspaceId } }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to delete workspace", - }; + if (!result.success) { + errors.push({ + index: i, + workspaceId, + error: result.error ?? "Delete failed", + }); + } else { + deleted.push({ workspaceId }); + } + } catch (error) { + errors.push({ + index: i, + workspaceId, + error: + error instanceof Error ? error.message : "Failed to delete workspace", + }); + } } + + return buildBulkResult({ + items: deleted, + errors, + itemKey: "deleted", + allFailedMessage: "All workspace deletions failed", + total: params.workspaceIds.length, + }); } export const deleteWorkspace: ToolDefinition = { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts index e114b8304b1..68742302ef4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts @@ -8,6 +8,7 @@ import { startClaudeSession } from "./start-claude-session"; import { startClaudeSubagent } from "./start-claude-subagent"; import { switchWorkspace } from "./switch-workspace"; import type { CommandResult, ToolContext, ToolDefinition } from "./types"; +import { updateWorkspace } from "./update-workspace"; // Registry of all available tools // biome-ignore lint/suspicious/noExplicitAny: Tool schemas vary @@ -21,6 +22,7 @@ const tools: ToolDefinition[] = [ startClaudeSession, startClaudeSubagent, switchWorkspace, + updateWorkspace, ]; // Map for O(1) lookup by name diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts index 5bccfd63f23..4051352f100 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts @@ -8,6 +8,37 @@ export interface CommandResult { error?: string; } +export interface BulkItemError { + index: number; + error: string; + [key: string]: unknown; +} + +export function buildBulkResult({ + items, + errors, + itemKey, + allFailedMessage, + total, +}: { + items: T[]; + errors: BulkItemError[]; + itemKey: string; + allFailedMessage: string; + total: number; +}): CommandResult { + const data: Record = { + [itemKey]: items, + summary: { total, succeeded: items.length, failed: errors.length }, + }; + if (errors.length > 0) data.errors = errors; + return { + success: items.length > 0, + data, + error: items.length === 0 ? allFailedMessage : undefined, + }; +} + // Available mutations and queries passed to tool handlers export interface ToolContext { // Mutations @@ -16,6 +47,9 @@ export interface ToolContext { deleteWorkspace: ReturnType< typeof electronTrpc.workspaces.delete.useMutation >; + updateWorkspace: ReturnType< + typeof electronTrpc.workspaces.update.useMutation + >; // Query helpers refetchWorkspaces: () => Promise; getWorkspaces: () => SelectWorkspace[] | undefined; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts new file mode 100644 index 00000000000..f3e4e53755a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import type { + BulkItemError, + CommandResult, + ToolContext, + ToolDefinition, +} from "./types"; +import { buildBulkResult } from "./types"; + +const workspaceUpdateSchema = z.object({ + workspaceId: z.string().uuid(), + name: z.string().min(1).optional(), +}); + +const schema = z.object({ + updates: z.array(workspaceUpdateSchema).min(1).max(5), +}); + +interface UpdatedWorkspace { + workspaceId: string; + name?: string; +} + +async function execute( + params: z.infer, + ctx: ToolContext, +): Promise { + const updated: UpdatedWorkspace[] = []; + const errors: BulkItemError[] = []; + + for (const [i, update] of params.updates.entries()) { + try { + if (!update.name) { + errors.push({ + index: i, + workspaceId: update.workspaceId, + error: "No updatable fields provided", + }); + continue; + } + + await ctx.updateWorkspace.mutateAsync({ + id: update.workspaceId, + patch: { name: update.name }, + }); + + updated.push({ + workspaceId: update.workspaceId, + name: update.name, + }); + } catch (error) { + errors.push({ + index: i, + workspaceId: update.workspaceId, + error: + error instanceof Error ? error.message : "Failed to update workspace", + }); + } + } + + return buildBulkResult({ + items: updated, + errors, + itemKey: "updated", + allFailedMessage: "All workspace updates failed", + total: params.updates.length, + }); +} + +export const updateWorkspace: ToolDefinition = { + name: "update_workspace", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts index 7eccb303ccf..ab42178ff6b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts @@ -6,6 +6,7 @@ import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateWorkspace } from "renderer/react-query/workspaces/useCreateWorkspace"; import { useDeleteWorkspace } from "renderer/react-query/workspaces/useDeleteWorkspace"; +import { useUpdateWorkspace } from "renderer/react-query/workspaces/useUpdateWorkspace"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider"; import { executeTool, type ToolContext } from "./tools"; @@ -24,6 +25,7 @@ export function useCommandWatcher() { const createWorktree = useCreateWorkspace({ skipNavigation: true }); const setActive = electronTrpc.workspaces.setActive.useMutation(); const deleteWorkspace = useDeleteWorkspace(); + const updateWorkspace = useUpdateWorkspace(); const { data: workspaces, refetch: refetchWorkspaces } = electronTrpc.workspaces.getAll.useQuery(); @@ -41,6 +43,7 @@ export function useCommandWatcher() { createWorktree, setActive, deleteWorkspace, + updateWorkspace, refetchWorkspaces: async () => refetchWorkspaces(), getWorkspaces: () => workspaces, getProjects: () => projects, @@ -52,6 +55,7 @@ export function useCommandWatcher() { createWorktree, setActive, deleteWorkspace, + updateWorkspace, refetchWorkspaces, workspaces, projects, diff --git a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts index ebfbf43fe5f..9dd55bd4172 100644 --- a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts +++ b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts @@ -2,34 +2,41 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { executeOnDevice, getMcpContext } from "../../utils"; +const workspaceInputSchema = z.object({ + name: z + .string() + .optional() + .describe("Workspace name (auto-generated if not provided)"), + branchName: z + .string() + .optional() + .describe("Branch name (auto-generated if not provided)"), + baseBranch: z + .string() + .optional() + .describe("Branch to create from (defaults to main)"), +}); + export function register(server: McpServer) { server.registerTool( "create_workspace", { - description: "Create a new git worktree workspace", + description: "Create one or more git worktree workspaces on a device", inputSchema: { deviceId: z.string().describe("Target device ID"), - name: z - .string() - .optional() - .describe("Workspace name (auto-generated if not provided)"), - branchName: z - .string() - .optional() - .describe("Branch name (auto-generated if not provided)"), - baseBranch: z - .string() - .optional() - .describe("Branch to create from (defaults to main)"), - taskId: z - .string() - .optional() - .describe("Task ID to associate with workspace"), + workspaces: z + .array(workspaceInputSchema) + .min(1) + .max(5) + .describe("Array of workspaces to create (1-5)"), }, }, async (args, extra) => { const ctx = getMcpContext(extra); const deviceId = args.deviceId as string; + const workspaces = args.workspaces as z.infer< + typeof workspaceInputSchema + >[]; if (!deviceId) { return { @@ -42,12 +49,7 @@ export function register(server: McpServer) { ctx, deviceId, tool: "create_workspace", - params: { - name: args.name, - branchName: args.branchName, - baseBranch: args.baseBranch, - taskId: args.taskId, - }, + params: { workspaces }, }); }, ); diff --git a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts index cfe639a7f1e..d1eea3e9ecb 100644 --- a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts +++ b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts @@ -6,16 +6,20 @@ export function register(server: McpServer) { server.registerTool( "delete_workspace", { - description: "Delete a workspace", + description: "Delete one or more workspaces on a device", inputSchema: { deviceId: z.string().describe("Target device ID"), - workspaceId: z.string().uuid().describe("Workspace ID to delete"), + workspaceIds: z + .array(z.string().uuid()) + .min(1) + .max(5) + .describe("Array of workspace IDs to delete (1-5)"), }, }, async (args, extra) => { const ctx = getMcpContext(extra); const deviceId = args.deviceId as string; - const workspaceId = args.workspaceId as string; + const workspaceIds = args.workspaceIds as string[]; if (!deviceId) { return { @@ -28,7 +32,7 @@ export function register(server: McpServer) { ctx, deviceId, tool: "delete_workspace", - params: { workspaceId }, + params: { workspaceIds }, }); }, ); diff --git a/packages/mcp/src/tools/devices/update-workspace/index.ts b/packages/mcp/src/tools/devices/update-workspace/index.ts new file mode 100644 index 00000000000..532d18f466c --- /dev/null +++ b/packages/mcp/src/tools/devices/update-workspace/index.ts @@ -0,0 +1 @@ +export { register } from "./update-workspace"; diff --git a/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts b/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts new file mode 100644 index 00000000000..986b70a06cd --- /dev/null +++ b/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts @@ -0,0 +1,45 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +const workspaceUpdateSchema = z.object({ + workspaceId: z.string().uuid().describe("Workspace ID to update"), + name: z.string().min(1).optional().describe("New workspace name"), +}); + +export function register(server: McpServer) { + server.registerTool( + "update_workspace", + { + description: + "Update one or more workspaces on a device. Currently supports renaming.", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + updates: z + .array(workspaceUpdateSchema) + .min(1) + .max(5) + .describe("Array of workspace updates (1-5)"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const updates = args.updates as z.infer[]; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "update_workspace", + params: { updates }, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 0e5053d24fb..8d05dd29c26 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -8,6 +8,7 @@ import { register as listWorkspaces } from "./devices/list-workspaces"; import { register as navigateToWorkspace } from "./devices/navigate-to-workspace"; import { register as startClaudeSession } from "./devices/start-claude-session"; import { register as switchWorkspace } from "./devices/switch-workspace"; +import { register as updateWorkspace } from "./devices/update-workspace"; import { register as listMembers } from "./organizations/list-members"; import { register as createTask } from "./tasks/create-task"; import { register as deleteTask } from "./tasks/delete-task"; @@ -32,6 +33,7 @@ const allTools = [ createWorkspace, switchWorkspace, deleteWorkspace, + updateWorkspace, startClaudeSession, ]; From cb117a9130c1daa70f7e20e06e69016a811c3107 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 31 Jan 2026 13:53:51 -0800 Subject: [PATCH 2/2] fix(mcp,desktop): make name required in update_workspace schema The schema said name was optional but the handler errored if omitted. Since renaming is the only supported operation, make it required at the schema level so agents get a clear validation error upfront. --- .../useCommandWatcher/tools/update-workspace.ts | 13 ++----------- .../devices/update-workspace/update-workspace.ts | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts index f3e4e53755a..2a6dd12d98b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/update-workspace.ts @@ -9,7 +9,7 @@ import { buildBulkResult } from "./types"; const workspaceUpdateSchema = z.object({ workspaceId: z.string().uuid(), - name: z.string().min(1).optional(), + name: z.string().min(1), }); const schema = z.object({ @@ -18,7 +18,7 @@ const schema = z.object({ interface UpdatedWorkspace { workspaceId: string; - name?: string; + name: string; } async function execute( @@ -30,15 +30,6 @@ async function execute( for (const [i, update] of params.updates.entries()) { try { - if (!update.name) { - errors.push({ - index: i, - workspaceId: update.workspaceId, - error: "No updatable fields provided", - }); - continue; - } - await ctx.updateWorkspace.mutateAsync({ id: update.workspaceId, patch: { name: update.name }, diff --git a/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts b/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts index 986b70a06cd..cf3d067dc75 100644 --- a/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts +++ b/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts @@ -4,7 +4,7 @@ import { executeOnDevice, getMcpContext } from "../../utils"; const workspaceUpdateSchema = z.object({ workspaceId: z.string().uuid().describe("Workspace ID to update"), - name: z.string().min(1).optional().describe("New workspace name"), + name: z.string().min(1).describe("New workspace name"), }); export function register(server: McpServer) {