Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/api-keys/dto/api-key.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const ApiKeyPermissionsSchema = z.object({
run: z.boolean(),
list: z.boolean(),
read: z.boolean(),
create: z.boolean().optional(),
update: z.boolean().optional(),
delete: z.boolean().optional(),
}),
runs: z.object({
read: z.boolean(),
Expand Down
3 changes: 3 additions & 0 deletions backend/src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ export class AuthGuard implements CanActivate {
run: Boolean(permissions.workflows?.run),
list: Boolean(permissions.workflows?.list),
read: Boolean(permissions.workflows?.read),
create: Boolean(permissions.workflows?.create),
update: Boolean(permissions.workflows?.update),
delete: Boolean(permissions.workflows?.delete),
},
runs: {
read: Boolean(permissions.runs?.read),
Expand Down
3 changes: 3 additions & 0 deletions backend/src/database/schema/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface ApiKeyPermissions {
run: boolean;
list: boolean;
read: boolean;
create?: boolean;
update?: boolean;
delete?: boolean;
};
runs: {
read: boolean;
Expand Down
94 changes: 93 additions & 1 deletion backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ describe('StudioMcpService Unit Tests', () => {
workflowsService = {
list: jest.fn().mockResolvedValue([]),
findById: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({
id: 'created-workflow-id',
name: 'Created Workflow',
description: null,
currentVersion: 1,
currentVersionId: 'created-version-id',
}),
update: jest.fn().mockResolvedValue({
id: 'updated-workflow-id',
name: 'Updated Workflow',
description: 'Updated description',
currentVersion: 2,
currentVersionId: 'updated-version-id',
}),
updateMetadata: jest.fn().mockResolvedValue({
id: 'updated-workflow-id',
name: 'Updated Workflow',
description: 'Updated description',
}),
delete: jest.fn().mockResolvedValue(undefined),
run: jest.fn().mockResolvedValue({
runId: 'test-run-id',
workflowId: 'test-workflow-id',
Expand Down Expand Up @@ -69,6 +89,8 @@ describe('StudioMcpService Unit Tests', () => {
const toolNames = Object.keys(registeredTools).sort();
expect(toolNames).toEqual([
'cancel_run',
'create_workflow',
'delete_workflow',
'get_component',
'get_run_result',
'get_run_status',
Expand All @@ -77,6 +99,8 @@ describe('StudioMcpService Unit Tests', () => {
'list_runs',
'list_workflows',
'run_workflow',
'update_workflow',
'update_workflow_metadata',
]);
});

Expand Down Expand Up @@ -109,6 +133,58 @@ describe('StudioMcpService Unit Tests', () => {
expect(workflowsService.findById).toHaveBeenCalledWith(workflowId, mockAuthContext);
});

it('create_workflow tool uses auth context passed at creation time', async () => {
const server = service.createServer(mockAuthContext);
const registeredTools = getRegisteredTools(server);
const createWorkflowTool = registeredTools['create_workflow'];

expect(createWorkflowTool).toBeDefined();
await createWorkflowTool.handler({
name: 'New Workflow',
nodes: [
{
id: 'entry-1',
type: 'core.workflow.entrypoint',
position: { x: 10, y: 20 },
data: { label: 'Start', config: {} },
},
],
edges: [],
});

expect(workflowsService.create).toHaveBeenCalledWith(
{
name: 'New Workflow',
description: undefined,
nodes: [
{
id: 'entry-1',
type: 'core.workflow.entrypoint',
position: { x: 10, y: 20 },
data: { label: 'Start', config: {} },
},
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
mockAuthContext,
);
});

it('delete_workflow tool uses auth context passed at creation time', async () => {
const server = service.createServer(mockAuthContext);
const registeredTools = getRegisteredTools(server);
const deleteWorkflowTool = registeredTools['delete_workflow'];

expect(deleteWorkflowTool).toBeDefined();
await deleteWorkflowTool.handler({ workflowId: '11111111-1111-4111-8111-111111111111' });

expect(workflowsService.delete).toHaveBeenCalledWith(
'11111111-1111-4111-8111-111111111111',
mockAuthContext,
);
});

it('run_workflow task uses auth context passed at creation time', async () => {
const workflowId = '11111111-1111-4111-8111-111111111111';
const inputs = { key: 'value' };
Expand Down Expand Up @@ -257,6 +333,18 @@ describe('StudioMcpService Unit Tests', () => {
expect(errorThrown).toBe(true);
});

it('denies create_workflow when workflows.create is missing', async () => {
const server = service.createServer(restrictedAuth);
const tools = getRegisteredTools(server);
const result = (await tools['create_workflow'].handler({
name: 'Denied Create',
nodes: [],
edges: [],
})) as { isError?: boolean; content: { text: string }[] };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('workflows.create');
});

it('denies cancel_run when runs.cancel is false', async () => {
const server = service.createServer(restrictedAuth);
const tools = getRegisteredTools(server);
Expand Down Expand Up @@ -329,7 +417,7 @@ describe('StudioMcpService Unit Tests', () => {
expect(getResult.isError).toBeUndefined();
});

it('denies all 7 gated tools when all permissions are false', async () => {
it('denies all gated non-task tools when all permissions are false', async () => {
const noPermsAuth: AuthContext = {
...restrictedAuth,
apiKeyPermissions: {
Expand All @@ -345,6 +433,10 @@ describe('StudioMcpService Unit Tests', () => {
const gatedTools = [
'list_workflows',
'get_workflow',
'create_workflow',
'update_workflow',
'update_workflow_metadata',
'delete_workflow',
'list_runs',
'get_run_status',
'get_run_result',
Expand Down
205 changes: 204 additions & 1 deletion backend/src/studio-mcp/studio-mcp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
import type { ExecutionStatus } from '@shipsec/shared';
import { categorizeComponent } from '../components/utils/categorization';
import { WorkflowsService, type WorkflowRunSummary } from '../workflows/workflows.service';
import type { ServiceWorkflowResponse } from '../workflows/dto/workflow-graph.dto';
import {
WorkflowNodeSchema,
WorkflowEdgeSchema,
WorkflowViewportSchema,
type ServiceWorkflowResponse,
} from '../workflows/dto/workflow-graph.dto';
import type { AuthContext, ApiKeyPermissions } from '../auth/types';
import {
InMemoryTaskStore,
Expand All @@ -24,6 +29,9 @@ import {
type PermissionPath =
| 'workflows.list'
| 'workflows.read'
| 'workflows.create'
| 'workflows.update'
| 'workflows.delete'
| 'workflows.run'
| 'runs.read'
| 'runs.cancel';
Expand Down Expand Up @@ -154,6 +162,201 @@ export class StudioMcpService {
},
);

server.registerTool(
'create_workflow',
{
description:
'Create a new workflow. Provide a name, optional description, and the graph definition (nodes and edges).',
inputSchema: {
name: z.string().describe('Name of the workflow'),
description: z.string().optional().describe('Optional description of the workflow'),
nodes: z
.array(WorkflowNodeSchema)
.min(1)
.describe(
'Array of workflow nodes. Each node needs id, type (component ID), position {x, y}, and data {label, config}',
),
edges: z
.array(WorkflowEdgeSchema)
.describe(
'Array of edges connecting nodes. Each edge needs id, source, target, and optionally sourceHandle/targetHandle for specific ports',
),
viewport: WorkflowViewportSchema.optional().describe(
'Optional viewport position {x, y, zoom}',
),
},
},
async (args: {
name: string;
description?: string;
nodes: z.infer<typeof WorkflowNodeSchema>[];
edges: z.infer<typeof WorkflowEdgeSchema>[];
viewport?: z.infer<typeof WorkflowViewportSchema>;
}) => {
const gate = this.checkPermission(auth, 'workflows.create');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose workflow mutation permissions to API-key auth

This new gate checks workflows.create (and similarly workflows.update/workflows.delete below), but API-key auth currently only carries workflows.run/list/read (see backend/src/auth/auth.guard.ts normalization and backend/src/api-keys/dto/api-key.dto.ts), so these flags are always undefined and checkPermission always denies API-key callers. In practice, all new workflow mutation MCP tools become unusable for API-key clients regardless of key configuration until the permission model is wired end-to-end.

Useful? React with 👍 / 👎.

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<typeof WorkflowNodeSchema>[];
edges: z.infer<typeof WorkflowEdgeSchema>[];
viewport?: z.infer<typeof WorkflowViewportSchema>;
}) => {
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(),
Expand Down