From 24d42372158cd795444282b4241dca75c800547a Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 15:47:41 +0530 Subject: [PATCH 01/10] refactor: extract studio MCP tools into modular files Extract 13 existing MCP tools from monolithic studio-mcp.service.ts (727 lines) into separate files under tools/ directory: - tools/types.ts: shared types, permission checker, result helpers - tools/workflow.tools.ts: 7 workflow tools + task monitor - tools/component.tools.ts: 2 component tools - tools/run.tools.ts: 4 run tools Service is now a thin orchestrator (~55 lines) that creates the MCP server and delegates to tool registration functions. Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- backend/src/studio-mcp/studio-mcp.service.ts | 696 +----------------- .../src/studio-mcp/tools/component.tools.ts | 94 +++ backend/src/studio-mcp/tools/run.tools.ts | 111 +++ backend/src/studio-mcp/tools/types.ts | 85 +++ .../src/studio-mcp/tools/workflow.tools.ts | 368 +++++++++ 5 files changed, 670 insertions(+), 684 deletions(-) create mode 100644 backend/src/studio-mcp/tools/component.tools.ts create mode 100644 backend/src/studio-mcp/tools/run.tools.ts create mode 100644 backend/src/studio-mcp/tools/types.ts create mode 100644 backend/src/studio-mcp/tools/workflow.tools.ts diff --git a/backend/src/studio-mcp/studio-mcp.service.ts b/backend/src/studio-mcp/studio-mcp.service.ts index 3da39f56..2d9421bb 100644 --- a/backend/src/studio-mcp/studio-mcp.service.ts +++ b/backend/src/studio-mcp/studio-mcp.service.ts @@ -1,81 +1,23 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - -// Ensure all worker components are registered before accessing the registry -import '@shipsec/studio-worker/components'; -import { - componentRegistry, - extractPorts, - isAgentCallable, - getToolSchema, - type CachedComponentMetadata, -} from '@shipsec/component-sdk'; -import type { ExecutionStatus } from '@shipsec/shared'; -import { categorizeComponent } from '../components/utils/categorization'; -import { WorkflowsService, type WorkflowRunSummary } from '../workflows/workflows.service'; -import { - WorkflowNodeSchema, - WorkflowEdgeSchema, - WorkflowViewportSchema, - type ServiceWorkflowResponse, -} from '../workflows/dto/workflow-graph.dto'; -import type { AuthContext, ApiKeyPermissions } from '../auth/types'; import { InMemoryTaskStore, InMemoryTaskMessageQueue, } from '@modelcontextprotocol/sdk/experimental/index.js'; - -type PermissionPath = - | 'workflows.list' - | 'workflows.read' - | 'workflows.create' - | 'workflows.update' - | 'workflows.delete' - | 'workflows.run' - | 'runs.read' - | 'runs.cancel'; +import { WorkflowsService } from '../workflows/workflows.service'; +import type { AuthContext } from '../auth/types'; +import type { StudioMcpDeps } from './tools/types'; +import { registerWorkflowTools } from './tools/workflow.tools'; +import { registerComponentTools } from './tools/component.tools'; +import { registerRunTools } from './tools/run.tools'; @Injectable() export class StudioMcpService { - private readonly logger = new Logger(StudioMcpService.name); private readonly taskStore = new InMemoryTaskStore(); private readonly taskMessageQueue = new InMemoryTaskMessageQueue(); constructor(private readonly workflowsService: WorkflowsService) {} - /** - * Check whether the caller's API key permits the given action. - * Non-API-key callers (e.g. internal service tokens) are always allowed. - */ - private checkPermission( - auth: AuthContext, - permission: PermissionPath, - ): - | { allowed: true } - | { allowed: false; error: { content: { type: 'text'; text: string }[]; isError: true } } { - const perms = auth.apiKeyPermissions; - if (!perms) return { allowed: true }; // non-API-key auth → unrestricted - - const [scope, action] = permission.split('.') as [keyof ApiKeyPermissions, string]; - const scopePerms = perms[scope] as Record | undefined; - if (!scopePerms || !scopePerms[action]) { - return { - allowed: false, - error: { - content: [ - { - type: 'text' as const, - text: `Permission denied: API key lacks '${permission}' permission.`, - }, - ], - isError: true, - }, - }; - } - return { allowed: true }; - } - /** * Create an MCP server with all Studio tools registered, scoped to the given auth context. * Uses Streamable HTTP transport only (no legacy SSE). @@ -102,626 +44,12 @@ export class StudioMcpService { } private registerTools(server: McpServer, auth: AuthContext): void { - this.registerWorkflowTools(server, auth); - this.registerComponentTools(server); - this.registerRunTools(server, auth); - } - - // --------------------------------------------------------------------------- - // Workflow tools - // --------------------------------------------------------------------------- - - private registerWorkflowTools(server: McpServer, auth: AuthContext): void { - server.registerTool( - 'list_workflows', - { - description: - 'List all workflows in the organization. Returns id, name, description, and version info.', - }, - async () => { - const gate = this.checkPermission(auth, 'workflows.list'); - if (!gate.allowed) return gate.error; - try { - const workflows = await this.workflowsService.list(auth); - const summary = workflows.map((w: ServiceWorkflowResponse) => ({ - id: w.id, - name: w.name, - description: w.description ?? null, - currentVersion: w.currentVersion, - currentVersionId: w.currentVersionId, - createdAt: w.createdAt, - updatedAt: w.updatedAt, - })); - return { - content: [{ type: 'text' as const, text: JSON.stringify(summary, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - - server.registerTool( - 'get_workflow', - { - description: - 'Get detailed information about a specific workflow, including its graph (nodes, edges) and runtime input definitions.', - inputSchema: { workflowId: z.string().uuid() }, - }, - async (args: { workflowId: string }) => { - const gate = this.checkPermission(auth, 'workflows.read'); - if (!gate.allowed) return gate.error; - try { - const workflow = await this.workflowsService.findById(args.workflowId, auth); - return { - content: [{ type: 'text' as const, text: JSON.stringify(workflow, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - - 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(), - versionId: z.string().uuid().optional(), + const deps: StudioMcpDeps = { + workflowsService: this.workflowsService, }; - server.experimental.tasks.registerToolTask( - 'run_workflow', - { - description: - 'Start a workflow execution as a background task. The task handle can be monitored for status updates, and finally retrieved for the workflow result. Also supports legacy polling via get_run_status.', - inputSchema: runWorkflowSchema, - execution: { taskSupport: 'optional' }, - }, - { - createTask: async (args, extra) => { - const gate = this.checkPermission(auth, 'workflows.run'); - if (!gate.allowed) throw new Error(gate.error.content[0].text); - - const task = await extra.taskStore.createTask({ ttl: 12 * 60 * 60 * 1000 }); - - const handle = await this.workflowsService.run( - args.workflowId, - { - inputs: args.inputs ?? {}, - versionId: args.versionId, - }, - auth, - { - trigger: { - type: 'api', - sourceId: auth.userId ?? 'api-key', - label: 'Studio MCP Task', - }, - }, - ); - - this.monitorWorkflowRun( - handle.runId, - handle.temporalRunId, - task.taskId, - extra.taskStore, - server, - auth, - ).catch((err) => { - this.logger.error(`Error monitoring workflow run task for run ${handle.runId}: ${err}`); - }); - - return { task }; - }, - getTask: async (args, extra) => { - const gate = this.checkPermission(auth, 'runs.read'); - if (!gate.allowed) throw new Error(gate.error.content[0].text); - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - getTaskResult: async (args, extra) => { - const gate = this.checkPermission(auth, 'runs.read'); - if (!gate.allowed) throw new Error(gate.error.content[0].text); - const result = await extra.taskStore.getTaskResult(extra.taskId); - return result as any; - }, - }, - ); - } - - // --------------------------------------------------------------------------- - // Component tools - // --------------------------------------------------------------------------- - - private registerComponentTools(server: McpServer): void { - server.registerTool( - 'list_components', - { - description: - 'List all available workflow components (nodes) with their category, description, and whether they are agent-callable.', - }, - async () => { - try { - const entries = componentRegistry.listMetadata(); - const components = entries.map((entry: CachedComponentMetadata) => { - const def = entry.definition; - const category = categorizeComponent(def); - return { - id: def.id, - name: def.label, - category, - description: def.ui?.description ?? def.docs ?? '', - runner: def.runner?.kind ?? 'inline', - agentCallable: isAgentCallable(def), - inputCount: (entry.inputs ?? []).length, - outputCount: (entry.outputs ?? []).length, - }; - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(components, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - - server.registerTool( - 'get_component', - { - description: - 'Get detailed information about a specific component, including its full input/output/parameter schemas.', - inputSchema: { componentId: z.string() }, - }, - async (args: { componentId: string }) => { - try { - const entry = componentRegistry.getMetadata(args.componentId); - if (!entry) { - return { - content: [ - { - type: 'text' as const, - text: `Component "${args.componentId}" not found`, - }, - ], - isError: true, - }; - } - const def = entry.definition; - const category = categorizeComponent(def); - const result = { - id: def.id, - name: def.label, - category, - description: def.ui?.description ?? def.docs ?? '', - documentation: def.docs ?? null, - runner: def.runner, - inputs: entry.inputs ?? extractPorts(def.inputs), - outputs: entry.outputs ?? extractPorts(def.outputs), - parameters: entry.parameters ?? [], - agentCallable: isAgentCallable(def), - toolSchema: isAgentCallable(def) ? getToolSchema(def) : null, - examples: def.ui?.examples ?? [], - }; - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - } - - // --------------------------------------------------------------------------- - // Run tools - // --------------------------------------------------------------------------- - - private registerRunTools(server: McpServer, auth: AuthContext): void { - server.registerTool( - 'list_runs', - { - description: 'List recent workflow runs. Optionally filter by workflow or status.', - inputSchema: { - workflowId: z.string().uuid().optional(), - status: z - .enum([ - 'RUNNING', - 'COMPLETED', - 'FAILED', - 'CANCELLED', - 'TERMINATED', - 'TIMED_OUT', - 'AWAITING_INPUT', - ]) - .optional(), - limit: z.number().int().positive().max(100).optional(), - }, - }, - async (args: { workflowId?: string; status?: ExecutionStatus; limit?: number }) => { - const gate = this.checkPermission(auth, 'runs.read'); - if (!gate.allowed) return gate.error; - try { - const result = await this.workflowsService.listRuns(auth, { - workflowId: args.workflowId, - status: args.status, - limit: args.limit ?? 20, - }); - const runs = result.runs.map((r: WorkflowRunSummary) => ({ - id: r.id, - workflowId: r.workflowId, - workflowName: r.workflowName, - status: r.status, - startTime: r.startTime, - endTime: r.endTime, - duration: r.duration, - triggerType: r.triggerType, - })); - return { - content: [{ type: 'text' as const, text: JSON.stringify(runs, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - - server.registerTool( - 'get_run_status', - { - description: - 'Get the current status of a workflow run including progress, failures, and timing.', - inputSchema: { runId: z.string() }, - }, - async (args: { runId: string }) => { - const gate = this.checkPermission(auth, 'runs.read'); - if (!gate.allowed) return gate.error; - try { - const status = await this.workflowsService.getRunStatus(args.runId, undefined, auth); - return { - content: [{ type: 'text' as const, text: JSON.stringify(status, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - - server.registerTool( - 'get_run_result', - { - description: 'Get the final result/output of a completed workflow run.', - inputSchema: { runId: z.string() }, - }, - async (args: { runId: string }) => { - const gate = this.checkPermission(auth, 'runs.read'); - if (!gate.allowed) return gate.error; - try { - const result = await this.workflowsService.getRunResult(args.runId, undefined, auth); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - - server.registerTool( - 'cancel_run', - { - description: 'Cancel a running workflow execution.', - inputSchema: { runId: z.string() }, - }, - async (args: { runId: string }) => { - const gate = this.checkPermission(auth, 'runs.cancel'); - if (!gate.allowed) return gate.error; - try { - await this.workflowsService.cancelRun(args.runId, undefined, auth); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ cancelled: true, runId: args.runId }, null, 2), - }, - ], - }; - } catch (error) { - return this.errorResult(error); - } - }, - ); - } - - private async monitorWorkflowRun( - runId: string, - temporalRunId: string | undefined, - taskId: string, - taskStore: any, - server: McpServer, - auth: AuthContext, - ): Promise { - const isTerminal = (status: string) => - ['COMPLETED', 'FAILED', 'CANCELLED', 'TERMINATED', 'TIMED_OUT'].includes(status); - - const mapStatus = (status: string): 'working' | 'completed' | 'cancelled' | 'failed' => { - switch (status) { - case 'RUNNING': - case 'QUEUED': - case 'AWAITING_INPUT': - return 'working'; - case 'COMPLETED': - return 'completed'; - case 'CANCELLED': - case 'TERMINATED': - case 'TIMED_OUT': - return 'cancelled'; - case 'FAILED': - return 'failed'; - default: - return 'working'; - } - }; - - while (true) { - try { - const runStatusPayload = await this.workflowsService.getRunStatus( - runId, - temporalRunId, - auth, - ); - const taskState = mapStatus(runStatusPayload.status); - - if (isTerminal(runStatusPayload.status)) { - // For terminal states, storeTaskResult sets the status itself. - // Do NOT call updateTaskStatus first — it would move the task into a terminal - // state and then storeTaskResult would refuse to update it again. - let resultData: any; - if (taskState === 'completed') { - try { - resultData = await this.workflowsService.getRunResult(runId, temporalRunId, auth); - } catch (err) { - resultData = { error: String(err) }; - } - } else { - resultData = runStatusPayload.failure || { reason: runStatusPayload.status }; - } - - const resultPayload = { - content: [{ type: 'text', text: JSON.stringify(resultData, null, 2) }], - }; - - const storeStatus = taskState === 'completed' ? 'completed' : 'failed'; - await taskStore.storeTaskResult(taskId, storeStatus, resultPayload); - break; - } - - // Non-terminal: just update status and keep polling - await taskStore.updateTaskStatus(taskId, taskState, runStatusPayload.status); - await new Promise((res) => setTimeout(res, 2000)); - } catch (err) { - this.logger.error(`Error monitoring task ${taskId} (run: ${runId}): ${err}`); - try { - // storeTaskResult sets the terminal status; don't call updateTaskStatus first - await taskStore.storeTaskResult(taskId, 'failed', { - content: [{ type: 'text', text: `Failed to monitor workflow run: ${String(err)}` }], - isError: true, - }); - } catch (_storeErr) { - // Ignore — task may already be in a terminal state - } - break; - } - } - } - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - private errorResult(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Studio MCP tool error: ${message}`); - return { - content: [{ type: 'text' as const, text: `Error: ${message}` }], - isError: true, - }; + registerWorkflowTools(server, auth, deps); + registerComponentTools(server); + registerRunTools(server, auth, deps); } } diff --git a/backend/src/studio-mcp/tools/component.tools.ts b/backend/src/studio-mcp/tools/component.tools.ts new file mode 100644 index 00000000..8124d318 --- /dev/null +++ b/backend/src/studio-mcp/tools/component.tools.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// Ensure all worker components are registered before accessing the registry +import '@shipsec/studio-worker/components'; +import { + componentRegistry, + extractPorts, + isAgentCallable, + getToolSchema, + type CachedComponentMetadata, +} from '@shipsec/component-sdk'; +import { categorizeComponent } from '../../components/utils/categorization'; +import { errorResult } from './types'; + +export function registerComponentTools(server: McpServer): void { + server.registerTool( + 'list_components', + { + description: + 'List all available workflow components (nodes) with their category, description, and whether they are agent-callable.', + }, + async () => { + try { + const entries = componentRegistry.listMetadata(); + const components = entries.map((entry: CachedComponentMetadata) => { + const def = entry.definition; + const category = categorizeComponent(def); + return { + id: def.id, + name: def.label, + category, + description: def.ui?.description ?? def.docs ?? '', + runner: def.runner?.kind ?? 'inline', + agentCallable: isAgentCallable(def), + inputCount: (entry.inputs ?? []).length, + outputCount: (entry.outputs ?? []).length, + }; + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(components, null, 2) }], + }; + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_component', + { + description: + 'Get detailed information about a specific component, including its full input/output/parameter schemas.', + inputSchema: { componentId: z.string() }, + }, + async (args: { componentId: string }) => { + try { + const entry = componentRegistry.getMetadata(args.componentId); + if (!entry) { + return { + content: [ + { + type: 'text' as const, + text: `Component "${args.componentId}" not found`, + }, + ], + isError: true, + }; + } + const def = entry.definition; + const category = categorizeComponent(def); + const result = { + id: def.id, + name: def.label, + category, + description: def.ui?.description ?? def.docs ?? '', + documentation: def.docs ?? null, + runner: def.runner, + inputs: entry.inputs ?? extractPorts(def.inputs), + outputs: entry.outputs ?? extractPorts(def.outputs), + parameters: entry.parameters ?? [], + agentCallable: isAgentCallable(def), + toolSchema: isAgentCallable(def) ? getToolSchema(def) : null, + examples: def.ui?.examples ?? [], + }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return errorResult(error); + } + }, + ); +} diff --git a/backend/src/studio-mcp/tools/run.tools.ts b/backend/src/studio-mcp/tools/run.tools.ts new file mode 100644 index 00000000..a154e1b6 --- /dev/null +++ b/backend/src/studio-mcp/tools/run.tools.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ExecutionStatus } from '@shipsec/shared'; +import type { AuthContext } from '../../auth/types'; +import type { WorkflowRunSummary } from '../../workflows/workflows.service'; +import { checkPermission, errorResult, jsonResult, type StudioMcpDeps } from './types'; + +export function registerRunTools(server: McpServer, auth: AuthContext, deps: StudioMcpDeps): void { + const { workflowsService } = deps; + + server.registerTool( + 'list_runs', + { + description: 'List recent workflow runs. Optionally filter by workflow or status.', + inputSchema: { + workflowId: z.string().uuid().optional(), + status: z + .enum([ + 'RUNNING', + 'COMPLETED', + 'FAILED', + 'CANCELLED', + 'TERMINATED', + 'TIMED_OUT', + 'AWAITING_INPUT', + ]) + .optional(), + limit: z.number().int().positive().max(100).optional(), + }, + }, + async (args: { workflowId?: string; status?: ExecutionStatus; limit?: number }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + try { + const result = await workflowsService.listRuns(auth, { + workflowId: args.workflowId, + status: args.status, + limit: args.limit ?? 20, + }); + const runs = result.runs.map((r: WorkflowRunSummary) => ({ + id: r.id, + workflowId: r.workflowId, + workflowName: r.workflowName, + status: r.status, + startTime: r.startTime, + endTime: r.endTime, + duration: r.duration, + triggerType: r.triggerType, + })); + return jsonResult(runs); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_run_status', + { + description: + 'Get the current status of a workflow run including progress, failures, and timing.', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + try { + const status = await workflowsService.getRunStatus(args.runId, undefined, auth); + return jsonResult(status); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_run_result', + { + description: 'Get the final result/output of a completed workflow run.', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + try { + const result = await workflowsService.getRunResult(args.runId, undefined, auth); + return jsonResult(result); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'cancel_run', + { + description: 'Cancel a running workflow execution.', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.cancel'); + if (!gate.allowed) return gate.error; + try { + await workflowsService.cancelRun(args.runId, undefined, auth); + return jsonResult({ cancelled: true, runId: args.runId }); + } catch (error) { + return errorResult(error); + } + }, + ); +} diff --git a/backend/src/studio-mcp/tools/types.ts b/backend/src/studio-mcp/tools/types.ts new file mode 100644 index 00000000..ac1a59bd --- /dev/null +++ b/backend/src/studio-mcp/tools/types.ts @@ -0,0 +1,85 @@ +import { Logger } from '@nestjs/common'; +import type { AuthContext, ApiKeyPermissions } from '../../auth/types'; +import type { WorkflowsService } from '../../workflows/workflows.service'; + +export type PermissionPath = + | 'workflows.list' + | 'workflows.read' + | 'workflows.create' + | 'workflows.update' + | 'workflows.delete' + | 'workflows.run' + | 'runs.read' + | 'runs.cancel' + | 'artifacts.read' + | 'artifacts.delete' + | 'secrets.list' + | 'secrets.read' + | 'secrets.create' + | 'secrets.update' + | 'secrets.delete' + | 'schedules.list' + | 'schedules.read' + | 'schedules.create' + | 'schedules.update' + | 'schedules.delete' + | 'human-inputs.read' + | 'human-inputs.resolve'; + +export interface ToolResult { + content: [{ type: 'text'; text: string }, ...{ type: 'text'; text: string }[]]; + isError?: boolean; +} + +export interface StudioMcpDeps { + workflowsService: WorkflowsService; +} + +const logger = new Logger('StudioMcpTools'); + +/** + * Check whether the caller's API key permits the given action. + * Non-API-key callers (e.g. internal service tokens) are always allowed. + */ +export function checkPermission( + auth: AuthContext, + permission: PermissionPath, +): + | { allowed: true } + | { allowed: false; error: { content: { type: 'text'; text: string }[]; isError: true } } { + const perms = auth.apiKeyPermissions; + if (!perms) return { allowed: true }; // non-API-key auth → unrestricted + + const [scope, action] = permission.split('.') as [keyof ApiKeyPermissions, string]; + const scopePerms = perms[scope] as Record | undefined; + if (!scopePerms || !scopePerms[action]) { + return { + allowed: false, + error: { + content: [ + { + type: 'text' as const, + text: `Permission denied: API key lacks '${permission}' permission.`, + }, + ], + isError: true, + }, + }; + } + return { allowed: true }; +} + +export function errorResult(error: unknown): ToolResult { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Studio MCP tool error: ${message}`); + return { + content: [{ type: 'text' as const, text: `Error: ${message}` }], + isError: true, + }; +} + +export function jsonResult(data: unknown): ToolResult { + return { + content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], + }; +} diff --git a/backend/src/studio-mcp/tools/workflow.tools.ts b/backend/src/studio-mcp/tools/workflow.tools.ts new file mode 100644 index 00000000..e3922a60 --- /dev/null +++ b/backend/src/studio-mcp/tools/workflow.tools.ts @@ -0,0 +1,368 @@ +import { Logger } from '@nestjs/common'; +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import { + WorkflowNodeSchema, + WorkflowEdgeSchema, + WorkflowViewportSchema, + type ServiceWorkflowResponse, +} from '../../workflows/dto/workflow-graph.dto'; +import { checkPermission, errorResult, jsonResult, type StudioMcpDeps } from './types'; + +const logger = new Logger('WorkflowTools'); + +export function registerWorkflowTools( + server: McpServer, + auth: AuthContext, + deps: StudioMcpDeps, +): void { + const { workflowsService } = deps; + + server.registerTool( + 'list_workflows', + { + description: + 'List all workflows in the organization. Returns id, name, description, and version info.', + }, + async () => { + const gate = checkPermission(auth, 'workflows.list'); + if (!gate.allowed) return gate.error; + try { + const workflows = await workflowsService.list(auth); + const summary = workflows.map((w: ServiceWorkflowResponse) => ({ + id: w.id, + name: w.name, + description: w.description ?? null, + currentVersion: w.currentVersion, + currentVersionId: w.currentVersionId, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + })); + return jsonResult(summary); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_workflow', + { + description: + 'Get detailed information about a specific workflow, including its graph (nodes, edges) and runtime input definitions.', + inputSchema: { workflowId: z.string().uuid() }, + }, + async (args: { workflowId: string }) => { + const gate = checkPermission(auth, 'workflows.read'); + if (!gate.allowed) return gate.error; + try { + const workflow = await workflowsService.findById(args.workflowId, auth); + return jsonResult(workflow); + } catch (error) { + return errorResult(error); + } + }, + ); + + 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 = 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 workflowsService.create(graph, auth); + return jsonResult({ + id: result.id, + name: result.name, + description: result.description ?? null, + currentVersion: result.currentVersion, + currentVersionId: result.currentVersionId, + }); + } catch (error) { + return 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 = 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 workflowsService.update(args.workflowId, graph, auth); + return jsonResult({ + id: result.id, + name: result.name, + description: result.description ?? null, + currentVersion: result.currentVersion, + currentVersionId: result.currentVersionId, + }); + } catch (error) { + return 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 = checkPermission(auth, 'workflows.update'); + if (!gate.allowed) return gate.error; + try { + const result = await workflowsService.updateMetadata( + args.workflowId, + { name: args.name, description: args.description ?? null }, + auth, + ); + return jsonResult({ + id: result.id, + name: result.name, + description: result.description ?? null, + }); + } catch (error) { + return 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 = checkPermission(auth, 'workflows.delete'); + if (!gate.allowed) return gate.error; + try { + await workflowsService.delete(args.workflowId, auth); + return jsonResult({ deleted: true, workflowId: args.workflowId }); + } catch (error) { + return errorResult(error); + } + }, + ); + + const runWorkflowSchema = { + workflowId: z.string().uuid(), + inputs: z.record(z.string(), z.unknown()).optional(), + versionId: z.string().uuid().optional(), + }; + + server.experimental.tasks.registerToolTask( + 'run_workflow', + { + description: + 'Start a workflow execution as a background task. The task handle can be monitored for status updates, and finally retrieved for the workflow result. Also supports legacy polling via get_run_status.', + inputSchema: runWorkflowSchema, + execution: { taskSupport: 'optional' }, + }, + { + createTask: async (args, extra) => { + const gate = checkPermission(auth, 'workflows.run'); + if (!gate.allowed) throw new Error(gate.error.content[0].text); + + const task = await extra.taskStore.createTask({ ttl: 12 * 60 * 60 * 1000 }); + + const handle = await workflowsService.run( + args.workflowId, + { + inputs: args.inputs ?? {}, + versionId: args.versionId, + }, + auth, + { + trigger: { + type: 'api', + sourceId: auth.userId ?? 'api-key', + label: 'Studio MCP Task', + }, + }, + ); + + monitorWorkflowRun( + handle.runId, + handle.temporalRunId, + task.taskId, + extra.taskStore, + workflowsService, + auth, + ).catch((err) => { + logger.error(`Error monitoring workflow run task for run ${handle.runId}: ${err}`); + }); + + return { task }; + }, + getTask: async (args, extra) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) throw new Error(gate.error.content[0].text); + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + getTaskResult: async (args, extra) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) throw new Error(gate.error.content[0].text); + const result = await extra.taskStore.getTaskResult(extra.taskId); + return result as any; + }, + }, + ); +} + +async function monitorWorkflowRun( + runId: string, + temporalRunId: string | undefined, + taskId: string, + taskStore: any, + workflowsService: StudioMcpDeps['workflowsService'], + auth: AuthContext, +): Promise { + const isTerminal = (status: string) => + ['COMPLETED', 'FAILED', 'CANCELLED', 'TERMINATED', 'TIMED_OUT'].includes(status); + + const mapStatus = (status: string): 'working' | 'completed' | 'cancelled' | 'failed' => { + switch (status) { + case 'RUNNING': + case 'QUEUED': + case 'AWAITING_INPUT': + return 'working'; + case 'COMPLETED': + return 'completed'; + case 'CANCELLED': + case 'TERMINATED': + case 'TIMED_OUT': + return 'cancelled'; + case 'FAILED': + return 'failed'; + default: + return 'working'; + } + }; + + while (true) { + try { + const runStatusPayload = await workflowsService.getRunStatus(runId, temporalRunId, auth); + const taskState = mapStatus(runStatusPayload.status); + + if (isTerminal(runStatusPayload.status)) { + // For terminal states, storeTaskResult sets the status itself. + // Do NOT call updateTaskStatus first — it would move the task into a terminal + // state and then storeTaskResult would refuse to update it again. + let resultData: any; + if (taskState === 'completed') { + try { + resultData = await workflowsService.getRunResult(runId, temporalRunId, auth); + } catch (err) { + resultData = { error: String(err) }; + } + } else { + resultData = runStatusPayload.failure || { reason: runStatusPayload.status }; + } + + const resultPayload = { + content: [{ type: 'text', text: JSON.stringify(resultData, null, 2) }], + }; + + const storeStatus = taskState === 'completed' ? 'completed' : 'failed'; + await taskStore.storeTaskResult(taskId, storeStatus, resultPayload); + break; + } + + // Non-terminal: just update status and keep polling + await taskStore.updateTaskStatus(taskId, taskState, runStatusPayload.status); + await new Promise((res) => setTimeout(res, 2000)); + } catch (err) { + logger.error(`Error monitoring task ${taskId} (run: ${runId}): ${err}`); + try { + // storeTaskResult sets the terminal status; don't call updateTaskStatus first + await taskStore.storeTaskResult(taskId, 'failed', { + content: [{ type: 'text', text: `Failed to monitor workflow run: ${String(err)}` }], + isError: true, + }); + } catch (_storeErr) { + // Ignore — task may already be in a terminal state + } + break; + } + } +} From abc049e46a06c9f5b2d88b6d566d03f8d0d5aa63 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 15:59:38 +0530 Subject: [PATCH 02/10] feat(studio-mcp): add run detail tools (trace, node-io, logs, config, children) Expand StudioMcpDeps interface with optional services for trace, node-io, log-stream, artifacts, schedules, secrets, and human-inputs. Add 6 new run-scoped tools: get_run_config, get_run_trace, list_run_node_io, get_node_io (with full data fetch), get_run_logs (cursor pagination), and list_child_runs. Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- backend/src/studio-mcp/tools/run.tools.ts | 144 +++++++++++++++++++++- backend/src/studio-mcp/tools/types.ts | 39 ++++++ 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/backend/src/studio-mcp/tools/run.tools.ts b/backend/src/studio-mcp/tools/run.tools.ts index a154e1b6..01c1baa7 100644 --- a/backend/src/studio-mcp/tools/run.tools.ts +++ b/backend/src/studio-mcp/tools/run.tools.ts @@ -6,7 +6,7 @@ import type { WorkflowRunSummary } from '../../workflows/workflows.service'; import { checkPermission, errorResult, jsonResult, type StudioMcpDeps } from './types'; export function registerRunTools(server: McpServer, auth: AuthContext, deps: StudioMcpDeps): void { - const { workflowsService } = deps; + const { workflowsService, traceService, nodeIOService, logStreamService } = deps; server.registerTool( 'list_runs', @@ -108,4 +108,146 @@ export function registerRunTools(server: McpServer, auth: AuthContext, deps: Stu } }, ); + + server.registerTool( + 'get_run_config', + { + description: 'Get the original inputs and version metadata for a run.', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + try { + const config = await workflowsService.getRunConfig(args.runId, auth); + return jsonResult(config); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_run_trace', + { + description: + 'Get trace events (node lifecycle: started, completed, failed, progress) for a run.', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + if (!traceService) return errorResult(new Error('traceService is not available')); + try { + const result = await traceService.list(args.runId, auth); + return jsonResult(result.events); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'list_run_node_io', + { + description: 'List all nodes in a run with their I/O summary (status, timing, data size).', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + if (!nodeIOService) return errorResult(new Error('nodeIOService is not available')); + try { + const summaries = await nodeIOService.listSummaries( + args.runId, + auth.organizationId ?? undefined, + ); + return jsonResult(summaries); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_node_io', + { + description: + 'Get detailed inputs and outputs for a specific node in a run. Set full=true to fetch complete data from storage instead of the 1KB preview.', + inputSchema: { + runId: z.string(), + nodeRef: z.string(), + full: z.boolean().optional(), + }, + }, + async (args: { runId: string; nodeRef: string; full?: boolean }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + if (!nodeIOService) return errorResult(new Error('nodeIOService is not available')); + try { + const io = await nodeIOService.getNodeIO(args.runId, args.nodeRef, args.full ?? false); + return jsonResult(io); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_run_logs', + { + description: + 'Get structured log entries for a run with optional filtering by node, stream, level, and pagination.', + inputSchema: { + runId: z.string(), + nodeRef: z.string().optional(), + stream: z.enum(['stdout', 'stderr']).optional(), + level: z.string().optional(), + limit: z.number().int().positive().max(1000).optional(), + cursor: z.string().optional(), + }, + }, + async (args: { + runId: string; + nodeRef?: string; + stream?: 'stdout' | 'stderr'; + level?: string; + limit?: number; + cursor?: string; + }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + if (!logStreamService) return errorResult(new Error('logStreamService is not available')); + try { + const logs = await logStreamService.fetch(args.runId, auth, { + nodeRef: args.nodeRef, + stream: args.stream, + level: args.level, + limit: args.limit ?? 100, + cursor: args.cursor, + }); + return jsonResult(logs); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'list_child_runs', + { + description: 'List sub-workflow runs spawned by a parent run.', + inputSchema: { runId: z.string() }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'runs.read'); + if (!gate.allowed) return gate.error; + try { + const children = await workflowsService.listChildRuns(args.runId, auth); + return jsonResult(children); + } catch (error) { + return errorResult(error); + } + }, + ); } diff --git a/backend/src/studio-mcp/tools/types.ts b/backend/src/studio-mcp/tools/types.ts index ac1a59bd..9bade28c 100644 --- a/backend/src/studio-mcp/tools/types.ts +++ b/backend/src/studio-mcp/tools/types.ts @@ -27,12 +27,51 @@ export type PermissionPath = | 'human-inputs.resolve'; export interface ToolResult { + [x: string]: unknown; content: [{ type: 'text'; text: string }, ...{ type: 'text'; text: string }[]]; isError?: boolean; } export interface StudioMcpDeps { workflowsService: WorkflowsService; + traceService?: { list(runId: string, auth?: any): Promise<{ events: any[]; cursor?: string }> }; + nodeIOService?: { + listSummaries(runId: string, organizationId?: string): Promise; + getNodeIO(runId: string, nodeRef: string, full?: boolean): Promise; + }; + logStreamService?: { fetch(runId: string, auth: any, options?: any): Promise }; + terminalStreamService?: { + listStreams(runId: string): Promise; + fetchChunks(runId: string, options?: any): Promise; + }; + artifactsService?: { + listArtifacts(auth: any, filters?: any): Promise; + listRunArtifacts(auth: any, runId: string): Promise; + downloadArtifact(auth: any, artifactId: string): Promise<{ buffer: Buffer; artifact: any }>; + deleteArtifact(auth: any, artifactId: string): Promise; + }; + schedulesService?: { + list(auth: any, filters?: any): Promise; + get(auth: any, id: string): Promise; + create(auth: any, dto: any): Promise; + update(auth: any, id: string, dto: any): Promise; + delete(auth: any, id: string): Promise; + pause(auth: any, id: string): Promise; + resume(auth: any, id: string): Promise; + trigger(auth: any, id: string): Promise; + }; + secretsService?: { + listSecrets(auth: any): Promise; + createSecret(auth: any, input: any): Promise; + rotateSecret(auth: any, secretId: string, input: any): Promise; + updateSecret(auth: any, secretId: string, input: any): Promise; + deleteSecret(auth: any, secretId: string): Promise; + }; + humanInputsService?: { + list(query?: any, organizationId?: string): Promise; + getById(id: string, organizationId?: string): Promise; + resolve(id: string, dto: any, organizationId?: string, auth?: any): Promise; + }; } const logger = new Logger('StudioMcpTools'); From 61d53941b0dd052e40a61adc90b046e39d7534a3 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 16:06:56 +0530 Subject: [PATCH 03/10] feat(studio-mcp): add artifact tools with windowed viewing Add 3 artifact tools: list_artifacts (filter by workflow/search), list_run_artifacts, and view_artifact with byte-level windowing for large files. Includes text/binary detection via MIME type and content heuristics. Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- .../src/studio-mcp/tools/artifact.tools.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 backend/src/studio-mcp/tools/artifact.tools.ts diff --git a/backend/src/studio-mcp/tools/artifact.tools.ts b/backend/src/studio-mcp/tools/artifact.tools.ts new file mode 100644 index 00000000..c9a180c7 --- /dev/null +++ b/backend/src/studio-mcp/tools/artifact.tools.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import { type StudioMcpDeps, checkPermission, jsonResult, errorResult } from './types'; + +const TEXT_MIME_PREFIXES = ['text/', 'application/json', 'application/xml', 'application/yaml']; + +function isTextMime(mimeType: string | undefined | null): boolean { + if (!mimeType) return false; + return TEXT_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix)); +} + +function isLikelyText(buffer: Buffer): boolean { + // Sample the first 512 bytes for null bytes — a quick binary heuristic + const sample = buffer.slice(0, 512); + for (const byte of sample) { + if (byte === 0) return false; + } + return true; +} + +export function registerArtifactTools( + server: McpServer, + auth: AuthContext, + deps: StudioMcpDeps, +): void { + server.registerTool( + 'list_artifacts', + { + description: 'List workspace artifacts with optional filtering by workflow or search term.', + inputSchema: { + workflowId: z.string().uuid().optional(), + search: z.string().optional(), + limit: z.number().int().positive().max(200).optional(), + }, + }, + async (args: { workflowId?: string; search?: string; limit?: number }) => { + const gate = checkPermission(auth, 'artifacts.read'); + if (!gate.allowed) return gate.error; + + if (!deps.artifactsService) { + return errorResult(new Error('Artifacts service is not available.')); + } + + try { + const result = await deps.artifactsService.listArtifacts(auth, { + workflowId: args.workflowId, + search: args.search, + limit: args.limit ?? 20, + }); + + // Normalise to a consistent shape regardless of what the service returns + const artifacts = Array.isArray(result) + ? result + : (result?.artifacts ?? result?.items ?? []); + const summary = artifacts.map((a: any) => ({ + id: a.id, + name: a.name, + size: a.size, + mimeType: a.mimeType ?? a.contentType, + runId: a.runId, + createdAt: a.createdAt, + })); + + return jsonResult(summary); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'list_run_artifacts', + { + description: 'List all artifacts produced by a specific workflow run.', + inputSchema: { + runId: z.string(), + }, + }, + async (args: { runId: string }) => { + const gate = checkPermission(auth, 'artifacts.read'); + if (!gate.allowed) return gate.error; + + if (!deps.artifactsService) { + return errorResult(new Error('Artifacts service is not available.')); + } + + try { + const result = await deps.artifactsService.listRunArtifacts(auth, args.runId); + const artifacts = Array.isArray(result) ? result : (result?.artifacts ?? []); + const summary = artifacts.map((a: any) => ({ + id: a.id, + name: a.name, + size: a.size, + mimeType: a.mimeType ?? a.contentType, + runId: a.runId, + createdAt: a.createdAt, + })); + + return jsonResult(summary); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'view_artifact', + { + description: + 'View artifact content with windowing support for large files. ' + + 'For binary files returns metadata only. ' + + 'Use offset/limit to page through large text files.', + inputSchema: { + artifactId: z.string(), + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(100000).optional(), + }, + }, + async (args: { artifactId: string; offset?: number; limit?: number }) => { + const gate = checkPermission(auth, 'artifacts.read'); + if (!gate.allowed) return gate.error; + + if (!deps.artifactsService) { + return errorResult(new Error('Artifacts service is not available.')); + } + + try { + const { buffer, artifact } = await deps.artifactsService.downloadArtifact( + auth, + args.artifactId, + ); + + const totalSize = buffer.length; + const offset = args.offset ?? 0; + const limit = args.limit ?? 10000; + const mimeType = artifact?.mimeType ?? artifact?.contentType; + + // Determine if the artifact is text-readable + const textByMime = isTextMime(mimeType); + const textByContent = !mimeType && isLikelyText(buffer); + + if (!textByMime && !textByContent) { + return jsonResult({ + id: artifact?.id ?? args.artifactId, + name: artifact?.name, + mimeType, + totalSize, + isText: false, + message: `Binary file, ${totalSize} bytes, mime: ${mimeType ?? 'unknown'}. Use the download endpoint for full content.`, + }); + } + + const slice = buffer.slice(offset, offset + limit); + let content: string; + try { + content = slice.toString('utf-8'); + } catch { + return jsonResult({ + id: artifact?.id ?? args.artifactId, + name: artifact?.name, + mimeType, + totalSize, + isText: false, + message: `Could not decode as UTF-8. Binary file, ${totalSize} bytes, mime: ${mimeType ?? 'unknown'}.`, + }); + } + + return jsonResult({ + id: artifact?.id ?? args.artifactId, + name: artifact?.name, + mimeType, + totalSize, + offset, + limit, + hasMore: offset + limit < totalSize, + isText: true, + content, + }); + } catch (error) { + return errorResult(error); + } + }, + ); +} From 0f5d4c446e6eb7224339ed88ad002467f11e98a8 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 16:07:08 +0530 Subject: [PATCH 04/10] feat(studio-mcp): add schedule, secret, and human-input tools Add 16 new MCP tools: - Schedules (8): list, get, create, update, delete, pause, resume, trigger - Secrets (5): list, create, rotate, update_metadata, delete (no value exposure) - Human inputs (3): list, get, resolve (approve/reject) Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- .../src/studio-mcp/tools/human-input.tools.ts | 106 ++++++++ .../src/studio-mcp/tools/schedule.tools.ts | 235 ++++++++++++++++++ backend/src/studio-mcp/tools/secret.tools.ts | 145 +++++++++++ 3 files changed, 486 insertions(+) create mode 100644 backend/src/studio-mcp/tools/human-input.tools.ts create mode 100644 backend/src/studio-mcp/tools/schedule.tools.ts create mode 100644 backend/src/studio-mcp/tools/secret.tools.ts diff --git a/backend/src/studio-mcp/tools/human-input.tools.ts b/backend/src/studio-mcp/tools/human-input.tools.ts new file mode 100644 index 00000000..15dfb752 --- /dev/null +++ b/backend/src/studio-mcp/tools/human-input.tools.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import { checkPermission, errorResult, jsonResult, type StudioMcpDeps } from './types'; + +export function registerHumanInputTools( + server: McpServer, + auth: AuthContext, + deps: StudioMcpDeps, +): void { + const { humanInputsService } = deps; + + server.registerTool( + 'list_human_inputs', + { + description: + 'List human input/approval requests for the organization. Filter by status to find pending items that need attention.', + inputSchema: { + status: z + .enum(['pending', 'resolved', 'expired']) + .optional() + .describe('Filter by status. Omit to return all.'), + }, + }, + async (args: { status?: 'pending' | 'resolved' | 'expired' }) => { + const gate = checkPermission(auth, 'human-inputs.read'); + if (!gate.allowed) return gate.error; + if (!humanInputsService) { + return errorResult(new Error('Human inputs service is not available')); + } + try { + const inputs = await humanInputsService.list( + { status: args.status }, + auth.organizationId ?? undefined, + ); + return jsonResult(inputs); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_human_input', + { + description: 'Get full details of a specific human input or approval request.', + inputSchema: { + inputId: z.string().describe('ID of the human input request'), + }, + }, + async (args: { inputId: string }) => { + const gate = checkPermission(auth, 'human-inputs.read'); + if (!gate.allowed) return gate.error; + if (!humanInputsService) { + return errorResult(new Error('Human inputs service is not available')); + } + try { + const input = await humanInputsService.getById( + args.inputId, + auth.organizationId ?? undefined, + ); + return jsonResult(input); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'resolve_human_input', + { + description: + 'Resolve a pending human input request by approving or rejecting it, optionally providing additional data.', + inputSchema: { + inputId: z.string().describe('ID of the human input request to resolve'), + action: z.enum(['approve', 'reject']).describe('Resolution action'), + data: z + .record(z.string(), z.unknown()) + .optional() + .describe('Optional additional data to include with the resolution'), + }, + }, + async (args: { + inputId: string; + action: 'approve' | 'reject'; + data?: Record; + }) => { + const gate = checkPermission(auth, 'human-inputs.resolve'); + if (!gate.allowed) return gate.error; + if (!humanInputsService) { + return errorResult(new Error('Human inputs service is not available')); + } + try { + const result = await humanInputsService.resolve( + args.inputId, + { action: args.action, data: args.data }, + auth.organizationId ?? undefined, + auth, + ); + return jsonResult(result); + } catch (error) { + return errorResult(error); + } + }, + ); +} diff --git a/backend/src/studio-mcp/tools/schedule.tools.ts b/backend/src/studio-mcp/tools/schedule.tools.ts new file mode 100644 index 00000000..4bcca54c --- /dev/null +++ b/backend/src/studio-mcp/tools/schedule.tools.ts @@ -0,0 +1,235 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import { checkPermission, errorResult, jsonResult, type StudioMcpDeps } from './types'; + +export function registerScheduleTools( + server: McpServer, + auth: AuthContext, + deps: StudioMcpDeps, +): void { + if (!deps.schedulesService) return; + + const { schedulesService } = deps; + + server.registerTool( + 'list_schedules', + { + description: + 'List all workflow schedules in the organization. Optionally filter by workflow ID.', + inputSchema: { + workflowId: z + .string() + .uuid() + .optional() + .describe('Optional workflow ID to filter schedules by'), + }, + }, + async (args: { workflowId?: string }) => { + const gate = checkPermission(auth, 'schedules.list'); + if (!gate.allowed) return gate.error; + try { + const filters = args.workflowId ? { workflowId: args.workflowId } : undefined; + const schedules = await schedulesService.list(auth, filters); + return jsonResult(schedules); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'get_schedule', + { + description: 'Get detailed information about a specific schedule.', + inputSchema: { + scheduleId: z.string().describe('ID of the schedule to retrieve'), + }, + }, + async (args: { scheduleId: string }) => { + const gate = checkPermission(auth, 'schedules.read'); + if (!gate.allowed) return gate.error; + try { + const schedule = await schedulesService.get(auth, args.scheduleId); + return jsonResult(schedule); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'create_schedule', + { + description: + 'Create a cron schedule that automatically triggers a workflow on a recurring basis.', + inputSchema: { + workflowId: z.string().uuid().describe('ID of the workflow to schedule'), + name: z.string().describe('Display name for the schedule'), + cronExpression: z + .string() + .describe( + 'Cron expression defining the schedule (e.g. "0 9 * * 1" for every Monday at 9am)', + ), + inputs: z + .record(z.string(), z.unknown()) + .optional() + .describe('Optional workflow input values to pass on each triggered run'), + timezone: z + .string() + .optional() + .describe( + 'IANA timezone for interpreting the cron expression (e.g. "America/New_York"). Defaults to UTC.', + ), + description: z.string().optional().describe('Optional description of the schedule'), + }, + }, + async (args: { + workflowId: string; + name: string; + cronExpression: string; + inputs?: Record; + timezone?: string; + description?: string; + }) => { + const gate = checkPermission(auth, 'schedules.create'); + if (!gate.allowed) return gate.error; + try { + const dto = { + workflowId: args.workflowId, + name: args.name, + cronExpression: args.cronExpression, + inputs: args.inputs, + timezone: args.timezone, + description: args.description, + }; + const schedule = await schedulesService.create(auth, dto); + return jsonResult(schedule); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'update_schedule', + { + description: 'Update an existing schedule. Only provided fields are changed.', + inputSchema: { + scheduleId: z.string().describe('ID of the schedule to update'), + name: z.string().optional().describe('New display name'), + cronExpression: z.string().optional().describe('New cron expression'), + inputs: z + .record(z.string(), z.unknown()) + .optional() + .describe('New workflow input values to pass on each triggered run'), + timezone: z.string().optional().describe('New IANA timezone'), + description: z.string().optional().describe('New description'), + }, + }, + async (args: { + scheduleId: string; + name?: string; + cronExpression?: string; + inputs?: Record; + timezone?: string; + description?: string; + }) => { + const gate = checkPermission(auth, 'schedules.update'); + if (!gate.allowed) return gate.error; + try { + const dto: Record = {}; + if (args.name !== undefined) dto.name = args.name; + if (args.cronExpression !== undefined) dto.cronExpression = args.cronExpression; + if (args.inputs !== undefined) dto.inputs = args.inputs; + if (args.timezone !== undefined) dto.timezone = args.timezone; + if (args.description !== undefined) dto.description = args.description; + const schedule = await schedulesService.update(auth, args.scheduleId, dto); + return jsonResult(schedule); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'delete_schedule', + { + description: 'Permanently delete a schedule. The associated workflow is not affected.', + inputSchema: { + scheduleId: z.string().describe('ID of the schedule to delete'), + }, + }, + async (args: { scheduleId: string }) => { + const gate = checkPermission(auth, 'schedules.delete'); + if (!gate.allowed) return gate.error; + try { + await schedulesService.delete(auth, args.scheduleId); + return jsonResult({ deleted: true, scheduleId: args.scheduleId }); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'pause_schedule', + { + description: + 'Pause a schedule so it stops triggering runs. Use resume_schedule to re-enable it.', + inputSchema: { + scheduleId: z.string().describe('ID of the schedule to pause'), + }, + }, + async (args: { scheduleId: string }) => { + const gate = checkPermission(auth, 'schedules.update'); + if (!gate.allowed) return gate.error; + try { + const schedule = await schedulesService.pause(auth, args.scheduleId); + return jsonResult(schedule); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'resume_schedule', + { + description: 'Resume a paused schedule so it resumes triggering runs on its cron cadence.', + inputSchema: { + scheduleId: z.string().describe('ID of the schedule to resume'), + }, + }, + async (args: { scheduleId: string }) => { + const gate = checkPermission(auth, 'schedules.update'); + if (!gate.allowed) return gate.error; + try { + const schedule = await schedulesService.resume(auth, args.scheduleId); + return jsonResult(schedule); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'trigger_schedule', + { + description: 'Immediately trigger a scheduled workflow run outside its normal cron cadence.', + inputSchema: { + scheduleId: z.string().describe('ID of the schedule to trigger immediately'), + }, + }, + async (args: { scheduleId: string }) => { + const gate = checkPermission(auth, 'schedules.update'); + if (!gate.allowed) return gate.error; + try { + const result = await schedulesService.trigger(auth, args.scheduleId); + return jsonResult(result); + } catch (error) { + return errorResult(error); + } + }, + ); +} diff --git a/backend/src/studio-mcp/tools/secret.tools.ts b/backend/src/studio-mcp/tools/secret.tools.ts new file mode 100644 index 00000000..40b466bd --- /dev/null +++ b/backend/src/studio-mcp/tools/secret.tools.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import { checkPermission, errorResult, jsonResult, type StudioMcpDeps } from './types'; + +export function registerSecretTools( + server: McpServer, + auth: AuthContext, + deps: StudioMcpDeps, +): void { + const { secretsService } = deps; + + server.registerTool( + 'list_secrets', + { + description: + 'List all secrets in the organization. Returns metadata only (id, name, description, tags, timestamps) — values are never exposed.', + }, + async () => { + const gate = checkPermission(auth, 'secrets.list'); + if (!gate.allowed) return gate.error; + if (!secretsService) { + return errorResult(new Error('Secrets service is not available')); + } + try { + const secrets = await secretsService.listSecrets(auth); + return jsonResult(secrets); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'create_secret', + { + description: 'Create a new secret. The value is stored encrypted and never returned.', + inputSchema: { + name: z.string().describe('Name of the secret'), + value: z.string().describe('Secret value to store encrypted'), + description: z.string().optional().describe('Optional description'), + tags: z.array(z.string()).optional().describe('Optional tags for categorization'), + }, + }, + async (args: { name: string; value: string; description?: string; tags?: string[] }) => { + const gate = checkPermission(auth, 'secrets.create'); + if (!gate.allowed) return gate.error; + if (!secretsService) { + return errorResult(new Error('Secrets service is not available')); + } + try { + const result = await secretsService.createSecret(auth, { + name: args.name, + value: args.value, + description: args.description, + tags: args.tags, + }); + return jsonResult(result); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'rotate_secret', + { + description: + 'Rotate a secret to a new value, creating a new version. The previous version is retained per retention policy.', + inputSchema: { + secretId: z.string().describe('ID of the secret to rotate'), + value: z.string().describe('New secret value'), + }, + }, + async (args: { secretId: string; value: string }) => { + const gate = checkPermission(auth, 'secrets.update'); + if (!gate.allowed) return gate.error; + if (!secretsService) { + return errorResult(new Error('Secrets service is not available')); + } + try { + const result = await secretsService.rotateSecret(auth, args.secretId, { + value: args.value, + }); + return jsonResult(result); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'update_secret', + { + description: + 'Update secret metadata (name, description, tags). Does not change the secret value — use rotate_secret for that.', + inputSchema: { + secretId: z.string().describe('ID of the secret to update'), + name: z.string().optional().describe('New name for the secret'), + description: z.string().optional().describe('New description'), + tags: z.array(z.string()).optional().describe('New tags (replaces existing tags)'), + }, + }, + async (args: { secretId: string; name?: string; description?: string; tags?: string[] }) => { + const gate = checkPermission(auth, 'secrets.update'); + if (!gate.allowed) return gate.error; + if (!secretsService) { + return errorResult(new Error('Secrets service is not available')); + } + try { + const result = await secretsService.updateSecret(auth, args.secretId, { + name: args.name, + description: args.description, + tags: args.tags, + }); + return jsonResult(result); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'delete_secret', + { + description: 'Permanently delete a secret and all its versions.', + inputSchema: { + secretId: z.string().describe('ID of the secret to delete'), + }, + }, + async (args: { secretId: string }) => { + const gate = checkPermission(auth, 'secrets.delete'); + if (!gate.allowed) return gate.error; + if (!secretsService) { + return errorResult(new Error('Secrets service is not available')); + } + try { + await secretsService.deleteSecret(auth, args.secretId); + return jsonResult({ deleted: true, secretId: args.secretId }); + } catch (error) { + return errorResult(error); + } + }, + ); +} From 165eed3786cb0708cef3e748159648f4851966f3 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 16:07:20 +0530 Subject: [PATCH 05/10] feat(studio-mcp): wire up DI for all new tool domains Import StorageModule, NodeIOModule, SchedulesModule, SecretsModule, and HumanInputsModule into StudioMcpModule. Inject all services into StudioMcpService (@Optional for graceful degradation) and register all tool groups. Export SchedulesService from SchedulesModule. Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- backend/src/schedules/schedules.module.ts | 1 + backend/src/studio-mcp/studio-mcp.module.ts | 15 ++++++++- backend/src/studio-mcp/studio-mcp.service.ts | 35 ++++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/backend/src/schedules/schedules.module.ts b/backend/src/schedules/schedules.module.ts index a35bf384..4b30d3ba 100644 --- a/backend/src/schedules/schedules.module.ts +++ b/backend/src/schedules/schedules.module.ts @@ -11,5 +11,6 @@ import { SchedulesService } from './schedules.service'; imports: [DatabaseModule, TemporalModule, WorkflowsModule], controllers: [SchedulesController], providers: [SchedulesService, ScheduleRepository], + exports: [SchedulesService], }) export class SchedulesModule {} diff --git a/backend/src/studio-mcp/studio-mcp.module.ts b/backend/src/studio-mcp/studio-mcp.module.ts index 81cfe053..74fc99a0 100644 --- a/backend/src/studio-mcp/studio-mcp.module.ts +++ b/backend/src/studio-mcp/studio-mcp.module.ts @@ -1,11 +1,24 @@ import { Module } from '@nestjs/common'; import { WorkflowsModule } from '../workflows/workflows.module'; +import { StorageModule } from '../storage/storage.module'; +import { NodeIOModule } from '../node-io/node-io.module'; +import { SchedulesModule } from '../schedules/schedules.module'; +import { SecretsModule } from '../secrets/secrets.module'; +import { HumanInputsModule } from '../human-inputs/human-inputs.module'; import { StudioMcpController } from './studio-mcp.controller'; import { StudioMcpService } from './studio-mcp.service'; @Module({ - imports: [WorkflowsModule], + imports: [ + WorkflowsModule, + StorageModule, + NodeIOModule, + SchedulesModule, + SecretsModule, + HumanInputsModule, + // TraceModule is @Global() — no import needed + ], controllers: [StudioMcpController], providers: [StudioMcpService], }) diff --git a/backend/src/studio-mcp/studio-mcp.service.ts b/backend/src/studio-mcp/studio-mcp.service.ts index 2d9421bb..cfdbbb97 100644 --- a/backend/src/studio-mcp/studio-mcp.service.ts +++ b/backend/src/studio-mcp/studio-mcp.service.ts @@ -1,22 +1,42 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { InMemoryTaskStore, InMemoryTaskMessageQueue, } from '@modelcontextprotocol/sdk/experimental/index.js'; import { WorkflowsService } from '../workflows/workflows.service'; +import { ArtifactsService } from '../storage/artifacts.service'; +import { NodeIOService } from '../node-io/node-io.service'; +import { TraceService } from '../trace/trace.service'; +import { LogStreamService } from '../trace/log-stream.service'; +import { SchedulesService } from '../schedules/schedules.service'; +import { SecretsService } from '../secrets/secrets.service'; +import { HumanInputsService } from '../human-inputs/human-inputs.service'; import type { AuthContext } from '../auth/types'; import type { StudioMcpDeps } from './tools/types'; import { registerWorkflowTools } from './tools/workflow.tools'; import { registerComponentTools } from './tools/component.tools'; import { registerRunTools } from './tools/run.tools'; +import { registerArtifactTools } from './tools/artifact.tools'; +import { registerScheduleTools } from './tools/schedule.tools'; +import { registerSecretTools } from './tools/secret.tools'; +import { registerHumanInputTools } from './tools/human-input.tools'; @Injectable() export class StudioMcpService { private readonly taskStore = new InMemoryTaskStore(); private readonly taskMessageQueue = new InMemoryTaskMessageQueue(); - constructor(private readonly workflowsService: WorkflowsService) {} + constructor( + private readonly workflowsService: WorkflowsService, + @Optional() private readonly artifactsService?: ArtifactsService, + @Optional() private readonly nodeIOService?: NodeIOService, + @Optional() private readonly traceService?: TraceService, + @Optional() private readonly logStreamService?: LogStreamService, + @Optional() private readonly schedulesService?: SchedulesService, + @Optional() private readonly secretsService?: SecretsService, + @Optional() private readonly humanInputsService?: HumanInputsService, + ) {} /** * Create an MCP server with all Studio tools registered, scoped to the given auth context. @@ -46,10 +66,21 @@ export class StudioMcpService { private registerTools(server: McpServer, auth: AuthContext): void { const deps: StudioMcpDeps = { workflowsService: this.workflowsService, + artifactsService: this.artifactsService, + nodeIOService: this.nodeIOService, + traceService: this.traceService, + logStreamService: this.logStreamService, + schedulesService: this.schedulesService, + secretsService: this.secretsService, + humanInputsService: this.humanInputsService, }; registerWorkflowTools(server, auth, deps); registerComponentTools(server); registerRunTools(server, auth, deps); + registerArtifactTools(server, auth, deps); + registerScheduleTools(server, auth, deps); + registerSecretTools(server, auth, deps); + registerHumanInputTools(server, auth, deps); } } From d06fc4675f320e78853c682f795386bad4d1615a Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 16:15:21 +0530 Subject: [PATCH 06/10] test(studio-mcp): update tests for new tool registrations Update tool list assertion with 17 new tools. Export monitorWorkflowRun from workflow.tools.ts and update test to call it directly instead of via service instance. Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- .../__tests__/studio-mcp.service.spec.ts | 27 +++++++++++++++---- .../src/studio-mcp/tools/workflow.tools.ts | 2 +- 2 files changed, 23 insertions(+), 6 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 dd7cc5bb..4ebbdc5b 100644 --- a/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts +++ b/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, jest } from 'bun:test'; import { StudioMcpService } from '../studio-mcp.service'; +import { monitorWorkflowRun } from '../tools/workflow.tools'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { AuthContext } from '../../auth/types'; import type { WorkflowsService } from '../../workflows/workflows.service'; @@ -89,18 +90,35 @@ describe('StudioMcpService Unit Tests', () => { const toolNames = Object.keys(registeredTools).sort(); expect(toolNames).toEqual([ 'cancel_run', + 'create_secret', 'create_workflow', + 'delete_secret', 'delete_workflow', 'get_component', + 'get_human_input', + 'get_node_io', + 'get_run_config', + 'get_run_logs', 'get_run_result', 'get_run_status', + 'get_run_trace', 'get_workflow', + 'list_artifacts', + 'list_child_runs', 'list_components', + 'list_human_inputs', + 'list_run_artifacts', + 'list_run_node_io', 'list_runs', + 'list_secrets', 'list_workflows', + 'resolve_human_input', + 'rotate_secret', 'run_workflow', + 'update_secret', 'update_workflow', 'update_workflow_metadata', + 'view_artifact', ]); }); @@ -512,7 +530,6 @@ describe('StudioMcpService Unit Tests', () => { storeTaskResult: jest.fn().mockResolvedValue(true), }; - const mockServer = {} as McpServer; const taskId = 'test-task-id'; const runId = 'test-run-id'; @@ -534,12 +551,12 @@ describe('StudioMcpService Unit Tests', () => { (global as any).setTimeout = (fn: any) => originalSetTimeout(fn, 1); try { - await (service as any).monitorWorkflowRun( + await monitorWorkflowRun( runId, undefined, taskId, mockTaskStore, - mockServer, + workflowsService, mockAuthContext, ); } finally { @@ -575,12 +592,12 @@ describe('StudioMcpService Unit Tests', () => { failure: { message: 'boom' }, }); - await (service as any).monitorWorkflowRun( + await monitorWorkflowRun( runId, undefined, taskId, mockTaskStore, - {} as McpServer, + workflowsService, mockAuthContext, ); diff --git a/backend/src/studio-mcp/tools/workflow.tools.ts b/backend/src/studio-mcp/tools/workflow.tools.ts index e3922a60..8373d0ff 100644 --- a/backend/src/studio-mcp/tools/workflow.tools.ts +++ b/backend/src/studio-mcp/tools/workflow.tools.ts @@ -289,7 +289,7 @@ export function registerWorkflowTools( ); } -async function monitorWorkflowRun( +export async function monitorWorkflowRun( runId: string, temporalRunId: string | undefined, taskId: string, From 3b13758415010be74982ab640472b18addee25e1 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 16:47:43 +0530 Subject: [PATCH 07/10] =?UTF-8?q?fix(studio-mcp):=20address=20Codex=20revi?= =?UTF-8?q?ew=20=E2=80=94=20scopes,=20DTOs,=20return=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend ApiKeyPermissions with artifacts, schedules, secrets, and human-inputs scopes so API-key callers can access new MCP tools - Normalize new scopes in auth guard alongside existing ones - Map schedule inputs to inputPayload.runtimeInputs (not flat inputs) - Map human-input resolve action to responseData.status DTO shape - Return explicit { triggered: true } for trigger_schedule (void return) Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- backend/src/auth/auth.guard.ts | 22 +++++++++++++++++++ backend/src/database/schema/api-keys.ts | 22 +++++++++++++++++++ .../src/studio-mcp/tools/human-input.tools.ts | 8 ++++++- .../src/studio-mcp/tools/schedule.tools.ts | 15 ++++++++----- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index af08cf6a..7eb0aae7 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -135,6 +135,28 @@ export class AuthGuard implements CanActivate { audit: { read: Boolean(permissions.audit?.read), }, + artifacts: { + read: Boolean(permissions.artifacts?.read), + delete: Boolean(permissions.artifacts?.delete), + }, + schedules: { + list: Boolean(permissions.schedules?.list), + read: Boolean(permissions.schedules?.read), + create: Boolean(permissions.schedules?.create), + update: Boolean(permissions.schedules?.update), + delete: Boolean(permissions.schedules?.delete), + }, + secrets: { + list: Boolean(permissions.secrets?.list), + read: Boolean(permissions.secrets?.read), + create: Boolean(permissions.secrets?.create), + update: Boolean(permissions.secrets?.update), + delete: Boolean(permissions.secrets?.delete), + }, + 'human-inputs': { + read: Boolean(permissions['human-inputs']?.read), + resolve: Boolean(permissions['human-inputs']?.resolve), + }, }; return { diff --git a/backend/src/database/schema/api-keys.ts b/backend/src/database/schema/api-keys.ts index 9bbae576..15df42fd 100644 --- a/backend/src/database/schema/api-keys.ts +++ b/backend/src/database/schema/api-keys.ts @@ -26,6 +26,28 @@ export interface ApiKeyPermissions { audit: { read: boolean; }; + artifacts?: { + read?: boolean; + delete?: boolean; + }; + schedules?: { + list?: boolean; + read?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; + }; + secrets?: { + list?: boolean; + read?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; + }; + 'human-inputs'?: { + read?: boolean; + resolve?: boolean; + }; } export const apiKeys = pgTable( diff --git a/backend/src/studio-mcp/tools/human-input.tools.ts b/backend/src/studio-mcp/tools/human-input.tools.ts index 15dfb752..5d105c79 100644 --- a/backend/src/studio-mcp/tools/human-input.tools.ts +++ b/backend/src/studio-mcp/tools/human-input.tools.ts @@ -93,7 +93,13 @@ export function registerHumanInputTools( try { const result = await humanInputsService.resolve( args.inputId, - { action: args.action, data: args.data }, + { + responseData: { + status: args.action === 'reject' ? 'rejected' : 'approved', + ...args.data, + }, + respondedBy: auth.userId ?? undefined, + }, auth.organizationId ?? undefined, auth, ); diff --git a/backend/src/studio-mcp/tools/schedule.tools.ts b/backend/src/studio-mcp/tools/schedule.tools.ts index 4bcca54c..f55092d8 100644 --- a/backend/src/studio-mcp/tools/schedule.tools.ts +++ b/backend/src/studio-mcp/tools/schedule.tools.ts @@ -99,9 +99,12 @@ export function registerScheduleTools( workflowId: args.workflowId, name: args.name, cronExpression: args.cronExpression, - inputs: args.inputs, - timezone: args.timezone, + timezone: args.timezone ?? 'UTC', description: args.description, + inputPayload: { + runtimeInputs: args.inputs ?? {}, + nodeOverrides: {}, + }, }; const schedule = await schedulesService.create(auth, dto); return jsonResult(schedule); @@ -141,7 +144,9 @@ export function registerScheduleTools( const dto: Record = {}; if (args.name !== undefined) dto.name = args.name; if (args.cronExpression !== undefined) dto.cronExpression = args.cronExpression; - if (args.inputs !== undefined) dto.inputs = args.inputs; + if (args.inputs !== undefined) { + dto.inputPayload = { runtimeInputs: args.inputs, nodeOverrides: {} }; + } if (args.timezone !== undefined) dto.timezone = args.timezone; if (args.description !== undefined) dto.description = args.description; const schedule = await schedulesService.update(auth, args.scheduleId, dto); @@ -225,8 +230,8 @@ export function registerScheduleTools( const gate = checkPermission(auth, 'schedules.update'); if (!gate.allowed) return gate.error; try { - const result = await schedulesService.trigger(auth, args.scheduleId); - return jsonResult(result); + await schedulesService.trigger(auth, args.scheduleId); + return jsonResult({ triggered: true, scheduleId: args.scheduleId }); } catch (error) { return errorResult(error); } From 715491d4a502cb12b54b71fc71e1abc109ae1580 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 16:56:36 +0530 Subject: [PATCH 08/10] =?UTF-8?q?fix(studio-mcp):=20address=20Codex=20CLI?= =?UTF-8?q?=20review=20=E2=80=94=20security=20and=20DTO=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prevent data.status from overriding action in resolve_human_input by spreading args.data before setting status (P1: approval bypass) - Add ensureRunAccess check to get_node_io to prevent cross-org data access via direct runId lookup (P1: org isolation) - Add new permission scopes to ApiKeyPermissionsSchema Zod validator so API keys can actually be granted the new MCP permissions (P2) Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- backend/src/api-keys/dto/api-key.dto.ts | 30 +++++++++++++++++++ .../src/studio-mcp/tools/human-input.tools.ts | 3 +- backend/src/studio-mcp/tools/run.tools.ts | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/src/api-keys/dto/api-key.dto.ts b/backend/src/api-keys/dto/api-key.dto.ts index f20e1b21..0589ffb8 100644 --- a/backend/src/api-keys/dto/api-key.dto.ts +++ b/backend/src/api-keys/dto/api-key.dto.ts @@ -19,6 +19,36 @@ export const ApiKeyPermissionsSchema = z.object({ audit: z.object({ read: z.boolean(), }), + artifacts: z + .object({ + read: z.boolean().optional(), + delete: z.boolean().optional(), + }) + .optional(), + schedules: z + .object({ + list: z.boolean().optional(), + read: z.boolean().optional(), + create: z.boolean().optional(), + update: z.boolean().optional(), + delete: z.boolean().optional(), + }) + .optional(), + secrets: z + .object({ + list: z.boolean().optional(), + read: z.boolean().optional(), + create: z.boolean().optional(), + update: z.boolean().optional(), + delete: z.boolean().optional(), + }) + .optional(), + 'human-inputs': z + .object({ + read: z.boolean().optional(), + resolve: z.boolean().optional(), + }) + .optional(), }); export const CreateApiKeySchema = z.object({ diff --git a/backend/src/studio-mcp/tools/human-input.tools.ts b/backend/src/studio-mcp/tools/human-input.tools.ts index 5d105c79..02d968e9 100644 --- a/backend/src/studio-mcp/tools/human-input.tools.ts +++ b/backend/src/studio-mcp/tools/human-input.tools.ts @@ -95,8 +95,9 @@ export function registerHumanInputTools( args.inputId, { responseData: { - status: args.action === 'reject' ? 'rejected' : 'approved', ...args.data, + // Set status AFTER spread to prevent caller-supplied data from overriding it + status: args.action === 'reject' ? 'rejected' : 'approved', }, respondedBy: auth.userId ?? undefined, }, diff --git a/backend/src/studio-mcp/tools/run.tools.ts b/backend/src/studio-mcp/tools/run.tools.ts index 01c1baa7..77b8e50f 100644 --- a/backend/src/studio-mcp/tools/run.tools.ts +++ b/backend/src/studio-mcp/tools/run.tools.ts @@ -185,6 +185,7 @@ export function registerRunTools(server: McpServer, auth: AuthContext, deps: Stu if (!gate.allowed) return gate.error; if (!nodeIOService) return errorResult(new Error('nodeIOService is not available')); try { + await workflowsService.ensureRunAccess(args.runId, auth); const io = await nodeIOService.getNodeIO(args.runId, args.nodeRef, args.full ?? false); return jsonResult(io); } catch (error) { From e4630b9c10c1673e7d69e1b2a4ba585729e0f9f6 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 17:01:16 +0530 Subject: [PATCH 09/10] test(studio-mcp): add comprehensive tests for all new MCP tools 68 new tests across 3 test files: - run-detail-tools.spec.ts (23 tests): get_run_config, get_run_trace, list_run_node_io, get_node_io (with ensureRunAccess security check), get_run_logs, list_child_runs, permission gating - artifact-tools.spec.ts (24 tests): list_artifacts, list_run_artifacts, view_artifact (text windowing, binary detection, offset/limit, hasMore), unavailable service fallback, permission gating - domain-tools.spec.ts (21 tests): schedule CRUD (inputPayload mapping), secret CRUD, human-input resolve (DTO shape, status override prevention), permission gating for all domains Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- .../__tests__/artifact-tools.spec.ts | 448 +++++++++++++++++ .../studio-mcp/__tests__/domain-tools.spec.ts | 462 ++++++++++++++++++ .../__tests__/run-detail-tools.spec.ts | 413 ++++++++++++++++ 3 files changed, 1323 insertions(+) create mode 100644 backend/src/studio-mcp/__tests__/artifact-tools.spec.ts create mode 100644 backend/src/studio-mcp/__tests__/domain-tools.spec.ts create mode 100644 backend/src/studio-mcp/__tests__/run-detail-tools.spec.ts diff --git a/backend/src/studio-mcp/__tests__/artifact-tools.spec.ts b/backend/src/studio-mcp/__tests__/artifact-tools.spec.ts new file mode 100644 index 00000000..1d273a97 --- /dev/null +++ b/backend/src/studio-mcp/__tests__/artifact-tools.spec.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import { StudioMcpService } from '../studio-mcp.service'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import type { WorkflowsService } from '../../workflows/workflows.service'; + +type RegisteredToolsMap = Record; + +function getRegisteredTools(server: McpServer): RegisteredToolsMap { + return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; +} + +const mockAuth: AuthContext = { + userId: 'test-user-id', + organizationId: 'test-org-id', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const sampleArtifact = { + id: 'a1', + name: 'report.txt', + size: 1024, + mimeType: 'text/plain', + runId: 'run-1', + createdAt: '2026-01-01', +}; + +function makeWorkflowsService(): WorkflowsService { + return { + list: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + updateMetadata: jest.fn().mockResolvedValue({}), + delete: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue({ runId: 'run-1' }), + listRuns: jest.fn().mockResolvedValue({ runs: [] }), + getRunStatus: jest.fn().mockResolvedValue({ status: 'COMPLETED' }), + getRunResult: jest.fn().mockResolvedValue({}), + cancelRun: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkflowsService; +} + +function makeArtifactsService() { + return { + listArtifacts: jest.fn().mockResolvedValue({ artifacts: [sampleArtifact] }), + listRunArtifacts: jest.fn().mockResolvedValue({ artifacts: [sampleArtifact] }), + downloadArtifact: jest.fn().mockResolvedValue({ + buffer: Buffer.from('hello world content here'), + artifact: { id: 'a1', name: 'test.txt', mimeType: 'text/plain' }, + }), + deleteArtifact: jest.fn().mockResolvedValue(undefined), + }; +} + +describe('Artifact Tools', () => { + let workflowsService: WorkflowsService; + let artifactsService: ReturnType; + let service: StudioMcpService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + artifactsService = makeArtifactsService(); + service = new StudioMcpService(workflowsService, artifactsService as any); + }); + + // ─── list_artifacts ────────────────────────────────────────────────────────── + + describe('list_artifacts', () => { + it('calls artifactsService.listArtifacts with auth and filters', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['list_artifacts'].handler({ + workflowId: 'wf-123', + search: 'report', + limit: 10, + }); + + expect(artifactsService.listArtifacts).toHaveBeenCalledWith(mockAuth, { + workflowId: 'wf-123', + search: 'report', + limit: 10, + }); + }); + + it('uses default limit of 20 when limit is not provided', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['list_artifacts'].handler({}); + + expect(artifactsService.listArtifacts).toHaveBeenCalledWith(mockAuth, { + workflowId: undefined, + search: undefined, + limit: 20, + }); + }); + + it('returns normalized summary with expected fields', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_artifacts'].handler({}); + const parsed = JSON.parse(result.content[0].text); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatchObject({ + id: 'a1', + name: 'report.txt', + size: 1024, + mimeType: 'text/plain', + runId: 'run-1', + createdAt: '2026-01-01', + }); + }); + + it('normalizes when service returns a plain array', async () => { + artifactsService.listArtifacts.mockResolvedValue([sampleArtifact]); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_artifacts'].handler({}); + const parsed = JSON.parse(result.content[0].text); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].id).toBe('a1'); + }); + + it('normalizes contentType → mimeType when mimeType is absent', async () => { + artifactsService.listArtifacts.mockResolvedValue({ + artifacts: [ + { + id: 'b1', + name: 'data.bin', + size: 512, + contentType: 'application/octet-stream', + runId: 'run-2', + createdAt: '2026-01-02', + }, + ], + }); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_artifacts'].handler({}); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed[0].mimeType).toBe('application/octet-stream'); + }); + + it('returns error result when artifactsService throws', async () => { + artifactsService.listArtifacts.mockRejectedValue(new Error('DB connection failed')); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_artifacts'].handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('DB connection failed'); + }); + }); + + // ─── list_run_artifacts ────────────────────────────────────────────────────── + + describe('list_run_artifacts', () => { + it('calls artifactsService.listRunArtifacts with auth and runId', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['list_run_artifacts'].handler({ runId: 'run-42' }); + + expect(artifactsService.listRunArtifacts).toHaveBeenCalledWith(mockAuth, 'run-42'); + }); + + it('returns normalized artifact summary', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_run_artifacts'].handler({ runId: 'run-1' }); + const parsed = JSON.parse(result.content[0].text); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toMatchObject({ + id: 'a1', + name: 'report.txt', + runId: 'run-1', + }); + }); + + it('normalizes when service returns a plain array', async () => { + artifactsService.listRunArtifacts.mockResolvedValue([sampleArtifact]); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_run_artifacts'].handler({ runId: 'run-1' }); + const parsed = JSON.parse(result.content[0].text); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].id).toBe('a1'); + }); + + it('returns error result when artifactsService throws', async () => { + artifactsService.listRunArtifacts.mockRejectedValue(new Error('not found')); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_run_artifacts'].handler({ runId: 'run-99' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('not found'); + }); + }); + + // ─── view_artifact ─────────────────────────────────────────────────────────── + + describe('view_artifact', () => { + it('returns text content for text/plain files', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ artifactId: 'a1' }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.isText).toBe(true); + expect(parsed.content).toBe('hello world content here'); + expect(parsed.id).toBe('a1'); + expect(parsed.name).toBe('test.txt'); + expect(parsed.mimeType).toBe('text/plain'); + expect(parsed.totalSize).toBe(24); // Buffer.from('hello world content here').length + }); + + it('returns metadata-only for binary files', async () => { + artifactsService.downloadArtifact.mockResolvedValue({ + buffer: Buffer.from([0x00, 0x01, 0x02, 0x03]), + artifact: { id: 'a2', name: 'image.png', mimeType: 'image/png' }, + }); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ artifactId: 'a2' }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.isText).toBe(false); + expect(parsed.id).toBe('a2'); + expect(parsed.name).toBe('image.png'); + expect(parsed.mimeType).toBe('image/png'); + expect(parsed.totalSize).toBe(4); + expect(parsed.message).toContain('Binary file'); + expect(parsed.content).toBeUndefined(); + }); + + it('windowing: offset=5, limit=5 returns correct slice', async () => { + // 'hello world content here' → bytes 5..9 → ' worl' + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ + artifactId: 'a1', + offset: 5, + limit: 5, + }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.isText).toBe(true); + expect(parsed.offset).toBe(5); + expect(parsed.limit).toBe(5); + expect(parsed.content).toBe(' worl'); + }); + + it('hasMore is true when there is remaining content', async () => { + // totalSize=24, offset=0, limit=10 → 0+10=10 < 24 → hasMore=true + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ + artifactId: 'a1', + offset: 0, + limit: 10, + }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.hasMore).toBe(true); + }); + + it('hasMore is false when offset+limit covers remaining content', async () => { + // totalSize=24, offset=20, limit=10 → 20+10=30 >= 24 → hasMore=false + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ + artifactId: 'a1', + offset: 20, + limit: 10, + }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.hasMore).toBe(false); + }); + + it('returns error result when artifactsService throws', async () => { + artifactsService.downloadArtifact.mockRejectedValue(new Error('artifact not found')); + + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ artifactId: 'missing' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('artifact not found'); + }); + }); + + // ─── Service unavailable ───────────────────────────────────────────────────── + + describe('when artifactsService is unavailable', () => { + let serviceWithoutArtifacts: StudioMcpService; + + beforeEach(() => { + serviceWithoutArtifacts = new StudioMcpService(workflowsService); + }); + + it('list_artifacts returns error when service is not injected', async () => { + const server = serviceWithoutArtifacts.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_artifacts'].handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Artifacts service is not available'); + }); + + it('list_run_artifacts returns error when service is not injected', async () => { + const server = serviceWithoutArtifacts.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_run_artifacts'].handler({ runId: 'run-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Artifacts service is not available'); + }); + + it('view_artifact returns error when service is not injected', async () => { + const server = serviceWithoutArtifacts.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ artifactId: 'a1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Artifacts service is not available'); + }); + }); + + // ─── Permission gating ─────────────────────────────────────────────────────── + + describe('permission gating', () => { + const deniedAuth: AuthContext = { + userId: 'api-key-user', + organizationId: 'test-org-id', + roles: ['MEMBER'], + isAuthenticated: true, + provider: 'api-key', + apiKeyPermissions: { + workflows: { run: true, list: true, read: true }, + runs: { read: true, cancel: false }, + audit: { read: false }, + artifacts: { read: false }, + }, + }; + + it('list_artifacts returns permission denied when artifacts.read is false', async () => { + const server = service.createServer(deniedAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_artifacts'].handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('artifacts.read'); + }); + + it('list_run_artifacts returns permission denied when artifacts.read is false', async () => { + const server = service.createServer(deniedAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_run_artifacts'].handler({ runId: 'run-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('artifacts.read'); + }); + + it('view_artifact returns permission denied when artifacts.read is false', async () => { + const server = service.createServer(deniedAuth); + const tools = getRegisteredTools(server); + + const result = await tools['view_artifact'].handler({ artifactId: 'a1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('artifacts.read'); + }); + + it('all artifact tools are allowed when no apiKeyPermissions (non-API-key auth)', async () => { + const server = service.createServer(mockAuth); // no apiKeyPermissions + const tools = getRegisteredTools(server); + + const r1 = await tools['list_artifacts'].handler({}); + expect(r1.isError).toBeUndefined(); + + const r2 = await tools['list_run_artifacts'].handler({ runId: 'run-1' }); + expect(r2.isError).toBeUndefined(); + + const r3 = await tools['view_artifact'].handler({ artifactId: 'a1' }); + expect(r3.isError).toBeUndefined(); + }); + + it('all artifact tools are allowed when artifacts.read is true', async () => { + const allowedAuth: AuthContext = { + ...deniedAuth, + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: false }, + artifacts: { read: true }, + }, + }; + + const server = service.createServer(allowedAuth); + const tools = getRegisteredTools(server); + + const r1 = await tools['list_artifacts'].handler({}); + expect(r1.isError).toBeUndefined(); + + const r2 = await tools['list_run_artifacts'].handler({ runId: 'run-1' }); + expect(r2.isError).toBeUndefined(); + + const r3 = await tools['view_artifact'].handler({ artifactId: 'a1' }); + expect(r3.isError).toBeUndefined(); + }); + }); +}); diff --git a/backend/src/studio-mcp/__tests__/domain-tools.spec.ts b/backend/src/studio-mcp/__tests__/domain-tools.spec.ts new file mode 100644 index 00000000..4598b756 --- /dev/null +++ b/backend/src/studio-mcp/__tests__/domain-tools.spec.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import { StudioMcpService } from '../studio-mcp.service'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import type { WorkflowsService } from '../../workflows/workflows.service'; + +type RegisteredToolsMap = Record; + +function getRegisteredTools(server: McpServer): RegisteredToolsMap { + return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; +} + +const mockAuth: AuthContext = { + userId: 'test-user-id', + organizationId: 'test-org-id', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const restrictedAuth: AuthContext = { + ...mockAuth, + provider: 'api-key', + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: false }, + schedules: { create: false, list: false, read: false, update: false, delete: false }, + secrets: { create: false, list: false, read: false, update: false, delete: false }, + 'human-inputs': { read: false, resolve: false }, + }, +}; + +function makeWorkflowsService(): WorkflowsService { + return { + list: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'wf-id' }), + update: jest.fn().mockResolvedValue({ id: 'wf-id' }), + updateMetadata: jest.fn().mockResolvedValue({ id: 'wf-id' }), + delete: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + listRuns: jest.fn().mockResolvedValue({ runs: [] }), + getRunStatus: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + getRunResult: jest.fn().mockResolvedValue({}), + cancelRun: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkflowsService; +} + +// ─── Schedule Tools ─────────────────────────────────────────────────────────── + +describe('Schedule Tools', () => { + let service: StudioMcpService; + let schedulesService: any; + let workflowsService: WorkflowsService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + schedulesService = { + list: jest.fn().mockResolvedValue([]), + get: jest.fn().mockResolvedValue({ id: 'sched-id' }), + create: jest.fn().mockResolvedValue({ id: 'sched-id', name: 'My Schedule' }), + update: jest.fn().mockResolvedValue({ id: 'sched-id', name: 'Updated' }), + delete: jest.fn().mockResolvedValue(undefined), + pause: jest.fn().mockResolvedValue({ id: 'sched-id', status: 'paused' }), + resume: jest.fn().mockResolvedValue({ id: 'sched-id', status: 'active' }), + trigger: jest.fn().mockResolvedValue(undefined), + }; + service = new StudioMcpService( + workflowsService, + undefined, + undefined, + undefined, + undefined, + schedulesService, + ); + }); + + it('create_schedule maps inputs to inputPayload.runtimeInputs (not flat inputs field)', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['create_schedule'].handler({ + workflowId: '11111111-1111-4111-8111-111111111111', + name: 'Daily Run', + cronExpression: '0 9 * * 1', + inputs: { foo: 'bar', count: 42 }, + timezone: 'America/New_York', + description: 'Weekly schedule', + }); + + expect(schedulesService.create).toHaveBeenCalledTimes(1); + const [calledAuth, dto] = schedulesService.create.mock.calls[0]; + expect(calledAuth).toBe(mockAuth); + // CRITICAL: inputs must be nested under inputPayload.runtimeInputs + expect(dto.inputPayload).toBeDefined(); + expect(dto.inputPayload.runtimeInputs).toEqual({ foo: 'bar', count: 42 }); + expect(dto.inputPayload.nodeOverrides).toEqual({}); + // Flat inputs field must NOT exist on the dto + expect(dto.inputs).toBeUndefined(); + expect(dto.name).toBe('Daily Run'); + expect(dto.cronExpression).toBe('0 9 * * 1'); + expect(dto.timezone).toBe('America/New_York'); + expect(dto.description).toBe('Weekly schedule'); + }); + + it('create_schedule defaults timezone to UTC when not provided', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['create_schedule'].handler({ + workflowId: '11111111-1111-4111-8111-111111111111', + name: 'No TZ', + cronExpression: '0 0 * * *', + }); + + const [, dto] = schedulesService.create.mock.calls[0]; + expect(dto.timezone).toBe('UTC'); + expect(dto.inputPayload.runtimeInputs).toEqual({}); + }); + + it('update_schedule maps inputs to inputPayload correctly', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['update_schedule'].handler({ + scheduleId: 'sched-123', + inputs: { newKey: 'newVal' }, + name: 'Renamed', + }); + + expect(schedulesService.update).toHaveBeenCalledTimes(1); + const [calledAuth, scheduleId, dto] = schedulesService.update.mock.calls[0]; + expect(calledAuth).toBe(mockAuth); + expect(scheduleId).toBe('sched-123'); + expect(dto.inputPayload).toEqual({ runtimeInputs: { newKey: 'newVal' }, nodeOverrides: {} }); + expect(dto.name).toBe('Renamed'); + // Flat inputs field must NOT exist + expect(dto.inputs).toBeUndefined(); + }); + + it('trigger_schedule returns { triggered: true, scheduleId } since service returns void', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['trigger_schedule'].handler({ scheduleId: 'sched-abc' }); + const parsed = JSON.parse(result.content[0].text); + + expect(schedulesService.trigger).toHaveBeenCalledWith(mockAuth, 'sched-abc'); + expect(parsed.triggered).toBe(true); + expect(parsed.scheduleId).toBe('sched-abc'); + expect(result.isError).toBeUndefined(); + }); + + it('delete_schedule calls delete and returns { deleted: true, scheduleId }', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['delete_schedule'].handler({ scheduleId: 'sched-del' }); + const parsed = JSON.parse(result.content[0].text); + + expect(schedulesService.delete).toHaveBeenCalledWith(mockAuth, 'sched-del'); + expect(parsed.deleted).toBe(true); + expect(parsed.scheduleId).toBe('sched-del'); + expect(result.isError).toBeUndefined(); + }); + + it('list_schedules passes auth and optional workflowId filter', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + // Without filter + await tools['list_schedules'].handler({}); + expect(schedulesService.list).toHaveBeenCalledWith(mockAuth, undefined); + + schedulesService.list.mockClear(); + + // With workflowId filter + await tools['list_schedules'].handler({ workflowId: '11111111-1111-4111-8111-111111111111' }); + expect(schedulesService.list).toHaveBeenCalledWith(mockAuth, { + workflowId: '11111111-1111-4111-8111-111111111111', + }); + }); + + it('schedules.create = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['create_schedule'].handler({ + workflowId: '11111111-1111-4111-8111-111111111111', + name: 'Blocked', + cronExpression: '0 9 * * *', + })) as { isError?: boolean; content: { text: string }[] }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('schedules.create'); + expect(schedulesService.create).not.toHaveBeenCalled(); + }); + + it('schedules.list = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['list_schedules'].handler({})) as { + isError?: boolean; + content: { text: string }[]; + }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('schedules.list'); + expect(schedulesService.list).not.toHaveBeenCalled(); + }); +}); + +// ─── Secret Tools ───────────────────────────────────────────────────────────── + +describe('Secret Tools', () => { + let service: StudioMcpService; + let secretsService: any; + let workflowsService: WorkflowsService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + secretsService = { + listSecrets: jest.fn().mockResolvedValue([{ id: 'sec-1', name: 'MY_SECRET' }]), + createSecret: jest.fn().mockResolvedValue({ id: 'sec-new', name: 'NEW_SECRET' }), + rotateSecret: jest.fn().mockResolvedValue({ id: 'sec-1', version: 2 }), + updateSecret: jest.fn().mockResolvedValue({ id: 'sec-1', name: 'RENAMED' }), + deleteSecret: jest.fn().mockResolvedValue(undefined), + }; + service = new StudioMcpService( + workflowsService, + undefined, + undefined, + undefined, + undefined, + undefined, + secretsService, + ); + }); + + it('list_secrets calls secretsService.listSecrets(auth)', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_secrets'].handler({}); + const parsed = JSON.parse(result.content[0].text); + + expect(secretsService.listSecrets).toHaveBeenCalledWith(mockAuth); + expect(Array.isArray(parsed)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('create_secret calls secretsService.createSecret(auth, { name, value, description, tags })', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['create_secret'].handler({ + name: 'MY_API_KEY', + value: 's3cr3t', + description: 'An API key', + tags: ['prod', 'external'], + }); + + expect(secretsService.createSecret).toHaveBeenCalledWith(mockAuth, { + name: 'MY_API_KEY', + value: 's3cr3t', + description: 'An API key', + tags: ['prod', 'external'], + }); + }); + + it('rotate_secret calls secretsService.rotateSecret(auth, secretId, { value })', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['rotate_secret'].handler({ + secretId: 'sec-rotate-me', + value: 'newvalue123', + }); + + expect(secretsService.rotateSecret).toHaveBeenCalledWith(mockAuth, 'sec-rotate-me', { + value: 'newvalue123', + }); + }); + + it('delete_secret calls deleteSecret and returns { deleted: true }', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['delete_secret'].handler({ secretId: 'sec-del' }); + const parsed = JSON.parse(result.content[0].text); + + expect(secretsService.deleteSecret).toHaveBeenCalledWith(mockAuth, 'sec-del'); + expect(parsed.deleted).toBe(true); + expect(parsed.secretId).toBe('sec-del'); + expect(result.isError).toBeUndefined(); + }); + + it('secrets.create = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['create_secret'].handler({ + name: 'BLOCKED', + value: 'nope', + })) as { isError?: boolean; content: { text: string }[] }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('secrets.create'); + expect(secretsService.createSecret).not.toHaveBeenCalled(); + }); + + it('secrets.list = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['list_secrets'].handler({})) as { + isError?: boolean; + content: { text: string }[]; + }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('secrets.list'); + expect(secretsService.listSecrets).not.toHaveBeenCalled(); + }); +}); + +// ─── Human-Input Tools ──────────────────────────────────────────────────────── + +describe('Human-Input Tools', () => { + let service: StudioMcpService; + let humanInputsService: any; + let workflowsService: WorkflowsService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + humanInputsService = { + list: jest.fn().mockResolvedValue([{ id: 'hi-1', status: 'pending' }]), + getById: jest.fn().mockResolvedValue({ id: 'hi-1', status: 'pending' }), + resolve: jest.fn().mockResolvedValue({ id: 'hi-1', status: 'approved' }), + }; + service = new StudioMcpService( + workflowsService, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + humanInputsService, + ); + }); + + it('list_human_inputs calls list with status filter and organizationId', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + // With status filter + await tools['list_human_inputs'].handler({ status: 'pending' }); + expect(humanInputsService.list).toHaveBeenCalledWith( + { status: 'pending' }, + mockAuth.organizationId, + ); + + humanInputsService.list.mockClear(); + + // Without status filter + await tools['list_human_inputs'].handler({}); + expect(humanInputsService.list).toHaveBeenCalledWith( + { status: undefined }, + mockAuth.organizationId, + ); + }); + + it('resolve_human_input maps action to responseData.status: approve → approved', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-approve', + action: 'approve', + }); + + expect(humanInputsService.resolve).toHaveBeenCalledTimes(1); + const [inputId, dto] = humanInputsService.resolve.mock.calls[0]; + expect(inputId).toBe('hi-approve'); + expect(dto.responseData.status).toBe('approved'); + }); + + it('resolve_human_input maps action to responseData.status: reject → rejected', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-reject', + action: 'reject', + }); + + const [, dto] = humanInputsService.resolve.mock.calls[0]; + expect(dto.responseData.status).toBe('rejected'); + }); + + it('SECURITY: caller-supplied data.status cannot override action (spread order test)', async () => { + // Pass action: 'reject' but data: { status: 'approved' } + // The tool must set status AFTER the spread, so action wins. + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-security', + action: 'reject', + data: { status: 'approved' }, // attacker tries to override to approved + }); + + expect(humanInputsService.resolve).toHaveBeenCalledTimes(1); + const [, dto] = humanInputsService.resolve.mock.calls[0]; + // The action ('reject') must win — status must be 'rejected', not 'approved' + expect(dto.responseData.status).toBe('rejected'); + }); + + it('resolve_human_input includes respondedBy: auth.userId', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-resp', + action: 'approve', + }); + + const [, dto] = humanInputsService.resolve.mock.calls[0]; + expect(dto.respondedBy).toBe(mockAuth.userId); + }); + + it('human-inputs.resolve = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['resolve_human_input'].handler({ + inputId: 'hi-blocked', + action: 'approve', + })) as { isError?: boolean; content: { text: string }[] }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('human-inputs.resolve'); + expect(humanInputsService.resolve).not.toHaveBeenCalled(); + }); + + it('human-inputs.read = false → denied on list_human_inputs', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['list_human_inputs'].handler({})) as { + isError?: boolean; + content: { text: string }[]; + }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('human-inputs.read'); + expect(humanInputsService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/studio-mcp/__tests__/run-detail-tools.spec.ts b/backend/src/studio-mcp/__tests__/run-detail-tools.spec.ts new file mode 100644 index 00000000..3bcb9426 --- /dev/null +++ b/backend/src/studio-mcp/__tests__/run-detail-tools.spec.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import { StudioMcpService } from '../studio-mcp.service'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; + +type RegisteredToolsMap = Record; +function getRegisteredTools(server: McpServer): RegisteredToolsMap { + return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; +} + +const mockAuth: AuthContext = { + userId: 'test-user-id', + organizationId: 'test-org-id', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const restrictedAuth: AuthContext = { + userId: 'restricted-user-id', + organizationId: 'test-org-id', + roles: ['MEMBER'], + isAuthenticated: true, + provider: 'test', + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: false }, + }, +}; + +function makeWorkflowsService() { + return { + listRuns: jest.fn().mockResolvedValue({ runs: [] }), + getRunStatus: jest.fn().mockResolvedValue({}), + getRunResult: jest.fn().mockResolvedValue({}), + cancelRun: jest.fn().mockResolvedValue(undefined), + getRunConfig: jest.fn(), + listChildRuns: jest.fn(), + ensureRunAccess: jest.fn(), + findById: jest.fn().mockResolvedValue(null), + listWorkflows: jest.fn().mockResolvedValue({ workflows: [] }), + } as any; +} + +function makeTraceService() { + return { + list: jest.fn().mockResolvedValue({ events: [], cursor: undefined }), + }; +} + +function makeNodeIOService() { + return { + listSummaries: jest.fn(), + getNodeIO: jest.fn(), + }; +} + +function makeLogStreamService() { + return { + fetch: jest.fn(), + }; +} + +describe('Run Detail Tools', () => { + let workflowsService: ReturnType; + let traceService: ReturnType; + let nodeIOService: ReturnType; + let logStreamService: ReturnType; + let mcpService: StudioMcpService; + let tools: RegisteredToolsMap; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + traceService = makeTraceService(); + nodeIOService = makeNodeIOService(); + logStreamService = makeLogStreamService(); + + mcpService = new StudioMcpService( + workflowsService, + undefined, + nodeIOService as any, + traceService as any, + logStreamService as any, + ); + + const server = mcpService.createServer(mockAuth); + tools = getRegisteredTools(server); + }); + + describe('get_run_config', () => { + it('calls workflowsService.getRunConfig with runId and auth and returns result', async () => { + const config = { + runId: 'run-abc', + inputs: { foo: 'bar' }, + workflowVersion: '2', + }; + workflowsService.getRunConfig.mockResolvedValue(config); + + const result = await tools['get_run_config'].handler({ runId: 'run-abc' }); + + expect(workflowsService.getRunConfig).toHaveBeenCalledWith('run-abc', mockAuth); + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual(config); + }); + + it('returns error result when getRunConfig throws', async () => { + workflowsService.getRunConfig.mockRejectedValue(new Error('not found')); + + const result = await tools['get_run_config'].handler({ runId: 'run-missing' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: not found'); + }); + }); + + describe('get_run_trace', () => { + it('calls traceService.list and returns only events (not cursor)', async () => { + const events = [ + { type: 'node.started', nodeRef: 'node-1', ts: '2024-01-01T00:00:00Z' }, + { type: 'node.completed', nodeRef: 'node-1', ts: '2024-01-01T00:00:05Z' }, + ]; + traceService.list.mockResolvedValue({ events, cursor: 'next-page-token' }); + + const result = await tools['get_run_trace'].handler({ runId: 'run-abc' }); + + expect(traceService.list).toHaveBeenCalledWith('run-abc', mockAuth); + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual(events); + // Must not expose cursor in the result + expect(result.content[0].text).not.toContain('next-page-token'); + }); + + it('returns error result when traceService.list throws', async () => { + traceService.list.mockRejectedValue(new Error('trace unavailable')); + + const result = await tools['get_run_trace'].handler({ runId: 'run-abc' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: trace unavailable'); + }); + + it('returns error when traceService is not provided', async () => { + const svcWithoutTrace = new StudioMcpService( + workflowsService, + undefined, + nodeIOService as any, + undefined, // no traceService + logStreamService as any, + ); + const server = svcWithoutTrace.createServer(mockAuth); + const toolsNoTrace = getRegisteredTools(server); + + const result = await toolsNoTrace['get_run_trace'].handler({ runId: 'run-abc' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error:'); + }); + }); + + describe('list_run_node_io', () => { + it('calls nodeIOService.listSummaries with runId and organizationId', async () => { + const summaries = [ + { nodeRef: 'node-1', status: 'completed', inputSize: 100, outputSize: 200 }, + ]; + nodeIOService.listSummaries.mockResolvedValue(summaries); + + const result = await tools['list_run_node_io'].handler({ runId: 'run-abc' }); + + expect(nodeIOService.listSummaries).toHaveBeenCalledWith('run-abc', 'test-org-id'); + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual(summaries); + }); + + it('returns error when nodeIOService is not provided', async () => { + const svcWithoutNodeIO = new StudioMcpService( + workflowsService, + undefined, + undefined, // no nodeIOService + traceService as any, + logStreamService as any, + ); + const server = svcWithoutNodeIO.createServer(mockAuth); + const toolsNoNodeIO = getRegisteredTools(server); + + const result = await toolsNoNodeIO['list_run_node_io'].handler({ runId: 'run-abc' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error:'); + }); + }); + + describe('get_node_io', () => { + it('calls ensureRunAccess before getNodeIO (security check)', async () => { + const callOrder: string[] = []; + workflowsService.ensureRunAccess.mockImplementation(async () => { + callOrder.push('ensureRunAccess'); + }); + nodeIOService.getNodeIO.mockImplementation(async () => { + callOrder.push('getNodeIO'); + return { inputs: {}, outputs: {} }; + }); + + const result = await tools['get_node_io'].handler({ + runId: 'run-abc', + nodeRef: 'node-1', + }); + + expect(callOrder).toEqual(['ensureRunAccess', 'getNodeIO']); + expect(workflowsService.ensureRunAccess).toHaveBeenCalledWith('run-abc', mockAuth); + expect(nodeIOService.getNodeIO).toHaveBeenCalledWith('run-abc', 'node-1', false); + expect(result.isError).toBeUndefined(); + }); + + it('passes full=true to getNodeIO when requested', async () => { + workflowsService.ensureRunAccess.mockResolvedValue(undefined); + nodeIOService.getNodeIO.mockResolvedValue({ inputs: {}, outputs: {} }); + + await tools['get_node_io'].handler({ runId: 'run-abc', nodeRef: 'node-1', full: true }); + + expect(nodeIOService.getNodeIO).toHaveBeenCalledWith('run-abc', 'node-1', true); + }); + + it('returns error and does not call getNodeIO if ensureRunAccess throws (cross-org protection)', async () => { + workflowsService.ensureRunAccess.mockRejectedValue(new Error('Access denied')); + + const result = await tools['get_node_io'].handler({ + runId: 'run-other-org', + nodeRef: 'node-1', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Access denied'); + expect(nodeIOService.getNodeIO).not.toHaveBeenCalled(); + }); + + it('returns error when nodeIOService is not provided', async () => { + const svcWithoutNodeIO = new StudioMcpService( + workflowsService, + undefined, + undefined, + traceService as any, + logStreamService as any, + ); + const server = svcWithoutNodeIO.createServer(mockAuth); + const toolsNoNodeIO = getRegisteredTools(server); + + const result = await toolsNoNodeIO['get_node_io'].handler({ + runId: 'run-abc', + nodeRef: 'node-1', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error:'); + }); + }); + + describe('get_run_logs', () => { + it('calls logStreamService.fetch with runId, auth and options', async () => { + const logs = { logs: [{ id: 'l1', message: 'hello', level: 'info' }], nextCursor: null }; + logStreamService.fetch.mockResolvedValue(logs); + + const result = await tools['get_run_logs'].handler({ + runId: 'run-abc', + nodeRef: 'node-1', + stream: 'stdout', + level: 'info', + limit: 50, + cursor: 'tok', + }); + + expect(logStreamService.fetch).toHaveBeenCalledWith('run-abc', mockAuth, { + nodeRef: 'node-1', + stream: 'stdout', + level: 'info', + limit: 50, + cursor: 'tok', + }); + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual(logs); + }); + + it('uses default limit of 100 when not specified', async () => { + logStreamService.fetch.mockResolvedValue({ logs: [] }); + + await tools['get_run_logs'].handler({ runId: 'run-abc' }); + + expect(logStreamService.fetch).toHaveBeenCalledWith( + 'run-abc', + mockAuth, + expect.objectContaining({ limit: 100 }), + ); + }); + + it('returns error when logStreamService is not provided', async () => { + const svcWithoutLogs = new StudioMcpService( + workflowsService, + undefined, + nodeIOService as any, + traceService as any, + undefined, // no logStreamService + ); + const server = svcWithoutLogs.createServer(mockAuth); + const toolsNoLogs = getRegisteredTools(server); + + const result = await toolsNoLogs['get_run_logs'].handler({ runId: 'run-abc' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error:'); + }); + + it('returns error result when logStreamService.fetch throws', async () => { + logStreamService.fetch.mockRejectedValue(new Error('Loki unavailable')); + + const result = await tools['get_run_logs'].handler({ runId: 'run-abc' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Loki unavailable'); + }); + }); + + describe('list_child_runs', () => { + it('calls workflowsService.listChildRuns with runId and auth and returns result', async () => { + const children = [ + { id: 'child-run-1', workflowId: 'wf-1', status: 'COMPLETED' }, + { id: 'child-run-2', workflowId: 'wf-2', status: 'RUNNING' }, + ]; + workflowsService.listChildRuns.mockResolvedValue(children); + + const result = await tools['list_child_runs'].handler({ runId: 'run-abc' }); + + expect(workflowsService.listChildRuns).toHaveBeenCalledWith('run-abc', mockAuth); + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual(children); + }); + + it('returns error result when listChildRuns throws', async () => { + workflowsService.listChildRuns.mockRejectedValue(new Error('run not found')); + + const result = await tools['list_child_runs'].handler({ runId: 'run-missing' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: run not found'); + }); + }); + + describe('Permission checks (restrictedAuth with runs.read = false)', () => { + let restrictedTools: RegisteredToolsMap; + + beforeEach(() => { + const svc = new StudioMcpService( + workflowsService, + undefined, + nodeIOService as any, + traceService as any, + logStreamService as any, + ); + const server = svc.createServer(restrictedAuth); + restrictedTools = getRegisteredTools(server); + }); + + it('get_run_config returns permission denied', async () => { + const result = await restrictedTools['get_run_config'].handler({ runId: 'run-abc' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(workflowsService.getRunConfig).not.toHaveBeenCalled(); + }); + + it('get_run_trace returns permission denied', async () => { + const result = await restrictedTools['get_run_trace'].handler({ runId: 'run-abc' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(traceService.list).not.toHaveBeenCalled(); + }); + + it('list_run_node_io returns permission denied', async () => { + const result = await restrictedTools['list_run_node_io'].handler({ runId: 'run-abc' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(nodeIOService.listSummaries).not.toHaveBeenCalled(); + }); + + it('get_node_io returns permission denied', async () => { + const result = await restrictedTools['get_node_io'].handler({ + runId: 'run-abc', + nodeRef: 'node-1', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(workflowsService.ensureRunAccess).not.toHaveBeenCalled(); + expect(nodeIOService.getNodeIO).not.toHaveBeenCalled(); + }); + + it('get_run_logs returns permission denied', async () => { + const result = await restrictedTools['get_run_logs'].handler({ runId: 'run-abc' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(logStreamService.fetch).not.toHaveBeenCalled(); + }); + + it('list_child_runs returns permission denied', async () => { + const result = await restrictedTools['list_child_runs'].handler({ runId: 'run-abc' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(workflowsService.listChildRuns).not.toHaveBeenCalled(); + }); + }); +}); From dbbf485071d54612c1687cfa94883e1c15841f87 Mon Sep 17 00:00:00 2001 From: betterclever Date: Tue, 24 Feb 2026 17:06:06 +0530 Subject: [PATCH 10/10] refactor: split domain-tools.spec.ts into per-domain test files Separate schedule, secret, and human-input tests into individual files for better organization and discoverability. Co-Authored-By: Claude Opus 4.6 Signed-off-by: betterclever --- .../studio-mcp/__tests__/domain-tools.spec.ts | 462 ------------------ .../__tests__/human-input-tools.spec.ts | 181 +++++++ .../__tests__/schedule-tools.spec.ts | 211 ++++++++ .../studio-mcp/__tests__/secret-tools.spec.ts | 160 ++++++ 4 files changed, 552 insertions(+), 462 deletions(-) delete mode 100644 backend/src/studio-mcp/__tests__/domain-tools.spec.ts create mode 100644 backend/src/studio-mcp/__tests__/human-input-tools.spec.ts create mode 100644 backend/src/studio-mcp/__tests__/schedule-tools.spec.ts create mode 100644 backend/src/studio-mcp/__tests__/secret-tools.spec.ts diff --git a/backend/src/studio-mcp/__tests__/domain-tools.spec.ts b/backend/src/studio-mcp/__tests__/domain-tools.spec.ts deleted file mode 100644 index 4598b756..00000000 --- a/backend/src/studio-mcp/__tests__/domain-tools.spec.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { describe, it, expect, beforeEach, jest } from 'bun:test'; -import { StudioMcpService } from '../studio-mcp.service'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { AuthContext } from '../../auth/types'; -import type { WorkflowsService } from '../../workflows/workflows.service'; - -type RegisteredToolsMap = Record; - -function getRegisteredTools(server: McpServer): RegisteredToolsMap { - return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; -} - -const mockAuth: AuthContext = { - userId: 'test-user-id', - organizationId: 'test-org-id', - roles: ['ADMIN'], - isAuthenticated: true, - provider: 'test', -}; - -const restrictedAuth: AuthContext = { - ...mockAuth, - provider: 'api-key', - apiKeyPermissions: { - workflows: { run: false, list: false, read: false }, - runs: { read: false, cancel: false }, - audit: { read: false }, - schedules: { create: false, list: false, read: false, update: false, delete: false }, - secrets: { create: false, list: false, read: false, update: false, delete: false }, - 'human-inputs': { read: false, resolve: false }, - }, -}; - -function makeWorkflowsService(): WorkflowsService { - return { - list: jest.fn().mockResolvedValue([]), - findById: jest.fn().mockResolvedValue(null), - create: jest.fn().mockResolvedValue({ id: 'wf-id' }), - update: jest.fn().mockResolvedValue({ id: 'wf-id' }), - updateMetadata: jest.fn().mockResolvedValue({ id: 'wf-id' }), - delete: jest.fn().mockResolvedValue(undefined), - run: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), - listRuns: jest.fn().mockResolvedValue({ runs: [] }), - getRunStatus: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), - getRunResult: jest.fn().mockResolvedValue({}), - cancelRun: jest.fn().mockResolvedValue(undefined), - } as unknown as WorkflowsService; -} - -// ─── Schedule Tools ─────────────────────────────────────────────────────────── - -describe('Schedule Tools', () => { - let service: StudioMcpService; - let schedulesService: any; - let workflowsService: WorkflowsService; - - beforeEach(() => { - workflowsService = makeWorkflowsService(); - schedulesService = { - list: jest.fn().mockResolvedValue([]), - get: jest.fn().mockResolvedValue({ id: 'sched-id' }), - create: jest.fn().mockResolvedValue({ id: 'sched-id', name: 'My Schedule' }), - update: jest.fn().mockResolvedValue({ id: 'sched-id', name: 'Updated' }), - delete: jest.fn().mockResolvedValue(undefined), - pause: jest.fn().mockResolvedValue({ id: 'sched-id', status: 'paused' }), - resume: jest.fn().mockResolvedValue({ id: 'sched-id', status: 'active' }), - trigger: jest.fn().mockResolvedValue(undefined), - }; - service = new StudioMcpService( - workflowsService, - undefined, - undefined, - undefined, - undefined, - schedulesService, - ); - }); - - it('create_schedule maps inputs to inputPayload.runtimeInputs (not flat inputs field)', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['create_schedule'].handler({ - workflowId: '11111111-1111-4111-8111-111111111111', - name: 'Daily Run', - cronExpression: '0 9 * * 1', - inputs: { foo: 'bar', count: 42 }, - timezone: 'America/New_York', - description: 'Weekly schedule', - }); - - expect(schedulesService.create).toHaveBeenCalledTimes(1); - const [calledAuth, dto] = schedulesService.create.mock.calls[0]; - expect(calledAuth).toBe(mockAuth); - // CRITICAL: inputs must be nested under inputPayload.runtimeInputs - expect(dto.inputPayload).toBeDefined(); - expect(dto.inputPayload.runtimeInputs).toEqual({ foo: 'bar', count: 42 }); - expect(dto.inputPayload.nodeOverrides).toEqual({}); - // Flat inputs field must NOT exist on the dto - expect(dto.inputs).toBeUndefined(); - expect(dto.name).toBe('Daily Run'); - expect(dto.cronExpression).toBe('0 9 * * 1'); - expect(dto.timezone).toBe('America/New_York'); - expect(dto.description).toBe('Weekly schedule'); - }); - - it('create_schedule defaults timezone to UTC when not provided', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['create_schedule'].handler({ - workflowId: '11111111-1111-4111-8111-111111111111', - name: 'No TZ', - cronExpression: '0 0 * * *', - }); - - const [, dto] = schedulesService.create.mock.calls[0]; - expect(dto.timezone).toBe('UTC'); - expect(dto.inputPayload.runtimeInputs).toEqual({}); - }); - - it('update_schedule maps inputs to inputPayload correctly', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['update_schedule'].handler({ - scheduleId: 'sched-123', - inputs: { newKey: 'newVal' }, - name: 'Renamed', - }); - - expect(schedulesService.update).toHaveBeenCalledTimes(1); - const [calledAuth, scheduleId, dto] = schedulesService.update.mock.calls[0]; - expect(calledAuth).toBe(mockAuth); - expect(scheduleId).toBe('sched-123'); - expect(dto.inputPayload).toEqual({ runtimeInputs: { newKey: 'newVal' }, nodeOverrides: {} }); - expect(dto.name).toBe('Renamed'); - // Flat inputs field must NOT exist - expect(dto.inputs).toBeUndefined(); - }); - - it('trigger_schedule returns { triggered: true, scheduleId } since service returns void', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - const result = await tools['trigger_schedule'].handler({ scheduleId: 'sched-abc' }); - const parsed = JSON.parse(result.content[0].text); - - expect(schedulesService.trigger).toHaveBeenCalledWith(mockAuth, 'sched-abc'); - expect(parsed.triggered).toBe(true); - expect(parsed.scheduleId).toBe('sched-abc'); - expect(result.isError).toBeUndefined(); - }); - - it('delete_schedule calls delete and returns { deleted: true, scheduleId }', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - const result = await tools['delete_schedule'].handler({ scheduleId: 'sched-del' }); - const parsed = JSON.parse(result.content[0].text); - - expect(schedulesService.delete).toHaveBeenCalledWith(mockAuth, 'sched-del'); - expect(parsed.deleted).toBe(true); - expect(parsed.scheduleId).toBe('sched-del'); - expect(result.isError).toBeUndefined(); - }); - - it('list_schedules passes auth and optional workflowId filter', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - // Without filter - await tools['list_schedules'].handler({}); - expect(schedulesService.list).toHaveBeenCalledWith(mockAuth, undefined); - - schedulesService.list.mockClear(); - - // With workflowId filter - await tools['list_schedules'].handler({ workflowId: '11111111-1111-4111-8111-111111111111' }); - expect(schedulesService.list).toHaveBeenCalledWith(mockAuth, { - workflowId: '11111111-1111-4111-8111-111111111111', - }); - }); - - it('schedules.create = false → denied', async () => { - const server = service.createServer(restrictedAuth); - const tools = getRegisteredTools(server); - - const result = (await tools['create_schedule'].handler({ - workflowId: '11111111-1111-4111-8111-111111111111', - name: 'Blocked', - cronExpression: '0 9 * * *', - })) as { isError?: boolean; content: { text: string }[] }; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('schedules.create'); - expect(schedulesService.create).not.toHaveBeenCalled(); - }); - - it('schedules.list = false → denied', async () => { - const server = service.createServer(restrictedAuth); - const tools = getRegisteredTools(server); - - const result = (await tools['list_schedules'].handler({})) as { - isError?: boolean; - content: { text: string }[]; - }; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('schedules.list'); - expect(schedulesService.list).not.toHaveBeenCalled(); - }); -}); - -// ─── Secret Tools ───────────────────────────────────────────────────────────── - -describe('Secret Tools', () => { - let service: StudioMcpService; - let secretsService: any; - let workflowsService: WorkflowsService; - - beforeEach(() => { - workflowsService = makeWorkflowsService(); - secretsService = { - listSecrets: jest.fn().mockResolvedValue([{ id: 'sec-1', name: 'MY_SECRET' }]), - createSecret: jest.fn().mockResolvedValue({ id: 'sec-new', name: 'NEW_SECRET' }), - rotateSecret: jest.fn().mockResolvedValue({ id: 'sec-1', version: 2 }), - updateSecret: jest.fn().mockResolvedValue({ id: 'sec-1', name: 'RENAMED' }), - deleteSecret: jest.fn().mockResolvedValue(undefined), - }; - service = new StudioMcpService( - workflowsService, - undefined, - undefined, - undefined, - undefined, - undefined, - secretsService, - ); - }); - - it('list_secrets calls secretsService.listSecrets(auth)', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - const result = await tools['list_secrets'].handler({}); - const parsed = JSON.parse(result.content[0].text); - - expect(secretsService.listSecrets).toHaveBeenCalledWith(mockAuth); - expect(Array.isArray(parsed)).toBe(true); - expect(result.isError).toBeUndefined(); - }); - - it('create_secret calls secretsService.createSecret(auth, { name, value, description, tags })', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['create_secret'].handler({ - name: 'MY_API_KEY', - value: 's3cr3t', - description: 'An API key', - tags: ['prod', 'external'], - }); - - expect(secretsService.createSecret).toHaveBeenCalledWith(mockAuth, { - name: 'MY_API_KEY', - value: 's3cr3t', - description: 'An API key', - tags: ['prod', 'external'], - }); - }); - - it('rotate_secret calls secretsService.rotateSecret(auth, secretId, { value })', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['rotate_secret'].handler({ - secretId: 'sec-rotate-me', - value: 'newvalue123', - }); - - expect(secretsService.rotateSecret).toHaveBeenCalledWith(mockAuth, 'sec-rotate-me', { - value: 'newvalue123', - }); - }); - - it('delete_secret calls deleteSecret and returns { deleted: true }', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - const result = await tools['delete_secret'].handler({ secretId: 'sec-del' }); - const parsed = JSON.parse(result.content[0].text); - - expect(secretsService.deleteSecret).toHaveBeenCalledWith(mockAuth, 'sec-del'); - expect(parsed.deleted).toBe(true); - expect(parsed.secretId).toBe('sec-del'); - expect(result.isError).toBeUndefined(); - }); - - it('secrets.create = false → denied', async () => { - const server = service.createServer(restrictedAuth); - const tools = getRegisteredTools(server); - - const result = (await tools['create_secret'].handler({ - name: 'BLOCKED', - value: 'nope', - })) as { isError?: boolean; content: { text: string }[] }; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('secrets.create'); - expect(secretsService.createSecret).not.toHaveBeenCalled(); - }); - - it('secrets.list = false → denied', async () => { - const server = service.createServer(restrictedAuth); - const tools = getRegisteredTools(server); - - const result = (await tools['list_secrets'].handler({})) as { - isError?: boolean; - content: { text: string }[]; - }; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('secrets.list'); - expect(secretsService.listSecrets).not.toHaveBeenCalled(); - }); -}); - -// ─── Human-Input Tools ──────────────────────────────────────────────────────── - -describe('Human-Input Tools', () => { - let service: StudioMcpService; - let humanInputsService: any; - let workflowsService: WorkflowsService; - - beforeEach(() => { - workflowsService = makeWorkflowsService(); - humanInputsService = { - list: jest.fn().mockResolvedValue([{ id: 'hi-1', status: 'pending' }]), - getById: jest.fn().mockResolvedValue({ id: 'hi-1', status: 'pending' }), - resolve: jest.fn().mockResolvedValue({ id: 'hi-1', status: 'approved' }), - }; - service = new StudioMcpService( - workflowsService, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - humanInputsService, - ); - }); - - it('list_human_inputs calls list with status filter and organizationId', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - // With status filter - await tools['list_human_inputs'].handler({ status: 'pending' }); - expect(humanInputsService.list).toHaveBeenCalledWith( - { status: 'pending' }, - mockAuth.organizationId, - ); - - humanInputsService.list.mockClear(); - - // Without status filter - await tools['list_human_inputs'].handler({}); - expect(humanInputsService.list).toHaveBeenCalledWith( - { status: undefined }, - mockAuth.organizationId, - ); - }); - - it('resolve_human_input maps action to responseData.status: approve → approved', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['resolve_human_input'].handler({ - inputId: 'hi-approve', - action: 'approve', - }); - - expect(humanInputsService.resolve).toHaveBeenCalledTimes(1); - const [inputId, dto] = humanInputsService.resolve.mock.calls[0]; - expect(inputId).toBe('hi-approve'); - expect(dto.responseData.status).toBe('approved'); - }); - - it('resolve_human_input maps action to responseData.status: reject → rejected', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['resolve_human_input'].handler({ - inputId: 'hi-reject', - action: 'reject', - }); - - const [, dto] = humanInputsService.resolve.mock.calls[0]; - expect(dto.responseData.status).toBe('rejected'); - }); - - it('SECURITY: caller-supplied data.status cannot override action (spread order test)', async () => { - // Pass action: 'reject' but data: { status: 'approved' } - // The tool must set status AFTER the spread, so action wins. - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['resolve_human_input'].handler({ - inputId: 'hi-security', - action: 'reject', - data: { status: 'approved' }, // attacker tries to override to approved - }); - - expect(humanInputsService.resolve).toHaveBeenCalledTimes(1); - const [, dto] = humanInputsService.resolve.mock.calls[0]; - // The action ('reject') must win — status must be 'rejected', not 'approved' - expect(dto.responseData.status).toBe('rejected'); - }); - - it('resolve_human_input includes respondedBy: auth.userId', async () => { - const server = service.createServer(mockAuth); - const tools = getRegisteredTools(server); - - await tools['resolve_human_input'].handler({ - inputId: 'hi-resp', - action: 'approve', - }); - - const [, dto] = humanInputsService.resolve.mock.calls[0]; - expect(dto.respondedBy).toBe(mockAuth.userId); - }); - - it('human-inputs.resolve = false → denied', async () => { - const server = service.createServer(restrictedAuth); - const tools = getRegisteredTools(server); - - const result = (await tools['resolve_human_input'].handler({ - inputId: 'hi-blocked', - action: 'approve', - })) as { isError?: boolean; content: { text: string }[] }; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('human-inputs.resolve'); - expect(humanInputsService.resolve).not.toHaveBeenCalled(); - }); - - it('human-inputs.read = false → denied on list_human_inputs', async () => { - const server = service.createServer(restrictedAuth); - const tools = getRegisteredTools(server); - - const result = (await tools['list_human_inputs'].handler({})) as { - isError?: boolean; - content: { text: string }[]; - }; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('human-inputs.read'); - expect(humanInputsService.list).not.toHaveBeenCalled(); - }); -}); diff --git a/backend/src/studio-mcp/__tests__/human-input-tools.spec.ts b/backend/src/studio-mcp/__tests__/human-input-tools.spec.ts new file mode 100644 index 00000000..70e3dac9 --- /dev/null +++ b/backend/src/studio-mcp/__tests__/human-input-tools.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import { StudioMcpService } from '../studio-mcp.service'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import type { WorkflowsService } from '../../workflows/workflows.service'; + +type RegisteredToolsMap = Record; + +function getRegisteredTools(server: McpServer): RegisteredToolsMap { + return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; +} + +const mockAuth: AuthContext = { + userId: 'test-user-id', + organizationId: 'test-org-id', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const restrictedAuth: AuthContext = { + ...mockAuth, + provider: 'api-key', + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: false }, + schedules: { create: false, list: false, read: false, update: false, delete: false }, + secrets: { create: false, list: false, read: false, update: false, delete: false }, + 'human-inputs': { read: false, resolve: false }, + }, +}; + +function makeWorkflowsService(): WorkflowsService { + return { + list: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'wf-id' }), + update: jest.fn().mockResolvedValue({ id: 'wf-id' }), + updateMetadata: jest.fn().mockResolvedValue({ id: 'wf-id' }), + delete: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + listRuns: jest.fn().mockResolvedValue({ runs: [] }), + getRunStatus: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + getRunResult: jest.fn().mockResolvedValue({}), + cancelRun: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkflowsService; +} + +describe('Human-Input Tools', () => { + let service: StudioMcpService; + let humanInputsService: any; + let workflowsService: WorkflowsService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + humanInputsService = { + list: jest.fn().mockResolvedValue([{ id: 'hi-1', status: 'pending' }]), + getById: jest.fn().mockResolvedValue({ id: 'hi-1', status: 'pending' }), + resolve: jest.fn().mockResolvedValue({ id: 'hi-1', status: 'approved' }), + }; + service = new StudioMcpService( + workflowsService, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + humanInputsService, + ); + }); + + it('list_human_inputs calls list with status filter and organizationId', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + // With status filter + await tools['list_human_inputs'].handler({ status: 'pending' }); + expect(humanInputsService.list).toHaveBeenCalledWith( + { status: 'pending' }, + mockAuth.organizationId, + ); + + humanInputsService.list.mockClear(); + + // Without status filter + await tools['list_human_inputs'].handler({}); + expect(humanInputsService.list).toHaveBeenCalledWith( + { status: undefined }, + mockAuth.organizationId, + ); + }); + + it('resolve_human_input maps action to responseData.status: approve → approved', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-approve', + action: 'approve', + }); + + expect(humanInputsService.resolve).toHaveBeenCalledTimes(1); + const [inputId, dto] = humanInputsService.resolve.mock.calls[0]; + expect(inputId).toBe('hi-approve'); + expect(dto.responseData.status).toBe('approved'); + }); + + it('resolve_human_input maps action to responseData.status: reject → rejected', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-reject', + action: 'reject', + }); + + const [, dto] = humanInputsService.resolve.mock.calls[0]; + expect(dto.responseData.status).toBe('rejected'); + }); + + it('SECURITY: caller-supplied data.status cannot override action (spread order test)', async () => { + // Pass action: 'reject' but data: { status: 'approved' } + // The tool must set status AFTER the spread, so action wins. + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-security', + action: 'reject', + data: { status: 'approved' }, // attacker tries to override to approved + }); + + expect(humanInputsService.resolve).toHaveBeenCalledTimes(1); + const [, dto] = humanInputsService.resolve.mock.calls[0]; + // The action ('reject') must win — status must be 'rejected', not 'approved' + expect(dto.responseData.status).toBe('rejected'); + }); + + it('resolve_human_input includes respondedBy: auth.userId', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['resolve_human_input'].handler({ + inputId: 'hi-resp', + action: 'approve', + }); + + const [, dto] = humanInputsService.resolve.mock.calls[0]; + expect(dto.respondedBy).toBe(mockAuth.userId); + }); + + it('human-inputs.resolve = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['resolve_human_input'].handler({ + inputId: 'hi-blocked', + action: 'approve', + })) as { isError?: boolean; content: { text: string }[] }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('human-inputs.resolve'); + expect(humanInputsService.resolve).not.toHaveBeenCalled(); + }); + + it('human-inputs.read = false → denied on list_human_inputs', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['list_human_inputs'].handler({})) as { + isError?: boolean; + content: { text: string }[]; + }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('human-inputs.read'); + expect(humanInputsService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/studio-mcp/__tests__/schedule-tools.spec.ts b/backend/src/studio-mcp/__tests__/schedule-tools.spec.ts new file mode 100644 index 00000000..0aae1a52 --- /dev/null +++ b/backend/src/studio-mcp/__tests__/schedule-tools.spec.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import { StudioMcpService } from '../studio-mcp.service'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import type { WorkflowsService } from '../../workflows/workflows.service'; + +type RegisteredToolsMap = Record; + +function getRegisteredTools(server: McpServer): RegisteredToolsMap { + return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; +} + +const mockAuth: AuthContext = { + userId: 'test-user-id', + organizationId: 'test-org-id', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const restrictedAuth: AuthContext = { + ...mockAuth, + provider: 'api-key', + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: false }, + schedules: { create: false, list: false, read: false, update: false, delete: false }, + secrets: { create: false, list: false, read: false, update: false, delete: false }, + 'human-inputs': { read: false, resolve: false }, + }, +}; + +function makeWorkflowsService(): WorkflowsService { + return { + list: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'wf-id' }), + update: jest.fn().mockResolvedValue({ id: 'wf-id' }), + updateMetadata: jest.fn().mockResolvedValue({ id: 'wf-id' }), + delete: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + listRuns: jest.fn().mockResolvedValue({ runs: [] }), + getRunStatus: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + getRunResult: jest.fn().mockResolvedValue({}), + cancelRun: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkflowsService; +} + +describe('Schedule Tools', () => { + let service: StudioMcpService; + let schedulesService: any; + let workflowsService: WorkflowsService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + schedulesService = { + list: jest.fn().mockResolvedValue([]), + get: jest.fn().mockResolvedValue({ id: 'sched-id' }), + create: jest.fn().mockResolvedValue({ id: 'sched-id', name: 'My Schedule' }), + update: jest.fn().mockResolvedValue({ id: 'sched-id', name: 'Updated' }), + delete: jest.fn().mockResolvedValue(undefined), + pause: jest.fn().mockResolvedValue({ id: 'sched-id', status: 'paused' }), + resume: jest.fn().mockResolvedValue({ id: 'sched-id', status: 'active' }), + trigger: jest.fn().mockResolvedValue(undefined), + }; + service = new StudioMcpService( + workflowsService, + undefined, + undefined, + undefined, + undefined, + schedulesService, + ); + }); + + it('create_schedule maps inputs to inputPayload.runtimeInputs (not flat inputs field)', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['create_schedule'].handler({ + workflowId: '11111111-1111-4111-8111-111111111111', + name: 'Daily Run', + cronExpression: '0 9 * * 1', + inputs: { foo: 'bar', count: 42 }, + timezone: 'America/New_York', + description: 'Weekly schedule', + }); + + expect(schedulesService.create).toHaveBeenCalledTimes(1); + const [calledAuth, dto] = schedulesService.create.mock.calls[0]; + expect(calledAuth).toBe(mockAuth); + // CRITICAL: inputs must be nested under inputPayload.runtimeInputs + expect(dto.inputPayload).toBeDefined(); + expect(dto.inputPayload.runtimeInputs).toEqual({ foo: 'bar', count: 42 }); + expect(dto.inputPayload.nodeOverrides).toEqual({}); + // Flat inputs field must NOT exist on the dto + expect(dto.inputs).toBeUndefined(); + expect(dto.name).toBe('Daily Run'); + expect(dto.cronExpression).toBe('0 9 * * 1'); + expect(dto.timezone).toBe('America/New_York'); + expect(dto.description).toBe('Weekly schedule'); + }); + + it('create_schedule defaults timezone to UTC when not provided', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['create_schedule'].handler({ + workflowId: '11111111-1111-4111-8111-111111111111', + name: 'No TZ', + cronExpression: '0 0 * * *', + }); + + const [, dto] = schedulesService.create.mock.calls[0]; + expect(dto.timezone).toBe('UTC'); + expect(dto.inputPayload.runtimeInputs).toEqual({}); + }); + + it('update_schedule maps inputs to inputPayload correctly', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['update_schedule'].handler({ + scheduleId: 'sched-123', + inputs: { newKey: 'newVal' }, + name: 'Renamed', + }); + + expect(schedulesService.update).toHaveBeenCalledTimes(1); + const [calledAuth, scheduleId, dto] = schedulesService.update.mock.calls[0]; + expect(calledAuth).toBe(mockAuth); + expect(scheduleId).toBe('sched-123'); + expect(dto.inputPayload).toEqual({ runtimeInputs: { newKey: 'newVal' }, nodeOverrides: {} }); + expect(dto.name).toBe('Renamed'); + // Flat inputs field must NOT exist + expect(dto.inputs).toBeUndefined(); + }); + + it('trigger_schedule returns { triggered: true, scheduleId } since service returns void', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['trigger_schedule'].handler({ scheduleId: 'sched-abc' }); + const parsed = JSON.parse(result.content[0].text); + + expect(schedulesService.trigger).toHaveBeenCalledWith(mockAuth, 'sched-abc'); + expect(parsed.triggered).toBe(true); + expect(parsed.scheduleId).toBe('sched-abc'); + expect(result.isError).toBeUndefined(); + }); + + it('delete_schedule calls delete and returns { deleted: true, scheduleId }', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['delete_schedule'].handler({ scheduleId: 'sched-del' }); + const parsed = JSON.parse(result.content[0].text); + + expect(schedulesService.delete).toHaveBeenCalledWith(mockAuth, 'sched-del'); + expect(parsed.deleted).toBe(true); + expect(parsed.scheduleId).toBe('sched-del'); + expect(result.isError).toBeUndefined(); + }); + + it('list_schedules passes auth and optional workflowId filter', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + // Without filter + await tools['list_schedules'].handler({}); + expect(schedulesService.list).toHaveBeenCalledWith(mockAuth, undefined); + + schedulesService.list.mockClear(); + + // With workflowId filter + await tools['list_schedules'].handler({ workflowId: '11111111-1111-4111-8111-111111111111' }); + expect(schedulesService.list).toHaveBeenCalledWith(mockAuth, { + workflowId: '11111111-1111-4111-8111-111111111111', + }); + }); + + it('schedules.create = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['create_schedule'].handler({ + workflowId: '11111111-1111-4111-8111-111111111111', + name: 'Blocked', + cronExpression: '0 9 * * *', + })) as { isError?: boolean; content: { text: string }[] }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('schedules.create'); + expect(schedulesService.create).not.toHaveBeenCalled(); + }); + + it('schedules.list = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['list_schedules'].handler({})) as { + isError?: boolean; + content: { text: string }[]; + }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('schedules.list'); + expect(schedulesService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/studio-mcp/__tests__/secret-tools.spec.ts b/backend/src/studio-mcp/__tests__/secret-tools.spec.ts new file mode 100644 index 00000000..6d71bff0 --- /dev/null +++ b/backend/src/studio-mcp/__tests__/secret-tools.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import { StudioMcpService } from '../studio-mcp.service'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AuthContext } from '../../auth/types'; +import type { WorkflowsService } from '../../workflows/workflows.service'; + +type RegisteredToolsMap = Record; + +function getRegisteredTools(server: McpServer): RegisteredToolsMap { + return (server as unknown as { _registeredTools: RegisteredToolsMap })._registeredTools; +} + +const mockAuth: AuthContext = { + userId: 'test-user-id', + organizationId: 'test-org-id', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const restrictedAuth: AuthContext = { + ...mockAuth, + provider: 'api-key', + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: false }, + schedules: { create: false, list: false, read: false, update: false, delete: false }, + secrets: { create: false, list: false, read: false, update: false, delete: false }, + 'human-inputs': { read: false, resolve: false }, + }, +}; + +function makeWorkflowsService(): WorkflowsService { + return { + list: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'wf-id' }), + update: jest.fn().mockResolvedValue({ id: 'wf-id' }), + updateMetadata: jest.fn().mockResolvedValue({ id: 'wf-id' }), + delete: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + listRuns: jest.fn().mockResolvedValue({ runs: [] }), + getRunStatus: jest.fn().mockResolvedValue({ runId: 'run-id', status: 'RUNNING' }), + getRunResult: jest.fn().mockResolvedValue({}), + cancelRun: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkflowsService; +} + +describe('Secret Tools', () => { + let service: StudioMcpService; + let secretsService: any; + let workflowsService: WorkflowsService; + + beforeEach(() => { + workflowsService = makeWorkflowsService(); + secretsService = { + listSecrets: jest.fn().mockResolvedValue([{ id: 'sec-1', name: 'MY_SECRET' }]), + createSecret: jest.fn().mockResolvedValue({ id: 'sec-new', name: 'NEW_SECRET' }), + rotateSecret: jest.fn().mockResolvedValue({ id: 'sec-1', version: 2 }), + updateSecret: jest.fn().mockResolvedValue({ id: 'sec-1', name: 'RENAMED' }), + deleteSecret: jest.fn().mockResolvedValue(undefined), + }; + service = new StudioMcpService( + workflowsService, + undefined, + undefined, + undefined, + undefined, + undefined, + secretsService, + ); + }); + + it('list_secrets calls secretsService.listSecrets(auth)', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['list_secrets'].handler({}); + const parsed = JSON.parse(result.content[0].text); + + expect(secretsService.listSecrets).toHaveBeenCalledWith(mockAuth); + expect(Array.isArray(parsed)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('create_secret calls secretsService.createSecret(auth, { name, value, description, tags })', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['create_secret'].handler({ + name: 'MY_API_KEY', + value: 's3cr3t', + description: 'An API key', + tags: ['prod', 'external'], + }); + + expect(secretsService.createSecret).toHaveBeenCalledWith(mockAuth, { + name: 'MY_API_KEY', + value: 's3cr3t', + description: 'An API key', + tags: ['prod', 'external'], + }); + }); + + it('rotate_secret calls secretsService.rotateSecret(auth, secretId, { value })', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + await tools['rotate_secret'].handler({ + secretId: 'sec-rotate-me', + value: 'newvalue123', + }); + + expect(secretsService.rotateSecret).toHaveBeenCalledWith(mockAuth, 'sec-rotate-me', { + value: 'newvalue123', + }); + }); + + it('delete_secret calls deleteSecret and returns { deleted: true }', async () => { + const server = service.createServer(mockAuth); + const tools = getRegisteredTools(server); + + const result = await tools['delete_secret'].handler({ secretId: 'sec-del' }); + const parsed = JSON.parse(result.content[0].text); + + expect(secretsService.deleteSecret).toHaveBeenCalledWith(mockAuth, 'sec-del'); + expect(parsed.deleted).toBe(true); + expect(parsed.secretId).toBe('sec-del'); + expect(result.isError).toBeUndefined(); + }); + + it('secrets.create = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['create_secret'].handler({ + name: 'BLOCKED', + value: 'nope', + })) as { isError?: boolean; content: { text: string }[] }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('secrets.create'); + expect(secretsService.createSecret).not.toHaveBeenCalled(); + }); + + it('secrets.list = false → denied', async () => { + const server = service.createServer(restrictedAuth); + const tools = getRegisteredTools(server); + + const result = (await tools['list_secrets'].handler({})) as { + isError?: boolean; + content: { text: string }[]; + }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('secrets.list'); + expect(secretsService.listSecrets).not.toHaveBeenCalled(); + }); +});