From 9c5d3f847f4c0d817d327fd2b563880e96a571c3 Mon Sep 17 00:00:00 2001 From: betterclever Date: Sun, 22 Feb 2026 01:10:11 +0530 Subject: [PATCH 1/2] feat(studio-mcp): add workflow CRUD tools alongside task runs Signed-off-by: betterclever --- .../__tests__/studio-mcp.service.spec.ts | 94 +++++++- backend/src/studio-mcp/studio-mcp.service.ts | 205 +++++++++++++++++- 2 files changed, 297 insertions(+), 2 deletions(-) diff --git a/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts b/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts index 60057234..dd7cc5bb 100644 --- a/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts +++ b/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts @@ -27,6 +27,26 @@ describe('StudioMcpService Unit Tests', () => { workflowsService = { list: jest.fn().mockResolvedValue([]), findById: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ + id: 'created-workflow-id', + name: 'Created Workflow', + description: null, + currentVersion: 1, + currentVersionId: 'created-version-id', + }), + update: jest.fn().mockResolvedValue({ + id: 'updated-workflow-id', + name: 'Updated Workflow', + description: 'Updated description', + currentVersion: 2, + currentVersionId: 'updated-version-id', + }), + updateMetadata: jest.fn().mockResolvedValue({ + id: 'updated-workflow-id', + name: 'Updated Workflow', + description: 'Updated description', + }), + delete: jest.fn().mockResolvedValue(undefined), run: jest.fn().mockResolvedValue({ runId: 'test-run-id', workflowId: 'test-workflow-id', @@ -69,6 +89,8 @@ describe('StudioMcpService Unit Tests', () => { const toolNames = Object.keys(registeredTools).sort(); expect(toolNames).toEqual([ 'cancel_run', + 'create_workflow', + 'delete_workflow', 'get_component', 'get_run_result', 'get_run_status', @@ -77,6 +99,8 @@ describe('StudioMcpService Unit Tests', () => { 'list_runs', 'list_workflows', 'run_workflow', + 'update_workflow', + 'update_workflow_metadata', ]); }); @@ -109,6 +133,58 @@ describe('StudioMcpService Unit Tests', () => { expect(workflowsService.findById).toHaveBeenCalledWith(workflowId, mockAuthContext); }); + it('create_workflow tool uses auth context passed at creation time', async () => { + const server = service.createServer(mockAuthContext); + const registeredTools = getRegisteredTools(server); + const createWorkflowTool = registeredTools['create_workflow']; + + expect(createWorkflowTool).toBeDefined(); + await createWorkflowTool.handler({ + name: 'New Workflow', + nodes: [ + { + id: 'entry-1', + type: 'core.workflow.entrypoint', + position: { x: 10, y: 20 }, + data: { label: 'Start', config: {} }, + }, + ], + edges: [], + }); + + expect(workflowsService.create).toHaveBeenCalledWith( + { + name: 'New Workflow', + description: undefined, + nodes: [ + { + id: 'entry-1', + type: 'core.workflow.entrypoint', + position: { x: 10, y: 20 }, + data: { label: 'Start', config: {} }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + mockAuthContext, + ); + }); + + it('delete_workflow tool uses auth context passed at creation time', async () => { + const server = service.createServer(mockAuthContext); + const registeredTools = getRegisteredTools(server); + const deleteWorkflowTool = registeredTools['delete_workflow']; + + expect(deleteWorkflowTool).toBeDefined(); + await deleteWorkflowTool.handler({ workflowId: '11111111-1111-4111-8111-111111111111' }); + + expect(workflowsService.delete).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111', + mockAuthContext, + ); + }); + it('run_workflow task uses auth context passed at creation time', async () => { const workflowId = '11111111-1111-4111-8111-111111111111'; const inputs = { key: 'value' }; @@ -257,6 +333,18 @@ describe('StudioMcpService Unit Tests', () => { expect(errorThrown).toBe(true); }); + it('denies create_workflow when workflows.create is missing', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + const result = (await tools['create_workflow'].handler({ + name: 'Denied Create', + nodes: [], + edges: [], + })) as { isError?: boolean; content: { text: string }[] }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('workflows.create'); + }); + it('denies cancel_run when runs.cancel is false', async () => { const server = service.createServer(restrictedAuth); const tools = getRegisteredTools(server); @@ -329,7 +417,7 @@ describe('StudioMcpService Unit Tests', () => { expect(getResult.isError).toBeUndefined(); }); - it('denies all 7 gated tools when all permissions are false', async () => { + it('denies all gated non-task tools when all permissions are false', async () => { const noPermsAuth: AuthContext = { ...restrictedAuth, apiKeyPermissions: { @@ -345,6 +433,10 @@ describe('StudioMcpService Unit Tests', () => { const gatedTools = [ 'list_workflows', 'get_workflow', + 'create_workflow', + 'update_workflow', + 'update_workflow_metadata', + 'delete_workflow', 'list_runs', 'get_run_status', 'get_run_result', diff --git a/backend/src/studio-mcp/studio-mcp.service.ts b/backend/src/studio-mcp/studio-mcp.service.ts index d58b6c2d..3da39f56 100644 --- a/backend/src/studio-mcp/studio-mcp.service.ts +++ b/backend/src/studio-mcp/studio-mcp.service.ts @@ -14,7 +14,12 @@ import { import type { ExecutionStatus } from '@shipsec/shared'; import { categorizeComponent } from '../components/utils/categorization'; import { WorkflowsService, type WorkflowRunSummary } from '../workflows/workflows.service'; -import type { ServiceWorkflowResponse } from '../workflows/dto/workflow-graph.dto'; +import { + WorkflowNodeSchema, + WorkflowEdgeSchema, + WorkflowViewportSchema, + type ServiceWorkflowResponse, +} from '../workflows/dto/workflow-graph.dto'; import type { AuthContext, ApiKeyPermissions } from '../auth/types'; import { InMemoryTaskStore, @@ -24,6 +29,9 @@ import { type PermissionPath = | 'workflows.list' | 'workflows.read' + | 'workflows.create' + | 'workflows.update' + | 'workflows.delete' | 'workflows.run' | 'runs.read' | 'runs.cancel'; @@ -154,6 +162,201 @@ export class StudioMcpService { }, ); + server.registerTool( + 'create_workflow', + { + description: + 'Create a new workflow. Provide a name, optional description, and the graph definition (nodes and edges).', + inputSchema: { + name: z.string().describe('Name of the workflow'), + description: z.string().optional().describe('Optional description of the workflow'), + nodes: z + .array(WorkflowNodeSchema) + .min(1) + .describe( + 'Array of workflow nodes. Each node needs id, type (component ID), position {x, y}, and data {label, config}', + ), + edges: z + .array(WorkflowEdgeSchema) + .describe( + 'Array of edges connecting nodes. Each edge needs id, source, target, and optionally sourceHandle/targetHandle for specific ports', + ), + viewport: WorkflowViewportSchema.optional().describe( + 'Optional viewport position {x, y, zoom}', + ), + }, + }, + async (args: { + name: string; + description?: string; + nodes: z.infer[]; + edges: z.infer[]; + viewport?: z.infer; + }) => { + const gate = this.checkPermission(auth, 'workflows.create'); + if (!gate.allowed) return gate.error; + try { + const graph = { + name: args.name, + description: args.description, + nodes: args.nodes, + edges: args.edges, + viewport: args.viewport ?? { x: 0, y: 0, zoom: 1 }, + }; + const result = await this.workflowsService.create(graph, auth); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: result.id, + name: result.name, + description: result.description ?? null, + currentVersion: result.currentVersion, + currentVersionId: result.currentVersionId, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.errorResult(error); + } + }, + ); + + server.registerTool( + 'update_workflow', + { + description: + 'Update an existing workflow graph (nodes and edges). This creates a new workflow version.', + inputSchema: { + workflowId: z.string().uuid().describe('ID of the workflow to update'), + name: z.string().describe('Name of the workflow'), + description: z.string().optional().describe('Optional description'), + nodes: z.array(WorkflowNodeSchema).min(1).describe('Full array of workflow nodes'), + edges: z.array(WorkflowEdgeSchema).describe('Full array of edges'), + viewport: WorkflowViewportSchema.optional().describe('Optional viewport position'), + }, + }, + async (args: { + workflowId: string; + name: string; + description?: string; + nodes: z.infer[]; + edges: z.infer[]; + viewport?: z.infer; + }) => { + const gate = this.checkPermission(auth, 'workflows.update'); + if (!gate.allowed) return gate.error; + try { + const graph = { + name: args.name, + description: args.description, + nodes: args.nodes, + edges: args.edges, + viewport: args.viewport ?? { x: 0, y: 0, zoom: 1 }, + }; + const result = await this.workflowsService.update(args.workflowId, graph, auth); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: result.id, + name: result.name, + description: result.description ?? null, + currentVersion: result.currentVersion, + currentVersionId: result.currentVersionId, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.errorResult(error); + } + }, + ); + + server.registerTool( + 'update_workflow_metadata', + { + description: 'Update only the name and/or description of a workflow.', + inputSchema: { + workflowId: z.string().uuid().describe('ID of the workflow to update'), + name: z.string().describe('New name for the workflow'), + description: z + .string() + .optional() + .nullable() + .describe('New description (or null to clear)'), + }, + }, + async (args: { workflowId: string; name: string; description?: string | null }) => { + const gate = this.checkPermission(auth, 'workflows.update'); + if (!gate.allowed) return gate.error; + try { + const result = await this.workflowsService.updateMetadata( + args.workflowId, + { name: args.name, description: args.description ?? null }, + auth, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: result.id, + name: result.name, + description: result.description ?? null, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.errorResult(error); + } + }, + ); + + server.registerTool( + 'delete_workflow', + { + description: 'Permanently delete a workflow and all its versions.', + inputSchema: { + workflowId: z.string().uuid().describe('ID of the workflow to delete'), + }, + }, + async (args: { workflowId: string }) => { + const gate = this.checkPermission(auth, 'workflows.delete'); + if (!gate.allowed) return gate.error; + try { + await this.workflowsService.delete(args.workflowId, auth); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ deleted: true, workflowId: args.workflowId }, null, 2), + }, + ], + }; + } catch (error) { + return this.errorResult(error); + } + }, + ); + const runWorkflowSchema = { workflowId: z.string().uuid(), inputs: z.record(z.string(), z.unknown()).optional(), From abd21c35eb431e74aef0d2a8620c04809b579c1a Mon Sep 17 00:00:00 2001 From: betterclever Date: Sun, 22 Feb 2026 01:20:39 +0530 Subject: [PATCH 2/2] fix(auth): wire workflow mutation permissions for API keys Signed-off-by: betterclever --- backend/src/api-keys/dto/api-key.dto.ts | 3 +++ backend/src/auth/auth.guard.ts | 3 +++ backend/src/database/schema/api-keys.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/backend/src/api-keys/dto/api-key.dto.ts b/backend/src/api-keys/dto/api-key.dto.ts index 36c56fa0..f20e1b21 100644 --- a/backend/src/api-keys/dto/api-key.dto.ts +++ b/backend/src/api-keys/dto/api-key.dto.ts @@ -8,6 +8,9 @@ export const ApiKeyPermissionsSchema = z.object({ run: z.boolean(), list: z.boolean(), read: z.boolean(), + create: z.boolean().optional(), + update: z.boolean().optional(), + delete: z.boolean().optional(), }), runs: z.object({ read: z.boolean(), diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index eb9c5a97..af08cf6a 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -124,6 +124,9 @@ export class AuthGuard implements CanActivate { run: Boolean(permissions.workflows?.run), list: Boolean(permissions.workflows?.list), read: Boolean(permissions.workflows?.read), + create: Boolean(permissions.workflows?.create), + update: Boolean(permissions.workflows?.update), + delete: Boolean(permissions.workflows?.delete), }, runs: { read: Boolean(permissions.runs?.read), diff --git a/backend/src/database/schema/api-keys.ts b/backend/src/database/schema/api-keys.ts index 5aa0e7b1..9bbae576 100644 --- a/backend/src/database/schema/api-keys.ts +++ b/backend/src/database/schema/api-keys.ts @@ -15,6 +15,9 @@ export interface ApiKeyPermissions { run: boolean; list: boolean; read: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; }; runs: { read: boolean;