diff --git a/e2e/mcp.spec.ts b/e2e/mcp.spec.ts index 569a18b..ab611a4 100644 --- a/e2e/mcp.spec.ts +++ b/e2e/mcp.spec.ts @@ -1,7 +1,15 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { expect, test } from '@playwright/test'; import { resetE2EState } from './helpers'; -import { createPublicClient, E2E_OWNER_EMAIL, E2E_OWNER_PASSWORD, E2E_TEAM_ID } from './local-fixtures'; +import { + createPublicClient, + E2E_NODE_RECEIVE_ID, + E2E_OWNER_EMAIL, + E2E_OWNER_PASSWORD, + E2E_PROCESS_FLOW_ID, + E2E_STORY_MAP_ID, + E2E_TEAM_ID, +} from './local-fixtures'; test.beforeEach(async () => { await resetE2EState(); @@ -49,9 +57,17 @@ test('serves authenticated MCP tools over the v2 HTTP transport', async ({ baseU expect(client.getServerVersion()).toMatchObject({ name: 'beemspec' }); expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + expect(client.getInstructions()).toContain('processflow_nodes_mutate'); const { tools } = await client.listTools(); - expect(tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'team_list' })])); + const toolNames = new Set(tools.map((tool) => tool.name)); + expect(toolNames.has('team_list')).toBe(true); + expect(toolNames.has('storymap_workflow_guide')).toBe(false); + expect(toolNames.has('processflow_workflow_guide')).toBe(false); + expect(toolNames.has('processflow_nodes_mutate')).toBe(true); + expect(toolNames.has('processflow_edges_mutate')).toBe(true); + expect(toolNames.has('processflow_autolayout')).toBe(true); + expect(tools.every((tool) => tool.outputSchema)).toBe(true); const teamList = await client.callTool({ name: 'team_list', arguments: {} }); expect(teamList.isError).not.toBe(true); @@ -65,6 +81,64 @@ test('serves authenticated MCP tools over the v2 HTTP transport', async ({ baseU }, ]), }); + + const storyMap = await client.callTool({ + name: 'storymap_get', + arguments: { story_map_id: E2E_STORY_MAP_ID }, + }); + expect(storyMap.isError).not.toBe(true); + expect(storyMap.structuredContent).toMatchObject({ + ok: true, + data: { id: E2E_STORY_MAP_ID }, + }); + + const processFlow = await client.callTool({ + name: 'processflow_get', + arguments: { process_flow_id: E2E_PROCESS_FLOW_ID }, + }); + expect(processFlow.isError).not.toBe(true); + expect(processFlow.structuredContent).toMatchObject({ + ok: true, + data: { id: E2E_PROCESS_FLOW_ID }, + }); + + const batchResult = await client.callTool({ + name: 'processflow_nodes_mutate', + arguments: { + process_flow_id: E2E_PROCESS_FLOW_ID, + mutations: [ + { + action: 'update', + id: E2E_NODE_RECEIVE_ID, + payload: { data: { label: 'Receive and validate invoice' } }, + }, + ], + }, + }); + expect(batchResult.isError).not.toBe(true); + expect(batchResult.structuredContent).toMatchObject({ + ok: true, + data: { + updated: [ + { + id: E2E_NODE_RECEIVE_ID, + data: { label: 'Receive and validate invoice' }, + }, + ], + }, + }); + + const layoutResult = await client.callTool({ + name: 'processflow_autolayout', + arguments: { process_flow_id: E2E_PROCESS_FLOW_ID }, + }); + expect(layoutResult.isError).not.toBe(true); + expect(layoutResult.structuredContent).toMatchObject({ + ok: true, + data: { + nodes: expect.arrayContaining([expect.objectContaining({ id: E2E_NODE_RECEIVE_ID })]), + }, + }); } finally { await client.close(); const { error: signOutError } = await supabase.auth.signOut(); diff --git a/src/domain/process-flow/schemas.test.ts b/src/domain/process-flow/schemas.test.ts new file mode 100644 index 0000000..cd08882 --- /dev/null +++ b/src/domain/process-flow/schemas.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + batchMutateProcessFlowEdgesSchema, + batchMutateProcessFlowNodesSchema, + processFlowNodeDataSchema, + updateProcessFlowEdgeToolSchema, + updateProcessFlowNodeToolSchema, + updateProcessFlowToolSchema, +} from './schemas'; + +const id = (value: number) => `00000000-0000-4000-8000-${value.toString().padStart(12, '0')}`; + +describe('process flow model-facing schemas', () => { + it.each([ + ['process flow', updateProcessFlowToolSchema, { process_flow_id: id(1) }], + ['node', updateProcessFlowNodeToolSchema, { process_flow_id: id(1), node_id: id(2) }], + ['edge', updateProcessFlowEdgeToolSchema, { process_flow_id: id(1), edge_id: id(2) }], + ])('requires a real change when updating a %s', (_entity, schema, input) => { + expect(schema.safeParse(input).success).toBe(false); + expect(schema.description).toContain('at least one change is required'); + }); + + it('accepts documented operational node data', () => { + expect( + processFlowNodeDataSchema.safeParse({ + label: 'Approve request', + owner_role: 'Finance', + systems: ['ERP'], + inputs: ['Purchase request'], + outputs: ['Approval'], + frequency: 'Daily', + estimated_duration: '15 minutes', + }).success, + ).toBe(true); + }); + + it.each([ + ['node', batchMutateProcessFlowNodesSchema], + ['edge', batchMutateProcessFlowEdgesSchema], + ])('limits %s mutation batches', (_entity, schema) => { + const mutations = Array.from({ length: 101 }, (_, index) => ({ action: 'delete' as const, id: id(index + 1) })); + + expect(schema.safeParse({ process_flow_id: id(200), mutations }).success).toBe(false); + }); +}); diff --git a/src/domain/process-flow/schemas.ts b/src/domain/process-flow/schemas.ts index 427136c..45e8e1a 100644 --- a/src/domain/process-flow/schemas.ts +++ b/src/domain/process-flow/schemas.ts @@ -1,56 +1,75 @@ import { z } from 'zod'; -const uuid = z.string().uuid(); -const nullableString = z.string().min(1).nullable(); -const name = z.string().min(1, 'Required').max(200); -const nonEmptyLabel = z.string().min(1, 'Required').max(200); +const MAX_TEXT_LENGTH = 20_000; +const MAX_MARKDOWN_LENGTH = 100_000; +const MAX_COLLECTION_ITEMS = 200; +const MAX_BATCH_MUTATIONS = 100; + +const uuid = (description: string) => z.string().uuid().describe(description); +const nullableText = (description: string, max = MAX_TEXT_LENGTH) => + z.string().min(1).max(max).nullable().describe(`${description} Pass null to clear it.`); +const name = (description: string) => z.string().min(1, 'Required').max(200).describe(description); +const nonEmptyLabel = (description: string) => z.string().min(1, 'Required').max(200).describe(description); +const stringList = (description: string, itemDescription: string) => + z + .array(z.string().min(1).max(2_000).describe(itemDescription)) + .max(MAX_COLLECTION_ITEMS, `Collection cannot contain more than ${MAX_COLLECTION_ITEMS} items`) + .describe(description); const atLeastOneField = >(data: T): boolean => Object.values(data).some((value) => value !== undefined); const atLeastOneFieldMessage = { message: 'At least one field must be provided' }; +const updateDescription = (entity: string) => `Fields to change for the ${entity}; at least one change is required.`; export const processFlowViewportSchema = z .object({ - x: z.number(), - y: z.number(), - zoom: z.number().positive(), + x: z.number().finite().describe('Canvas viewport horizontal offset in pixels.'), + y: z.number().finite().describe('Canvas viewport vertical offset in pixels.'), + zoom: z.number().finite().positive().describe('Positive canvas zoom multiplier.'), }) .strict(); -export const processFlowNodeTypeSchema = z.enum(['step', 'decision', 'subprocess', 'actor', 'system', 'note']); +export const processFlowNodeTypeSchema = z + .enum(['step', 'decision', 'subprocess', 'actor', 'system', 'note']) + .describe('Semantic node type used to render and interpret the process step.'); -export const processFlowEdgeTypeSchema = z.enum(['flow', 'handoff', 'exception', 'dependency']); +export const processFlowEdgeTypeSchema = z + .enum(['flow', 'handoff', 'exception', 'dependency']) + .describe('Semantic relationship represented by the edge.'); export const processFlowNodeDataSchema = z .object({ - label: nonEmptyLabel, - owner_role: nullableString.optional(), - systems: z.array(z.string().min(1)).optional(), - inputs: z.array(z.string().min(1)).optional(), - outputs: z.array(z.string().min(1)).optional(), - pain_points: nullableString.optional(), - notes: nullableString.optional(), - automation_opportunity: nullableString.optional(), - frequency: nullableString.optional(), - estimated_duration: nullableString.optional(), - time_constraint: nullableString.optional(), + label: nonEmptyLabel('Short text displayed inside the node.'), + owner_role: nullableText('Role accountable for this step.').optional(), + systems: stringList('Systems or applications involved in this step.', 'System or application name.').optional(), + inputs: stringList('Information or artifacts consumed by this step.', 'Input name or description.').optional(), + outputs: stringList('Information or artifacts produced by this step.', 'Output name or description.').optional(), + pain_points: nullableText('Known friction, failures, or user pain at this step.').optional(), + notes: nullableText('Additional operational notes for this step.').optional(), + automation_opportunity: nullableText('Potential automation or improvement opportunity.').optional(), + frequency: nullableText('How often this step occurs, expressed in natural language.').optional(), + estimated_duration: nullableText('Typical elapsed or active duration, including units.').optional(), + time_constraint: nullableText('Deadline, timing window, or service-level constraint.').optional(), }) .strict(); export const processFlowEdgeDataSchema = z .object({ - label: nullableString.optional(), - condition: nullableString.optional(), + label: nullableText('Short text displayed on the connection.').optional(), + condition: nullableText('Rule or event that determines when this path is taken.').optional(), }) .strict(); export const processFlowBase = z .object({ - team_id: uuid, - name, - description: nullableString, - context_markdown: nullableString, - viewport: processFlowViewportSchema.nullable().optional(), + team_id: uuid('Team UUID that owns the process flow.'), + name: name('Human-readable process flow name.'), + description: nullableText('Short process flow description.'), + context_markdown: nullableText( + 'Long-form Markdown operational context, decisions, constraints, and links for agents.', + MAX_MARKDOWN_LENGTH, + ), + viewport: processFlowViewportSchema.nullable().optional().describe('Saved canvas viewport. Pass null to clear it.'), }) .strict(); @@ -62,26 +81,36 @@ export const createProcessFlowSchema = processFlowBase.partial({ const updateProcessFlowFieldsSchema = processFlowBase.omit({ team_id: true }).partial().strict(); -export const updateProcessFlowSchema = updateProcessFlowFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updateProcessFlowSchema = updateProcessFlowFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('process flow')); export const updateProcessFlowToolSchema = updateProcessFlowFieldsSchema - .extend({ process_flow_id: uuid }) - .refine(({ process_flow_id: _processFlowId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ process_flow_id: uuid('Process flow UUID to update.') }) + .refine(({ process_flow_id: _processFlowId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('process flow')); export const processFlowNodeBase = z .object({ - process_flow_id: uuid, + process_flow_id: uuid('Process flow UUID that owns the node.'), type: processFlowNodeTypeSchema, - position: z.object({ x: z.number(), y: z.number() }).strict(), + position: z + .object({ + x: z.number().finite().describe('Horizontal canvas coordinate in pixels.'), + y: z.number().finite().describe('Vertical canvas coordinate in pixels.'), + }) + .strict() + .describe('Absolute node position on the process-flow canvas.'), size: z .object({ - width: z.number().positive().optional(), - height: z.number().positive().optional(), + width: z.number().finite().positive().optional().describe('Rendered node width in pixels.'), + height: z.number().finite().positive().optional().describe('Rendered node height in pixels.'), }) .strict() .nullable() - .optional(), - data: processFlowNodeDataSchema, + .optional() + .describe('Optional rendered node dimensions. Pass null to clear them.'), + data: processFlowNodeDataSchema.describe('Operational content displayed by and associated with this node.'), }) .strict(); @@ -89,27 +118,30 @@ export const createProcessFlowNodeSchema = processFlowNodeBase; const updateProcessFlowNodeFieldsSchema = processFlowNodeBase.omit({ process_flow_id: true }).partial().strict(); -export const updateProcessFlowNodeSchema = updateProcessFlowNodeFieldsSchema.refine( - atLeastOneField, - atLeastOneFieldMessage, -); +export const updateProcessFlowNodeSchema = updateProcessFlowNodeFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('process flow node')); export const updateProcessFlowNodeToolSchema = updateProcessFlowNodeFieldsSchema - .extend({ process_flow_id: uuid, node_id: uuid }) + .extend({ + process_flow_id: uuid('Process flow UUID that owns the node.'), + node_id: uuid('Node UUID to update.'), + }) .refine( ({ process_flow_id: _processFlowId, node_id: _nodeId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage, - ); + ) + .describe(updateDescription('process flow node')); export const createProcessFlowNodeBodySchema = processFlowNodeBase.omit({ process_flow_id: true }); export const processFlowEdgeBase = z .object({ - process_flow_id: uuid, + process_flow_id: uuid('Process flow UUID that owns the edge.'), type: processFlowEdgeTypeSchema, - source_node_id: uuid, - target_node_id: uuid, - data: processFlowEdgeDataSchema.nullable().optional(), + source_node_id: uuid('Node UUID where the directed edge starts.'), + target_node_id: uuid('Node UUID where the directed edge ends.'), + data: processFlowEdgeDataSchema.nullable().optional().describe('Optional edge label and routing condition.'), }) .strict(); @@ -120,45 +152,88 @@ const updateProcessFlowEdgeFieldsSchema = processFlowEdgeBase .partial() .strict(); -export const updateProcessFlowEdgeSchema = updateProcessFlowEdgeFieldsSchema.refine( - atLeastOneField, - atLeastOneFieldMessage, -); +export const updateProcessFlowEdgeSchema = updateProcessFlowEdgeFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('process flow edge')); export const updateProcessFlowEdgeToolSchema = updateProcessFlowEdgeFieldsSchema - .extend({ process_flow_id: uuid, edge_id: uuid }) + .extend({ + process_flow_id: uuid('Process flow UUID that owns the edge.'), + edge_id: uuid('Edge UUID to update.'), + }) .refine( ({ process_flow_id: _processFlowId, edge_id: _edgeId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage, - ); + ) + .describe(updateDescription('process flow edge')); export const createProcessFlowEdgeBodySchema = processFlowEdgeBase.omit({ process_flow_id: true }); export const batchProcessFlowNodeMutationSchema = z.discriminatedUnion('action', [ - z.object({ action: z.literal('create'), payload: createProcessFlowNodeBodySchema }).strict(), - z.object({ action: z.literal('update'), id: uuid, payload: updateProcessFlowNodeSchema }).strict(), - z.object({ action: z.literal('delete'), id: uuid }).strict(), + z + .object({ + action: z.literal('create').describe('Create a new node.'), + payload: createProcessFlowNodeBodySchema.describe('Complete new node definition.'), + }) + .strict(), + z + .object({ + action: z.literal('update').describe('Update an existing node.'), + id: uuid('Node UUID to update.'), + payload: updateProcessFlowNodeSchema.describe('Node changes; at least one change is required.'), + }) + .strict(), + z + .object({ + action: z.literal('delete').describe('Delete an existing node.'), + id: uuid('Node UUID to delete.'), + }) + .strict(), ]); export const batchMutateProcessFlowNodesSchema = z .object({ - process_flow_id: uuid, - mutations: z.array(batchProcessFlowNodeMutationSchema).min(1, 'At least one mutation is required'), + process_flow_id: uuid('Process flow UUID whose nodes will be mutated.'), + mutations: z + .array(batchProcessFlowNodeMutationSchema) + .min(1, 'At least one mutation is required') + .max(MAX_BATCH_MUTATIONS, `Batch cannot contain more than ${MAX_BATCH_MUTATIONS} mutations`) + .describe('Ordered node mutations applied atomically in one transaction.'), }) .strict(); export const batchProcessFlowNodesBodySchema = batchMutateProcessFlowNodesSchema.omit({ process_flow_id: true }); export const batchProcessFlowEdgeMutationSchema = z.discriminatedUnion('action', [ - z.object({ action: z.literal('create'), payload: createProcessFlowEdgeBodySchema }).strict(), - z.object({ action: z.literal('update'), id: uuid, payload: updateProcessFlowEdgeSchema }).strict(), - z.object({ action: z.literal('delete'), id: uuid }).strict(), + z + .object({ + action: z.literal('create').describe('Create a new edge.'), + payload: createProcessFlowEdgeBodySchema.describe('Complete new edge definition.'), + }) + .strict(), + z + .object({ + action: z.literal('update').describe('Update an existing edge.'), + id: uuid('Edge UUID to update.'), + payload: updateProcessFlowEdgeSchema.describe('Edge changes; at least one change is required.'), + }) + .strict(), + z + .object({ + action: z.literal('delete').describe('Delete an existing edge.'), + id: uuid('Edge UUID to delete.'), + }) + .strict(), ]); export const batchMutateProcessFlowEdgesSchema = z .object({ - process_flow_id: uuid, - mutations: z.array(batchProcessFlowEdgeMutationSchema).min(1, 'At least one mutation is required'), + process_flow_id: uuid('Process flow UUID whose edges will be mutated.'), + mutations: z + .array(batchProcessFlowEdgeMutationSchema) + .min(1, 'At least one mutation is required') + .max(MAX_BATCH_MUTATIONS, `Batch cannot contain more than ${MAX_BATCH_MUTATIONS} mutations`) + .describe('Ordered edge mutations applied atomically in one transaction.'), }) .strict(); @@ -166,13 +241,13 @@ export const batchProcessFlowEdgesBodySchema = batchMutateProcessFlowEdgesSchema export const processFlowAutolayoutSchema = z .object({ - process_flow_id: uuid, + process_flow_id: uuid('Process flow UUID to lay out deterministically.'), }) .strict(); export const processFlowValidationRequestSchema = z .object({ - process_flow_id: uuid, + process_flow_id: uuid('Process flow UUID to validate.'), }) .strict(); diff --git a/src/domain/story-map/schemas.test.ts b/src/domain/story-map/schemas.test.ts new file mode 100644 index 0000000..5243bba --- /dev/null +++ b/src/domain/story-map/schemas.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + moveStorySchema, + moveTaskSchema, + reorderActivitiesSchema, + reorderReleasesSchema, + reorderStoriesSchema, + reorderTasksSchema, + updateActivityToolSchema, + updatePersonaToolSchema, + updateReleaseToolSchema, + updateStoryMapToolSchema, + updateStoryToolSchema, + updateTaskToolSchema, +} from './schemas'; + +const id = (value: number) => `00000000-0000-4000-8000-${value.toString().padStart(12, '0')}`; + +describe('story map model-facing schemas', () => { + it.each([ + ['story map', updateStoryMapToolSchema, { story_map_id: id(1) }], + ['release', updateReleaseToolSchema, { release_id: id(1) }], + ['activity', updateActivityToolSchema, { activity_id: id(1) }], + ['task', updateTaskToolSchema, { task_id: id(1) }], + ['story', updateStoryToolSchema, { story_id: id(1) }], + ['persona', updatePersonaToolSchema, { persona_id: id(1) }], + ])('requires a real change when updating a %s', (_entity, schema, input) => { + expect(schema.safeParse(input).success).toBe(false); + expect(schema.description).toContain('at least one change is required'); + }); + + it.each([ + ['releases', reorderReleasesSchema, { story_map_id: id(1), order: [id(2), id(2)] }], + ['activities', reorderActivitiesSchema, { story_map_id: id(1), order: [id(2), id(2)] }], + ['tasks', reorderTasksSchema, { activity_id: id(1), order: [id(2), id(2)] }], + ['stories', reorderStoriesSchema, { task_id: id(1), release_id: null, order: [id(2), id(2)] }], + ['moved tasks', moveTaskSchema, { target_activity_id: id(1), target_order: [id(2), id(2)] }], + [ + 'moved stories', + moveStorySchema, + { target_task_id: id(1), target_release_id: null, target_order: [id(2), id(2)] }, + ], + ])('rejects duplicate IDs when ordering %s', (_entity, schema, input) => { + const result = schema.safeParse(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message === 'Order must not contain duplicate IDs')).toBe(true); + } + }); + + it('preserves a valid complete order', () => { + expect(reorderReleasesSchema.safeParse({ story_map_id: id(1), order: [id(2), id(3)] }).success).toBe(true); + }); +}); diff --git a/src/domain/story-map/schemas.ts b/src/domain/story-map/schemas.ts index 7238cf0..b5b80b3 100644 --- a/src/domain/story-map/schemas.ts +++ b/src/domain/story-map/schemas.ts @@ -4,19 +4,34 @@ import { z } from 'zod'; // Shared primitives // --------------------------------------------------------------------------- -const uuid = z.string().uuid(); -const nullableString = z.string().min(1).nullable(); -const name = z.string().min(1, 'Required').max(200); +const MAX_TEXT_LENGTH = 20_000; +const MAX_MARKDOWN_LENGTH = 100_000; +const MAX_ORDER_ITEMS = 1_000; + +const uuid = (description: string) => z.string().uuid().describe(description); +const nullableText = (description: string, max = MAX_TEXT_LENGTH) => + z.string().min(1).max(max).nullable().describe(`${description} Pass null to clear it.`); +const name = (description: string) => z.string().min(1, 'Required').max(200).describe(description); +const uniqueUuidOrder = (description: string, itemDescription: string) => + z + .array(uuid(itemDescription)) + .min(1, 'Order array cannot be empty') + .max(MAX_ORDER_ITEMS, `Order cannot contain more than ${MAX_ORDER_ITEMS} IDs`) + .refine((ids) => new Set(ids).size === ids.length, 'Order must not contain duplicate IDs') + .describe(description); const atLeastOneField = >(data: T): boolean => Object.values(data).some((value) => value !== undefined); const atLeastOneFieldMessage = { message: 'At least one field must be provided' }; +const updateDescription = (entity: string) => `Fields to change for the ${entity}; at least one change is required.`; // --------------------------------------------------------------------------- // Story status — single source of truth (derive the TS type via z.infer) // --------------------------------------------------------------------------- -export const storyStatus = z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done']); +export const storyStatus = z + .enum(['backlog', 'todo', 'in_progress', 'in_review', 'done']) + .describe('Story workflow status.'); // --------------------------------------------------------------------------- // Story content — structured spec fields stored as JSON @@ -24,12 +39,20 @@ export const storyStatus = z.enum(['backlog', 'todo', 'in_progress', 'in_review' export const storyContentSchema = z .object({ - _version: z.literal(1).optional().default(1), - user_story: z.string().min(1, 'Required'), - acceptance_criteria: z.string().min(1, 'Required'), - figma_link: z.url().nullable().optional(), - edge_cases: nullableString.optional(), - technical_guidelines: nullableString.optional(), + _version: z.literal(1).optional().default(1).describe('Story content schema version; currently 1.'), + user_story: z + .string() + .min(1, 'Required') + .max(MAX_MARKDOWN_LENGTH) + .describe('User-centered story statement, including the actor, desired capability, and outcome.'), + acceptance_criteria: z + .string() + .min(1, 'Required') + .max(MAX_MARKDOWN_LENGTH) + .describe('Testable acceptance criteria, preferably as concise Markdown.'), + figma_link: z.url().max(2_048).nullable().optional().describe('Related Figma design URL. Pass null to clear it.'), + edge_cases: nullableText('Known edge cases and exceptional behavior.').optional(), + technical_guidelines: nullableText('Implementation constraints or technical guidance.').optional(), }) .strict(); @@ -39,10 +62,13 @@ export const storyContentSchema = z export const storyMapBase = z .object({ - team_id: uuid, - name, - description: nullableString, - context_markdown: nullableString, + team_id: uuid('Team UUID that owns the story map.'), + name: name('Human-readable story map name.'), + description: nullableText('Short story map description.'), + context_markdown: nullableText( + 'Long-form Markdown product context, decisions, constraints, and links for agents.', + MAX_MARKDOWN_LENGTH, + ), }) .strict(); @@ -50,11 +76,14 @@ export const createStoryMapSchema = storyMapBase.partial({ description: true, co const updateStoryMapFieldsSchema = storyMapBase.omit({ team_id: true }).partial().strict(); -export const updateStoryMapSchema = updateStoryMapFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updateStoryMapSchema = updateStoryMapFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('story map')); export const updateStoryMapToolSchema = updateStoryMapFieldsSchema - .extend({ story_map_id: uuid }) - .refine(({ story_map_id: _storyMapId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ story_map_id: uuid('Story map UUID to update.') }) + .refine(({ story_map_id: _storyMapId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('story map')); // --------------------------------------------------------------------------- // Release @@ -62,10 +91,13 @@ export const updateStoryMapToolSchema = updateStoryMapFieldsSchema export const releaseBase = z .object({ - story_map_id: uuid, - name, - description: nullableString, - context_markdown: nullableString, + story_map_id: uuid('Story map UUID that owns the release.'), + name: name('Human-readable release name.'), + description: nullableText('Short release description.'), + context_markdown: nullableText( + 'Long-form Markdown release scope, decisions, constraints, and links for agents.', + MAX_MARKDOWN_LENGTH, + ), }) .strict(); @@ -73,16 +105,22 @@ export const createReleaseSchema = releaseBase.partial({ description: true, cont const updateReleaseFieldsSchema = releaseBase.omit({ story_map_id: true }).partial().strict(); -export const updateReleaseSchema = updateReleaseFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updateReleaseSchema = updateReleaseFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('release')); export const updateReleaseToolSchema = updateReleaseFieldsSchema - .extend({ release_id: uuid }) - .refine(({ release_id: _releaseId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ release_id: uuid('Release UUID to update.') }) + .refine(({ release_id: _releaseId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('release')); export const reorderReleasesSchema = z .object({ - story_map_id: uuid, - order: z.array(uuid).min(1, 'Order array cannot be empty'), + story_map_id: uuid('Story map UUID whose releases will be reordered.'), + order: uniqueUuidOrder( + 'Complete final ordering of every release UUID in the story map.', + 'Release UUID in final display order.', + ), }) .strict(); @@ -92,9 +130,9 @@ export const reorderReleasesSchema = z export const activityBase = z .object({ - story_map_id: uuid, - name, - description: nullableString, + story_map_id: uuid('Story map UUID that owns the activity.'), + name: name('Human-readable activity name.'), + description: nullableText('Short activity description.'), }) .strict(); @@ -102,16 +140,22 @@ export const createActivitySchema = activityBase.partial({ description: true }); const updateActivityFieldsSchema = activityBase.omit({ story_map_id: true }).partial().strict(); -export const updateActivitySchema = updateActivityFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updateActivitySchema = updateActivityFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('activity')); export const updateActivityToolSchema = updateActivityFieldsSchema - .extend({ activity_id: uuid }) - .refine(({ activity_id: _activityId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ activity_id: uuid('Activity UUID to update.') }) + .refine(({ activity_id: _activityId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('activity')); export const reorderActivitiesSchema = z .object({ - story_map_id: uuid, - order: z.array(uuid).min(1, 'Order array cannot be empty'), + story_map_id: uuid('Story map UUID whose activities will be reordered.'), + order: uniqueUuidOrder( + 'Complete final ordering of every activity UUID in the story map.', + 'Activity UUID in final display order.', + ), }) .strict(); @@ -121,9 +165,9 @@ export const reorderActivitiesSchema = z export const taskBase = z .object({ - activity_id: uuid, - name, - description: nullableString, + activity_id: uuid('Activity UUID that owns the task.'), + name: name('Human-readable task name.'), + description: nullableText('Short task description.'), }) .strict(); @@ -131,23 +175,32 @@ export const createTaskSchema = taskBase.partial({ description: true }); const updateTaskFieldsSchema = taskBase.omit({ activity_id: true }).partial().strict(); -export const updateTaskSchema = updateTaskFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updateTaskSchema = updateTaskFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('task')); export const updateTaskToolSchema = updateTaskFieldsSchema - .extend({ task_id: uuid }) - .refine(({ task_id: _taskId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ task_id: uuid('Task UUID to update.') }) + .refine(({ task_id: _taskId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('task')); export const reorderTasksSchema = z .object({ - activity_id: uuid, - order: z.array(uuid).min(1, 'Order array cannot be empty'), + activity_id: uuid('Activity UUID whose tasks will be reordered.'), + order: uniqueUuidOrder( + 'Complete final ordering of every task UUID in the activity.', + 'Task UUID in final display order.', + ), }) .strict(); export const moveTaskSchema = z .object({ - target_activity_id: uuid, - target_order: z.array(uuid).min(1, 'Order array cannot be empty'), + target_activity_id: uuid('Destination activity UUID.'), + target_order: uniqueUuidOrder( + 'Complete final task ordering for the destination activity, including the moved task exactly once.', + 'Task UUID in destination display order.', + ), }) .strict(); @@ -157,10 +210,10 @@ export const moveTaskSchema = z export const storyBase = z .object({ - task_id: uuid, - release_id: uuid.nullable(), - title: z.string().min(1, 'Required').max(500), - content: storyContentSchema, + task_id: uuid('Task UUID that owns the story.'), + release_id: uuid('Release UUID for the story; null places it in the backlog.').nullable(), + title: z.string().min(1, 'Required').max(500).describe('Concise story title.'), + content: storyContentSchema.describe('Structured implementation specification for the story.'), status: storyStatus, }) .strict(); @@ -174,25 +227,34 @@ export const createStorySchema = storyBase const updateStoryFieldsSchema = storyBase.omit({ task_id: true, release_id: true }).partial().strict(); -export const updateStorySchema = updateStoryFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updateStorySchema = updateStoryFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('story')); export const updateStoryToolSchema = updateStoryFieldsSchema - .extend({ story_id: uuid }) - .refine(({ story_id: _storyId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ story_id: uuid('Story UUID to update.') }) + .refine(({ story_id: _storyId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('story')); export const reorderStoriesSchema = z .object({ - task_id: uuid, - release_id: uuid.nullable(), - order: z.array(uuid).min(1, 'Order array cannot be empty'), + task_id: uuid('Task UUID containing the story cell.'), + release_id: uuid('Release UUID for the story cell; null identifies the backlog.').nullable(), + order: uniqueUuidOrder( + 'Complete final ordering of every story UUID in this task and release cell.', + 'Story UUID in final display order.', + ), }) .strict(); export const moveStorySchema = z .object({ - target_task_id: uuid, - target_release_id: uuid.nullable(), - target_order: z.array(uuid).min(1, 'Order array cannot be empty'), + target_task_id: uuid('Destination task UUID.'), + target_release_id: uuid('Destination release UUID; null moves the story to the backlog.').nullable(), + target_order: uniqueUuidOrder( + 'Complete final story ordering for the destination cell, including the moved story exactly once.', + 'Story UUID in destination display order.', + ), }) .strict(); @@ -202,10 +264,10 @@ export const moveStorySchema = z export const personaBase = z .object({ - story_map_id: uuid, - name, - description: nullableString, - goals: nullableString, + story_map_id: uuid('Story map UUID that owns the persona.'), + name: name('Human-readable persona name.'), + description: nullableText('Persona characteristics, needs, and context.'), + goals: nullableText('Persona goals and desired outcomes.'), }) .strict(); @@ -216,11 +278,14 @@ export const createPersonaSchema = personaBase.partial({ const updatePersonaFieldsSchema = personaBase.omit({ story_map_id: true }).partial().strict(); -export const updatePersonaSchema = updatePersonaFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); +export const updatePersonaSchema = updatePersonaFieldsSchema + .refine(atLeastOneField, atLeastOneFieldMessage) + .describe(updateDescription('persona')); export const updatePersonaToolSchema = updatePersonaFieldsSchema - .extend({ persona_id: uuid }) - .refine(({ persona_id: _personaId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); + .extend({ persona_id: uuid('Persona UUID to update.') }) + .refine(({ persona_id: _personaId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage) + .describe(updateDescription('persona')); // --------------------------------------------------------------------------- // Inferred types diff --git a/src/integrations/mcp/output-schemas.ts b/src/integrations/mcp/output-schemas.ts new file mode 100644 index 0000000..0c7a23b --- /dev/null +++ b/src/integrations/mcp/output-schemas.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +/** Wrap a tool's successful data contract in the shape returned by successResult. */ +export function successOutputSchema(dataSchema: T) { + return z + .object({ + ok: z.literal(true).describe('Whether the tool call succeeded.'), + data: dataSchema.describe('Successful tool result.'), + }) + .strict(); +} + +export const mcpUuidSchema = z.string().uuid(); + +/** Database entities may gain columns, but their stable identity is always present. */ +export const databaseRowSchema = z.looseObject({ + id: mcpUuidSchema.describe('Stable entity UUID.'), +}); + +export function deletedRowSchema(rowSchema: T) { + return z.object({ deleted: rowSchema.describe('Deleted entity as it existed before deletion.') }).strict(); +} + +export const nonNegativeCountSchema = z.number().int().nonnegative(); diff --git a/src/integrations/mcp/server.test.ts b/src/integrations/mcp/server.test.ts index bac28e4..3bff3d3 100644 --- a/src/integrations/mcp/server.test.ts +++ b/src/integrations/mcp/server.test.ts @@ -5,6 +5,21 @@ import * as storymapService from '@/storymap/service'; import { createMcpAuthInfo } from './auth'; import { createBeemspecMcpHandler } from './server'; +const testIds = { + team: '10000000-0000-4000-8000-000000000000', + processFlow: '10000000-0000-4000-8000-000000000008', + processNode: '10000000-0000-4000-8000-000000000009', + otherProcessNode: '10000000-0000-4000-8000-000000000010', + processEdge: '10000000-0000-4000-8000-000000000011', + storyMap: '10000000-0000-4000-8000-000000000001', + activity: '10000000-0000-4000-8000-000000000002', + task: '10000000-0000-4000-8000-000000000003', + story: '10000000-0000-4000-8000-000000000004', + otherStory: '10000000-0000-4000-8000-000000000005', + release: '10000000-0000-4000-8000-000000000006', + persona: '10000000-0000-4000-8000-000000000007', +} as const; + const handler = createBeemspecMcpHandler(); async function handleMcpRequest( @@ -64,6 +79,9 @@ describe('mcp server', () => { ); expect(initializeResponse.status).toBe(200); + const initializePayload = (await initializeResponse.json()) as { result: { instructions?: string } }; + expect(initializePayload.result.instructions).toContain('storymap_get before structural edits'); + expect(initializePayload.result.instructions).toContain('processflow_nodes_mutate'); const initializedResponse = await handleMcpRequest( rpcRequest({ @@ -91,13 +109,18 @@ describe('mcp server', () => { const payload = (await listResponse.json()) as { result: { - tools: Array<{ name: string }>; + tools: Array<{ + name: string; + inputSchema: Record; + outputSchema?: Record; + annotations?: { idempotentHint?: boolean }; + }>; }; }; const toolNames = new Set(payload.result.tools.map((tool) => tool.name)); - expect(toolNames.has('storymap_workflow_guide')).toBe(true); - expect(toolNames.has('processflow_workflow_guide')).toBe(true); + expect(toolNames.has('storymap_workflow_guide')).toBe(false); + expect(toolNames.has('processflow_workflow_guide')).toBe(false); expect(toolNames.has('storymap_list')).toBe(true); expect(toolNames.has('processflow_list')).toBe(true); expect(toolNames.has('storymap_get')).toBe(true); @@ -105,6 +128,9 @@ describe('mcp server', () => { expect(toolNames.has('processflow_validation_get')).toBe(true); expect(toolNames.has('processflow_create')).toBe(true); expect(toolNames.has('processflow_update')).toBe(true); + expect(toolNames.has('processflow_nodes_mutate')).toBe(true); + expect(toolNames.has('processflow_edges_mutate')).toBe(true); + expect(toolNames.has('processflow_autolayout')).toBe(true); expect(toolNames.has('processflow_node_create')).toBe(true); expect(toolNames.has('processflow_edge_create')).toBe(true); expect(toolNames.has('release_get')).toBe(true); @@ -120,114 +146,12 @@ describe('mcp server', () => { expect(toolNames.has('story_mark_blocked')).toBe(false); expect(toolNames.has('story')).toBe(false); expect(toolNames.has('blocked')).toBe(false); - }); - - it('returns workflow guide content for planning sequence', async () => { - const response = await handleMcpRequest( - rpcRequest({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'storymap_workflow_guide', - arguments: {}, - }, - }), - supabase, - user, - ); - - expect(response.status).toBe(200); - - const payload = (await response.json()) as { - result: { - structuredContent: { - ok: boolean; - data: { - operating_mode: string[]; - clarification_policy: string[]; - tool_sequence: string[]; - tool_usage_rules: string[]; - implementation_principles: string[]; - update_policy: string[]; - safe_vs_unsafe_inference: { - safe_to_infer: string[]; - unsafe_to_infer: string[]; - }; - story_quality_principles: string[]; - }; - }; - }; - }; - - expect(payload.result.structuredContent.ok).toBe(true); - expect(payload.result.structuredContent.data.operating_mode[0]).toContain('product-minded implementation partner'); - expect(payload.result.structuredContent.data.clarification_policy[0]).toContain('materially change'); - expect(payload.result.structuredContent.data.tool_sequence[0]).toContain('storymap_list'); - expect(payload.result.structuredContent.data.tool_sequence[1]).toContain('storymap_get'); - expect(payload.result.structuredContent.data.tool_sequence[2]).toContain('release_get'); - expect(payload.result.structuredContent.data.tool_usage_rules.join(' ')).toContain('story map context markdown'); - expect(payload.result.structuredContent.data.clarification_policy.join(' ')).toContain('context markdown'); - expect(payload.result.structuredContent.data.safe_vs_unsafe_inference.unsafe_to_infer[0]).toContain( - 'New product scope', - ); - expect(payload.result.structuredContent.data.implementation_principles.join(' ')).toContain('Figma MCP server'); - expect(payload.result.structuredContent.data.story_quality_principles[0]).toContain('user-visible value'); - expect(payload.result.structuredContent.data.update_policy.join(' ')).toContain('metrics'); - expect(payload.result.structuredContent.data.update_policy.join(' ')).toContain('architecture document'); - expect(payload.result.structuredContent.data.update_policy.join(' ')).toContain('technical guidelines'); - }); - - it('returns process flow workflow guide content for modeling sequence', async () => { - const response = await handleMcpRequest( - rpcRequest({ - jsonrpc: '2.0', - id: 300, - method: 'tools/call', - params: { - name: 'processflow_workflow_guide', - arguments: {}, - }, - }), - supabase, - user, - ); - - expect(response.status).toBe(200); + expect(payload.result.tools.every((tool) => tool.outputSchema)).toBe(true); - const payload = (await response.json()) as { - result: { - structuredContent: { - ok: boolean; - data: { - tool_sequence: string[]; - process_modeling_principles: string[]; - operating_mode: string[]; - safe_vs_unsafe_inference: { - safe_to_infer: string[]; - unsafe_to_infer: string[]; - }; - }; - }; - }; - }; - - expect(payload.result.structuredContent.ok).toBe(true); - expect(payload.result.structuredContent.data.tool_sequence[0]).toContain('processflow_list'); - expect(payload.result.structuredContent.data.tool_sequence[1]).toContain('processflow_get'); - expect(payload.result.structuredContent.data.tool_sequence[3]).toContain('processflow_validation_get'); - expect(payload.result.structuredContent.data.process_modeling_principles.join(' ')).toContain('step nodes'); - expect(payload.result.structuredContent.data.process_modeling_principles.join(' ')).toContain( - 'Frequency times duration', - ); - expect(payload.result.structuredContent.data.process_modeling_principles.join(' ')).toContain('condition field'); - expect(payload.result.structuredContent.data.safe_vs_unsafe_inference.safe_to_infer.join(' ')).toContain( - 'high volume, multiple times per day', - ); - expect(payload.result.structuredContent.data.safe_vs_unsafe_inference.unsafe_to_infer.join(' ')).toContain( - 'do not invent compliance requirements', - ); - expect(payload.result.structuredContent.data.operating_mode[2]).toContain('operational reality'); + const byName = new Map(payload.result.tools.map((tool) => [tool.name, tool])); + expect(byName.get('story_create')?.annotations?.idempotentHint).toBe(false); + expect(byName.get('story_update')?.annotations?.idempotentHint).toBe(true); + expect(byName.get('story_delete')?.annotations?.idempotentHint).toBe(true); }); it('documents new process flow metadata fields in tool descriptions', async () => { @@ -277,8 +201,8 @@ describe('mcp server', () => { vi.spyOn(processflowService, 'getProcessFlowMcpContext').mockResolvedValue({ flowResult: { data: { - id: 'flow-1', - team_id: 'team-1', + id: testIds.processFlow, + team_id: testIds.team, name: 'Accounts Payable', description: 'Invoice intake and approval', context_markdown: null, @@ -290,8 +214,8 @@ describe('mcp server', () => { nodesResult: { data: [ { - id: 'node-1', - process_flow_id: 'flow-1', + id: testIds.processNode, + process_flow_id: testIds.processFlow, type: 'step', position: { x: 0, y: 0 }, size: null, @@ -304,8 +228,8 @@ describe('mcp server', () => { }, }, { - id: 'node-2', - process_flow_id: 'flow-1', + id: testIds.otherProcessNode, + process_flow_id: testIds.processFlow, type: 'decision', position: { x: 100, y: 0 }, size: null, @@ -317,11 +241,11 @@ describe('mcp server', () => { edgesResult: { data: [ { - id: 'edge-1', - process_flow_id: 'flow-1', + id: testIds.processEdge, + process_flow_id: testIds.processFlow, type: 'flow', - source_node_id: 'node-1', - target_node_id: 'node-2', + source_node_id: testIds.processNode, + target_node_id: testIds.otherProcessNode, data: { label: 'Review', condition: 'amount > $10,000' }, }, ], @@ -373,6 +297,126 @@ describe('mcp server', () => { ); }); + it('runs atomic process flow node batches through the MCP contract', async () => { + const deletedNode = { + id: testIds.processNode, + process_flow_id: testIds.processFlow, + type: 'step' as const, + position: { x: 0, y: 0 }, + size: null, + data: { label: 'Old step' }, + }; + const mutateSpy = vi.spyOn(processflowService, 'batchMutateProcessFlowNodes').mockResolvedValue({ + data: { created: [], updated: [], deleted: [deletedNode] }, + error: null, + }); + + const response = await handleMcpRequest( + rpcRequest({ + jsonrpc: '2.0', + id: 104, + method: 'tools/call', + params: { + name: 'processflow_nodes_mutate', + arguments: { + process_flow_id: testIds.processFlow, + mutations: [{ action: 'delete', id: testIds.processNode }], + }, + }, + }), + supabase, + user, + ); + + expect(response.status).toBe(200); + expect(mutateSpy).toHaveBeenCalledWith(supabase, { + process_flow_id: testIds.processFlow, + mutations: [{ action: 'delete', id: testIds.processNode }], + }); + const payload = (await response.json()) as { + result: { structuredContent: { ok: boolean; data: { deleted: Array<{ id: string }> } } }; + }; + expect(payload.result.structuredContent).toMatchObject({ + ok: true, + data: { deleted: [{ id: testIds.processNode }] }, + }); + }); + + it('runs atomic process flow edge batches through the MCP contract', async () => { + const mutateSpy = vi.spyOn(processflowService, 'batchMutateProcessFlowEdges').mockResolvedValue({ + data: { + created: [], + updated: [], + deleted: [ + { + id: testIds.processEdge, + process_flow_id: testIds.processFlow, + type: 'flow', + source_node_id: testIds.processNode, + target_node_id: testIds.otherProcessNode, + data: null, + }, + ], + }, + error: null, + }); + + const response = await handleMcpRequest( + rpcRequest({ + jsonrpc: '2.0', + id: 105, + method: 'tools/call', + params: { + name: 'processflow_edges_mutate', + arguments: { + process_flow_id: testIds.processFlow, + mutations: [{ action: 'delete', id: testIds.processEdge }], + }, + }, + }), + supabase, + user, + ); + + expect(response.status).toBe(200); + expect(mutateSpy).toHaveBeenCalledWith(supabase, { + process_flow_id: testIds.processFlow, + mutations: [{ action: 'delete', id: testIds.processEdge }], + }); + const payload = (await response.json()) as { + result: { structuredContent: { ok: boolean; data: { deleted: Array<{ id: string }> } } }; + }; + expect(payload.result.structuredContent.data.deleted[0].id).toBe(testIds.processEdge); + }); + + it('autolayouts a process flow through the MCP contract', async () => { + const layoutSpy = vi.spyOn(processflowService, 'autolayoutProcessFlow').mockResolvedValue({ + data: { nodes: [], edges: [] }, + error: null, + }); + + const response = await handleMcpRequest( + rpcRequest({ + jsonrpc: '2.0', + id: 106, + method: 'tools/call', + params: { + name: 'processflow_autolayout', + arguments: { process_flow_id: testIds.processFlow }, + }, + }), + supabase, + user, + ); + + expect(response.status).toBe(200); + expect(layoutSpy).toHaveBeenCalledWith(supabase, testIds.processFlow); + const payload = (await response.json()) as { + result: { structuredContent: { ok: boolean; data: { nodes: unknown[]; edges: unknown[] } } }; + }; + expect(payload.result.structuredContent).toEqual({ ok: true, data: { nodes: [], edges: [] } }); + }); + it('rejects processflow_node_update without process_flow_id', async () => { const response = await handleMcpRequest( rpcRequest({ @@ -427,27 +471,27 @@ describe('mcp server', () => { it('returns story map insights with warnings and recommendations', async () => { vi.spyOn(storymapService, 'getStoryMapMcpContext').mockResolvedValue({ mapResult: { - data: { id: 'map-1', name: 'Core Product', description: 'Primary map', context_markdown: null }, + data: { id: testIds.storyMap, name: 'Core Product', description: 'Primary map', context_markdown: null }, error: null, }, activitiesResult: { data: [ { - id: 'activity-1', - story_map_id: 'map-1', + id: testIds.activity, + story_map_id: testIds.storyMap, name: 'Frontend', description: null, sort_order: 0, tasks: [ { - id: 'task-1', - activity_id: 'activity-1', + id: testIds.task, + activity_id: testIds.activity, name: 'API integration', description: null, sort_order: 0, stories: [ { - id: 'story-1', + id: testIds.story, title: 'Build API endpoint', status: 'backlog', release_id: null, @@ -466,12 +510,19 @@ describe('mcp server', () => { }, releasesResult: { data: [ - { id: 'release-1', story_map_id: 'map-1', name: 'Release 1', description: null, context_markdown: null }, + { + id: testIds.release, + story_map_id: testIds.storyMap, + name: 'Release 1', + description: null, + context_markdown: null, + sort_order: 0, + }, ], error: null, }, personasResult: { - data: [{ id: 'persona-1', name: 'Admin', goals: 'Ship safely' }], + data: [{ id: testIds.persona, name: 'Admin', goals: 'Ship safely' }], error: null, }, } as never); @@ -508,7 +559,7 @@ describe('mcp server', () => { releaseResult: { data: { id: releaseId, - story_map_id: 'map-1', + story_map_id: testIds.storyMap, name: 'Release 1', description: 'Core scope', context_markdown: '## Focus\nShip activation', @@ -517,27 +568,32 @@ describe('mcp server', () => { error: null, }, mapResult: { - data: { id: 'map-1', name: 'Core Product', description: 'Primary map', context_markdown: '## Goal' }, + data: { + id: testIds.storyMap, + name: 'Core Product', + description: 'Primary map', + context_markdown: '## Goal', + }, error: null, }, activitiesResult: { data: [ { - id: 'activity-1', - story_map_id: 'map-1', + id: testIds.activity, + story_map_id: testIds.storyMap, name: 'Browse', description: null, sort_order: 0, tasks: [ { - id: 'task-1', - activity_id: 'activity-1', + id: testIds.task, + activity_id: testIds.activity, name: 'View rates', description: null, sort_order: 0, stories: [ { - id: 'story-1', + id: testIds.story, title: 'Show featured rates', status: 'todo', release_id: releaseId, @@ -545,7 +601,7 @@ describe('mcp server', () => { content: { edge_cases: null, figma_link: null }, }, { - id: 'story-2', + id: testIds.otherStory, title: 'Backlog story', status: 'backlog', release_id: null, @@ -590,7 +646,7 @@ describe('mcp server', () => { expect(payload.result.structuredContent.ok).toBe(true); expect(payload.result.structuredContent.data.summary.storyCount).toBe(1); expect(payload.result.structuredContent.data.activities[0].tasks[0].stories).toEqual([ - expect.objectContaining({ id: 'story-1' }), + expect.objectContaining({ id: testIds.story }), ]); }); @@ -665,12 +721,14 @@ describe('mcp server', () => { id: 'd7f34189-5d27-4dc0-b2c5-23d11796add4', title: 'Implement sign-in', status: 'backlog', - task_id: 'task-1', + task_id: testIds.task, release_id: null, content: { + user_story: 'User can sign in', acceptance_criteria: '- [ ] Sign in succeeds', figma_link: 'https://figma.com/design/abc/Test?node-id=1-2', }, + sort_order: 0, }, error: null, } as never); @@ -968,7 +1026,7 @@ describe('mcp server', () => { single: vi.fn().mockResolvedValue({ data: { id: 'd7f34189-5d27-4dc0-b2c5-23d11796add4', - task_id: 'task-1', + task_id: testIds.task, title: 'Backlog story', status: 'backlog', sort_order: 3, @@ -993,8 +1051,8 @@ describe('mcp server', () => { eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { - id: 'task-1', - activity_id: 'activity-1', + id: testIds.task, + activity_id: testIds.activity, name: 'Review backlog item', description: 'Review the story before development', sort_order: 2, @@ -1011,8 +1069,8 @@ describe('mcp server', () => { eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { - id: 'activity-1', - story_map_id: 'map-1', + id: testIds.activity, + story_map_id: testIds.storyMap, name: 'Plan work', description: 'Plan the release', sort_order: 1, @@ -1029,7 +1087,7 @@ describe('mcp server', () => { eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { - id: 'map-1', + id: testIds.storyMap, name: 'Core Product', description: 'Primary planning map', context_markdown: '## Goals\nImprove conversion', @@ -1047,7 +1105,7 @@ describe('mcp server', () => { order: vi.fn().mockResolvedValue({ data: [ { - id: 'persona-1', + id: testIds.persona, name: 'Workspace Admin', description: 'Manages the rollout', goals: 'Ship safely', @@ -1184,7 +1242,7 @@ describe('mcp server', () => { single: vi.fn().mockResolvedValue({ data: { id: storyId, - task_id: 'task-1', + task_id: testIds.task, title: 'Release story', status: 'todo', sort_order: 0, @@ -1205,7 +1263,13 @@ describe('mcp server', () => { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ - data: { id: 'task-1', activity_id: 'activity-1', name: 'Checkout', description: null, sort_order: 0 }, + data: { + id: testIds.task, + activity_id: testIds.activity, + name: 'Checkout', + description: null, + sort_order: 0, + }, error: null, }), }), @@ -1218,8 +1282,8 @@ describe('mcp server', () => { eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { - id: 'activity-1', - story_map_id: 'map-1', + id: testIds.activity, + story_map_id: testIds.storyMap, name: 'Buy', description: null, sort_order: 0, @@ -1236,7 +1300,7 @@ describe('mcp server', () => { eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { - id: 'map-1', + id: testIds.storyMap, name: 'Core Product', description: null, context_markdown: '## Product goal', diff --git a/src/integrations/mcp/server.ts b/src/integrations/mcp/server.ts index 0bb4924..568373d 100644 --- a/src/integrations/mcp/server.ts +++ b/src/integrations/mcp/server.ts @@ -1,8 +1,10 @@ import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; import { getMcpAuthContext } from '@/integrations/mcp/auth'; import type { AuthenticatedUser } from '@/lib/auth'; import type { Supabase } from '@/lib/supabase/types'; import { listTeamsForUser } from '@/lib/teams'; +import { mcpUuidSchema, successOutputSchema } from './output-schemas'; import { describeDbError, errorResult, readAnnotations, successResult, withToolErrorBoundary } from './tool-support'; import { registerPersonaTools } from './tools/personas'; import { registerPlanningTools } from './tools/planning'; @@ -10,17 +12,38 @@ import { registerProcessFlowTools } from './tools/process-flows'; import { registerStoryTools } from './tools/stories'; import { registerStoryMapTools } from './tools/story-maps'; +const MCP_SERVER_INSTRUCTIONS = [ + 'Use team_list when team context is unknown.', + 'For story-map work, use storymap_list to discover maps, storymap_get before structural edits, release_get for release scope, and story_context_get before implementing one story.', + 'Use story_update for content or status, story_move and task_move for placement, and reorder tools only with the complete final ID order.', + 'For process-flow work, use processflow_list to discover flows and processflow_get before structural edits.', + 'Prefer processflow_nodes_mutate and processflow_edges_mutate for related atomic graph changes; use single-item tools for isolated edits, then processflow_autolayout and processflow_validation_get after material structural changes.', + 'Preserve observed product and operational intent. Do not invent scope, systems, approvals, ownership, constraints, or UI behavior when stored context or a focused user clarification should decide them.', +].join(' '); + +const teamSummarySchema = z + .object({ + team_id: mcpUuidSchema.describe('Team UUID.'), + role: z.string().describe('Authenticated user role in the team.'), + name: z.string().nullable().describe('Human-readable team name.'), + }) + .strict(); + function createMcpServer(supabase: Supabase, user: AuthenticatedUser): McpServer { - const server = new McpServer({ - name: 'beemspec', - version: '0.1.0', - }); + const server = new McpServer( + { + name: 'beemspec', + version: '0.1.0', + }, + { instructions: MCP_SERVER_INSTRUCTIONS }, + ); server.registerTool( 'team_list', { title: 'List Teams', description: 'List teams available to the authenticated user. Use this when team_id is unknown.', + outputSchema: successOutputSchema(z.array(teamSummarySchema)), annotations: readAnnotations, }, withToolErrorBoundary('team_list', async () => { diff --git a/src/integrations/mcp/tool-support.test.ts b/src/integrations/mcp/tool-support.test.ts new file mode 100644 index 0000000..c95336e --- /dev/null +++ b/src/integrations/mcp/tool-support.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + createAnnotations, + describeDbError, + destructiveAnnotations, + errorResult, + successResult, + updateAnnotations, +} from './tool-support'; + +describe('MCP tool support', () => { + it('returns compact structured and text success content', () => { + const result = successResult({ id: 'item-1' }); + + expect(result.structuredContent).toEqual({ ok: true, data: { id: 'item-1' } }); + expect(result.content).toEqual([{ type: 'text', text: '{"ok":true,"data":{"id":"item-1"}}' }]); + }); + + it('returns model-visible tool errors', () => { + const result = errorResult('Unable to update item', { code: 'P0001' }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + ok: false, + error: 'Unable to update item', + details: { code: 'P0001' }, + }); + }); + + it('does not expose raw database diagnostics', () => { + expect( + describeDbError({ + code: '23505', + message: 'duplicate key violates internal_constraint_name', + details: 'Key (secret_column) already exists', + hint: 'Inspect private_table', + }), + ).toEqual({ code: '23505' }); + expect(describeDbError(new Error('connection string leaked'))).toEqual({}); + }); + + it('accurately distinguishes create, update, and destructive idempotence', () => { + expect(createAnnotations).toMatchObject({ idempotentHint: false, destructiveHint: false }); + expect(updateAnnotations).toMatchObject({ idempotentHint: true, destructiveHint: false }); + expect(destructiveAnnotations).toMatchObject({ idempotentHint: true, destructiveHint: true }); + }); +}); diff --git a/src/integrations/mcp/tool-support.ts b/src/integrations/mcp/tool-support.ts index 9ce8f51..4b44e6e 100644 --- a/src/integrations/mcp/tool-support.ts +++ b/src/integrations/mcp/tool-support.ts @@ -3,7 +3,7 @@ import type { Supabase } from '@/lib/supabase/types'; import { listTeamsForUser } from '@/lib/teams'; function jsonText(value: unknown): string { - return JSON.stringify(value, null, 2); + return JSON.stringify(value); } export function successResult(data: T) { @@ -38,19 +38,8 @@ export function isNotFound(error: unknown): boolean { } export function describeDbError(error: unknown): Record { - if (typeof error !== 'object' || !error) return { message: 'Unknown database error' }; - - const message = Reflect.get(error, 'message'); - const details = Reflect.get(error, 'details'); - const hint = Reflect.get(error, 'hint'); - const code = Reflect.get(error, 'code'); - - return { - ...(typeof message === 'string' ? { message } : {}), - ...(typeof details === 'string' ? { details } : {}), - ...(typeof hint === 'string' ? { hint } : {}), - ...(typeof code === 'string' ? { code } : {}), - }; + const code = dbCode(error); + return code ? { code } : {}; } type ToolCall = (args: Input) => Promise | ReturnType>; @@ -73,15 +62,20 @@ export const readAnnotations = { openWorldHint: false, } as const; -export const mutateAnnotations = { +export const createAnnotations = { readOnlyHint: false, idempotentHint: false, destructiveHint: false, openWorldHint: false, } as const; +export const updateAnnotations = { + ...createAnnotations, + idempotentHint: true, +} as const; + export const destructiveAnnotations = { - ...mutateAnnotations, + ...updateAnnotations, destructiveHint: true, } as const; diff --git a/src/integrations/mcp/tools/personas.ts b/src/integrations/mcp/tools/personas.ts index 5a378d3..2fee0d5 100644 --- a/src/integrations/mcp/tools/personas.ts +++ b/src/integrations/mcp/tools/personas.ts @@ -4,18 +4,64 @@ import { createPersonaSchema } from '@/domain/story-map'; import { updatePersonaToolSchema } from '@/domain/story-map/schemas'; import type { Supabase } from '@/lib/supabase/types'; import { createPersona, deletePersona, listPersonas, updatePersona } from '@/storymap/service'; +import { deletedRowSchema, mcpUuidSchema, successOutputSchema } from '../output-schemas'; import { getStoryContext } from '../queries'; import { + createAnnotations, describeDbError, destructiveAnnotations, errorResult, isNotFound, - mutateAnnotations, readAnnotations, successResult, + updateAnnotations, withToolErrorBoundary, } from '../tool-support'; +const nullableTextSchema = z.string().nullable(); +const personaRowSchema = z + .object({ + id: mcpUuidSchema, + story_map_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + goals: nullableTextSchema, + }) + .passthrough(); +const storyContextPersonaSchema = z + .object({ + id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + goals: nullableTextSchema, + }) + .passthrough(); +const storyContextSchema = z + .object({ + storyId: mcpUuidSchema, + storyTitle: z.string(), + storyStatus: z.string(), + storyMapId: mcpUuidSchema, + storyMapName: z.string(), + activityId: mcpUuidSchema, + activityName: z.string(), + taskId: mcpUuidSchema, + taskName: z.string(), + releaseId: mcpUuidSchema.nullable(), + releaseName: nullableTextSchema, + userStory: z.string(), + acceptanceCriteria: z.string(), + personas: z.array(storyContextPersonaSchema), + agentGuidance: z + .object({ + riskFlags: z.array(z.string()), + missingContext: z.array(z.string()), + verificationFocus: z.array(z.string()), + }) + .passthrough(), + }) + .passthrough(); + export function registerPersonaTools(server: McpServer, supabase: Supabase): void { const getUserScopedClient = () => supabase; server.registerTool( @@ -24,6 +70,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi title: 'List Personas', description: 'List personas attached to a story map. Prefer storymap_get if you already need full map context.', inputSchema: z.object({ story_map_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(z.array(personaRowSchema)), annotations: readAnnotations, }, withToolErrorBoundary('persona_list', async ({ story_map_id }) => { @@ -41,7 +88,8 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi title: 'Create Persona', description: 'Create a persona for a story map to capture user archetypes and goals.', inputSchema: createPersonaSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(personaRowSchema), + annotations: createAnnotations, }, withToolErrorBoundary('persona_create', async (input) => { const supabase = getUserScopedClient(); @@ -56,9 +104,10 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi 'persona_update', { title: 'Update Persona', - description: 'Update persona fields like name, description, or goals.', + description: 'Update at least one persona field such as name, description, or goals.', inputSchema: updatePersonaToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(personaRowSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('persona_update', async ({ persona_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -79,6 +128,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi title: 'Delete Persona', description: 'Destructive. Deletes a persona from the story map.', inputSchema: z.object({ persona_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(deletedRowSchema(personaRowSchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('persona_delete', async ({ persona_id }) => { @@ -101,6 +151,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi description: 'Full implementation context for one story, including workflow placement, personas, and Figma hints when present. Use after selecting a story via storymap_get.', inputSchema: z.object({ story_id: z.string().uuid().describe('BeemSpec story UUID') }).strict(), + outputSchema: successOutputSchema(storyContextSchema), annotations: readAnnotations, }, withToolErrorBoundary('story_context_get', async ({ story_id }) => { diff --git a/src/integrations/mcp/tools/planning.ts b/src/integrations/mcp/tools/planning.ts index 54b05f2..dc292fc 100644 --- a/src/integrations/mcp/tools/planning.ts +++ b/src/integrations/mcp/tools/planning.ts @@ -27,17 +27,64 @@ import { updateTask, } from '@/storymap/service'; import { buildMutationGuidance } from '../insights/story-map'; +import { deletedRowSchema, mcpUuidSchema, nonNegativeCountSchema, successOutputSchema } from '../output-schemas'; import { + createAnnotations, describeDbError, destructiveAnnotations, errorResult, isNotFound, - mutateAnnotations, successResult, + updateAnnotations, withToolErrorBoundary, } from '../tool-support'; const moveTaskToolSchema = moveTaskSchema.extend({ task_id: z.string().uuid() }); +const nullableTextSchema = z.string().nullable(); +const mutationGuidanceSchema = z + .object({ + next_recommended_reads: z.array(z.string()), + verification_hints: z.array(z.string()), + warnings: z.array(z.string()), + }) + .strict(); +const activityRowSchema = z + .object({ + id: mcpUuidSchema, + story_map_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + sort_order: z.number().int(), + }) + .passthrough(); +const taskRowSchema = z + .object({ + id: mcpUuidSchema, + activity_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + sort_order: z.number().int(), + }) + .passthrough(); +const releaseRowSchema = z + .object({ + id: mcpUuidSchema, + story_map_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + context_markdown: nullableTextSchema, + sort_order: z.number().int(), + }) + .passthrough(); +const activityMutationSchema = activityRowSchema.extend({ agent_guidance: mutationGuidanceSchema }); +const taskMutationSchema = taskRowSchema.extend({ agent_guidance: mutationGuidanceSchema }); +const releaseMutationSchema = releaseRowSchema.extend({ agent_guidance: mutationGuidanceSchema }); +const reorderedOutputSchema = z + .object({ + reordered: nonNegativeCountSchema, + agent_guidance: mutationGuidanceSchema, + }) + .passthrough(); export function registerPlanningTools(server: McpServer, supabase: Supabase): void { const getUserScopedClient = () => supabase; @@ -47,7 +94,8 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Create Activity', description: 'Create an activity column in a story map. Use IDs from storymap_get and refresh after mutation.', inputSchema: createActivitySchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(activityMutationSchema), + annotations: createAnnotations, }, withToolErrorBoundary('activity_create', async (input) => { const supabase = getUserScopedClient(); @@ -69,9 +117,11 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo 'activity_update', { title: 'Update Activity', - description: 'Update activity fields like name or description. Use reorder tools for position changes.', + description: + 'Update at least one activity field such as name or description. Use reorder tools for position changes.', inputSchema: updateActivityToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(activityMutationSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('activity_update', async ({ activity_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -98,6 +148,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Delete Activity', description: 'Destructive. Deletes an activity and all nested tasks/stories under it.', inputSchema: z.object({ activity_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(deletedRowSchema(activityRowSchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('activity_delete', async ({ activity_id }) => { @@ -118,7 +169,8 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Reorder Activities', description: 'Reorder activities in final sequence. Provide full ordered ID list from storymap_get context.', inputSchema: reorderActivitiesSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(reorderedOutputSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('activity_reorder', async ({ story_map_id, order }) => { const supabase = getUserScopedClient(); @@ -142,7 +194,8 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Create Task', description: 'Create a task under an activity. Choose target activity from storymap_get output.', inputSchema: createTaskSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(taskMutationSchema), + annotations: createAnnotations, }, withToolErrorBoundary('task_create', async (input) => { const supabase = getUserScopedClient(); @@ -164,9 +217,11 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo 'task_update', { title: 'Update Task', - description: 'Update task fields like name or description. Use move/reorder tools for position changes.', + description: + 'Update at least one task field such as name or description. Use move/reorder for placement changes.', inputSchema: updateTaskToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(taskMutationSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('task_update', async ({ task_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -193,6 +248,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Delete Task', description: 'Destructive. Deletes a task and all stories under it.', inputSchema: z.object({ task_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(deletedRowSchema(taskRowSchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('task_delete', async ({ task_id }) => { @@ -213,7 +269,8 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Reorder Tasks', description: 'Reorder tasks within an activity. Provide full ordered ID list for that activity.', inputSchema: reorderTasksSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(reorderedOutputSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('task_reorder', async ({ activity_id, order }) => { const supabase = getUserScopedClient(); @@ -237,7 +294,17 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Move Task', description: 'Atomically move a task to another activity and set full target order in one operation.', inputSchema: moveTaskToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema( + z + .object({ + moved: mcpUuidSchema, + target_activity_id: mcpUuidSchema, + target_order_size: nonNegativeCountSchema, + agent_guidance: mutationGuidanceSchema, + }) + .strict(), + ), + annotations: updateAnnotations, }, withToolErrorBoundary('task_move', async ({ task_id, ...input }) => { const supabase = getUserScopedClient(); @@ -263,7 +330,8 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Create Release', description: 'Create a release lane (row) in a story map. Useful before placing or moving stories.', inputSchema: createReleaseSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(releaseMutationSchema), + annotations: createAnnotations, }, withToolErrorBoundary('release_create', async (input) => { const supabase = getUserScopedClient(); @@ -285,9 +353,11 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo 'release_update', { title: 'Update Release', - description: 'Update release fields like name or description. Use reorder tools for position changes.', + description: + 'Update at least one release field such as name, description, or context. Use reorder for position changes.', inputSchema: updateReleaseToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(releaseMutationSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('release_update', async ({ release_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -314,6 +384,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Delete Release', description: 'Destructive. Deletes a release and stories currently assigned to that release.', inputSchema: z.object({ release_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(deletedRowSchema(releaseRowSchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('release_delete', async ({ release_id }) => { @@ -334,7 +405,8 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo title: 'Reorder Releases', description: 'Reorder release lanes in final sequence. Provide full ordered release ID list.', inputSchema: reorderReleasesSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(reorderedOutputSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('release_reorder', async ({ story_map_id, order }) => { const supabase = getUserScopedClient(); diff --git a/src/integrations/mcp/tools/process-flows.ts b/src/integrations/mcp/tools/process-flows.ts index 82b2cf5..cd996ab 100644 --- a/src/integrations/mcp/tools/process-flows.ts +++ b/src/integrations/mcp/tools/process-flows.ts @@ -1,9 +1,12 @@ import type { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; import { + batchMutateProcessFlowEdgesSchema, + batchMutateProcessFlowNodesSchema, createProcessFlowEdgeSchema, createProcessFlowNodeSchema, createProcessFlowSchema, + processFlowAutolayoutSchema, } from '@/domain/process-flow'; import { updateProcessFlowEdgeToolSchema, @@ -13,6 +16,9 @@ import { import type { AuthenticatedUser } from '@/lib/auth'; import type { Supabase } from '@/lib/supabase/types'; import { + autolayoutProcessFlow, + batchMutateProcessFlowEdges, + batchMutateProcessFlowNodes, buildProcessFlowFull, createProcessFlow, createProcessFlowEdge, @@ -29,16 +35,18 @@ import { validateProcessFlowGraph, } from '@/processflow/service'; import { buildProcessFlowAgentInsights } from '../insights/process-flow'; +import { deletedRowSchema, mcpUuidSchema, nonNegativeCountSchema, successOutputSchema } from '../output-schemas'; import { + createAnnotations, describeDbError, destructiveAnnotations, errorResult, isNotFound, - mutateAnnotations, readAnnotations, resolveAccessibleTeamId, resolveProcessFlowIdByName, successResult, + updateAnnotations, withToolErrorBoundary, } from '../tool-support'; @@ -46,76 +54,103 @@ const createProcessFlowToolSchema = createProcessFlowSchema.extend({ team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), }); +const processFlowEntitySchema = z + .object({ + id: mcpUuidSchema, + team_id: mcpUuidSchema, + name: z.string(), + description: z.string().nullable(), + context_markdown: z.string().nullable(), + viewport: z.object({ x: z.number(), y: z.number(), zoom: z.number() }).strict().nullable(), + schema_version: z.literal(1), + }) + .passthrough(); + +const processFlowNodeEntitySchema = z + .object({ + id: mcpUuidSchema, + process_flow_id: mcpUuidSchema, + type: z.enum(['step', 'decision', 'subprocess', 'actor', 'system', 'note']), + data: z.object({ label: z.string() }).passthrough(), + }) + .passthrough(); + +const processFlowEdgeEntitySchema = z + .object({ + id: mcpUuidSchema, + process_flow_id: mcpUuidSchema, + type: z.enum(['flow', 'handoff', 'exception', 'dependency']), + source_node_id: mcpUuidSchema, + target_node_id: mcpUuidSchema, + data: z.object({}).passthrough().nullable(), + }) + .passthrough(); + +const processFlowValidationSchema = z + .object({ + warnings: z.array( + z + .object({ + code: z.string(), + message: z.string(), + node_ids: z.array(z.string()).optional(), + edge_ids: z.array(z.string()).optional(), + }) + .strict(), + ), + }) + .strict(); + +const processFlowInsightsSchema = z + .object({ + nodeCountsByType: z.record(z.string(), nonNegativeCountSchema), + edgeCount: nonNegativeCountSchema, + automationCandidates: nonNegativeCountSchema, + ownershipTaggedNodes: nonNegativeCountSchema, + frequencyTaggedNodes: nonNegativeCountSchema, + timeConstrainedNodes: nonNegativeCountSchema, + labeledEdges: nonNegativeCountSchema, + conditionedEdges: nonNegativeCountSchema, + }) + .strict(); + +const processFlowContextSchema = processFlowEntitySchema.extend({ + nodes: z.array(processFlowNodeEntitySchema), + edges: z.array(processFlowEdgeEntitySchema), + agent_insights: processFlowInsightsSchema, + validation: processFlowValidationSchema, +}); + +const processFlowNodeMutationResultSchema = z + .object({ + created: z.array(processFlowNodeEntitySchema), + updated: z.array(processFlowNodeEntitySchema), + deleted: z.array(processFlowNodeEntitySchema), + }) + .strict(); + +const processFlowEdgeMutationResultSchema = z + .object({ + created: z.array(processFlowEdgeEntitySchema), + updated: z.array(processFlowEdgeEntitySchema), + deleted: z.array(processFlowEdgeEntitySchema), + }) + .strict(); + +const processFlowAutolayoutResultSchema = z + .object({ + nodes: z.array(processFlowNodeEntitySchema), + edges: z.array(processFlowEdgeEntitySchema), + }) + .strict(); + +const batchMutationAnnotations = { + ...createAnnotations, + destructiveHint: true, +} as const; + export function registerProcessFlowTools(server: McpServer, supabase: Supabase, user: AuthenticatedUser): void { const getUserScopedClient = () => supabase; - server.registerTool( - 'processflow_workflow_guide', - { - title: 'Process Flow Workflow Guide', - description: - 'CALL THIS FIRST BEFORE TOUCHING THE PROCESS FLOW. Read-first guide for agents translating user input into an operational flow.', - annotations: readAnnotations, - }, - withToolErrorBoundary('processflow_workflow_guide', async () => { - return successResult({ - objective: - 'Use BeemSpec as the structured source of truth for operational process modeling. Build clear flows from messy user input with minimal redundant calls and minimal unsafe inference.', - operating_mode: [ - 'Act as an operations-minded modeling partner, not just a note taker.', - 'Read this guide first, then fetch only the flow context needed for the current decision.', - 'Prefer representing observed operational reality before proposing automation or redesign.', - 'Do not invent systems, approvals, branches, or ownership when the source material does not support them.', - 'When context is missing, prefer one focused clarification or one explicit assumption over speculative process design.', - ], - tool_sequence: [ - '1) Call processflow_list(team_id?) to discover candidate flows when the target is unclear.', - '2) Call processflow_get(process_flow_id or process_flow_name) before structural edits.', - '3) Create or update nodes and edges in focused batches of related changes.', - '4) Call processflow_validation_get(process_flow_id) to inspect deterministic warnings after major changes.', - '5) Re-read with processflow_get(process_flow_id) only after material structural changes or when flow context has changed.', - ], - tool_usage_rules: [ - 'Call team_list when team context is unknown or the user may have access to multiple teams.', - 'Use processflow_get as the canonical read for both reasoning and verification.', - 'Use node create/update/delete tools for explicit graph changes; use edge tools for connection changes.', - 'Use processflow_validation_get to surface structural warnings, not to replace reasoning about the business process.', - 'Use processflow context markdown for durable process context such as scope, assumptions, known constraints, source interviews, and audit notes.', - 'Avoid redundant reads when the current flow context already answers the next decision.', - ], - clarification_policy: [ - 'Ask the user questions only when ambiguity would materially change the flow structure, decision logic, ownership, or automation recommendation.', - 'Do all non-blocked work first before asking clarifying questions.', - 'Bundle clarifying questions into one focused round instead of many small follow-ups.', - 'Recommend a reasonable default when asking a question and explain what would change based on the answer.', - ], - safe_vs_unsafe_inference: { - safe_to_infer: [ - 'Minor node wording cleanup that preserves the same operational meaning.', - 'Simple edge labels for clearly described yes/no style decisions when the transcript explicitly implies them.', - 'Reasonable frequency estimates when the interviewee describes volume qualitatively (e.g., "we do this constantly" can be captured as "high volume, multiple times per day").', - 'Reasonable layout choices that do not alter the process semantics.', - ], - unsafe_to_infer: [ - 'Inventing systems, teams, approvals, or exception paths that the source material never mentioned.', - 'Rewriting the real-world process into an optimized future process without making that transition explicit to the user.', - 'Assuming automation feasibility without evidence about tools, systems, or constraints.', - 'Precise numeric frequency or duration values when the source material only gives vague qualitative descriptions.', - 'Time constraints or SLAs that were not explicitly stated in the source material — do not invent compliance requirements.', - ], - }, - process_modeling_principles: [ - 'Use step nodes for concrete actions, decision nodes for branching logic, actor/system nodes when ownership or system participation matters, and note nodes only for supporting context.', - 'Keep labels short, operational, and specific.', - 'Prefer one node per meaningful operational step rather than large paragraphs inside nodes.', - 'Use handoff edges when work meaningfully changes owner, team, or system context.', - 'Capture frequency, estimated duration, and time constraints when the source material mentions them — these are high-signal for automation prioritization. Frequency times duration equals operational cost; time constraints indicate urgency and compliance pressure. Both matter for automation ROI but answer different questions.', - 'Use the condition field on decision outbound edges to record the actual branch logic separately from the display label. The label is what humans read on the diagram; the condition is the rule the automation agent needs to generate workflow logic.', - 'Treat disconnected nodes as a warning sign unless they are intentionally exploratory notes.', - ], - }); - }), - ); - server.registerTool( 'processflow_list', { @@ -125,6 +160,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, inputSchema: z .object({ team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)') }) .strict(), + outputSchema: successOutputSchema(z.array(processFlowEntitySchema)), annotations: readAnnotations, }, withToolErrorBoundary('processflow_list', async ({ team_id }) => { @@ -144,14 +180,31 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Get Process Flow', description: - 'Primary context loader. Pass process_flow_id directly, or pass process_flow_name (and optional team_id) for resolution.', + 'Primary context loader. Select exactly one lookup mode: process_flow_id, or process_flow_name with optional team_id.', inputSchema: z .object({ - process_flow_id: z.string().uuid().optional().describe('Process flow UUID'), - process_flow_name: z.string().min(1).max(200).optional().describe('Process flow name'), - team_id: z.string().uuid().optional().describe('Team UUID for disambiguating name matches'), + process_flow_id: z + .string() + .uuid() + .optional() + .describe('Exact process flow UUID; omit process_flow_name when using this lookup mode'), + process_flow_name: z + .string() + .min(1) + .max(200) + .optional() + .describe('Exact process flow name; omit process_flow_id when using this lookup mode'), + team_id: z + .string() + .uuid() + .optional() + .describe('Team UUID used only to disambiguate process_flow_name matches'), }) - .strict(), + .strict() + .refine(({ process_flow_id, process_flow_name }) => Boolean(process_flow_id) !== Boolean(process_flow_name), { + message: 'Provide exactly one of process_flow_id or process_flow_name', + }), + outputSchema: successOutputSchema(processFlowContextSchema), annotations: readAnnotations, }, withToolErrorBoundary('processflow_get', async ({ process_flow_id, process_flow_name, team_id }) => { @@ -195,6 +248,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Validate Process Flow', description: 'Return deterministic structural warnings for a process flow.', inputSchema: z.object({ process_flow_id: z.string().uuid().describe('Process flow UUID') }).strict(), + outputSchema: successOutputSchema(processFlowValidationSchema), annotations: readAnnotations, }, withToolErrorBoundary('processflow_validation_get', async ({ process_flow_id }) => { @@ -215,7 +269,8 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Create Process Flow', description: 'Create a new process flow container. team_id is optional when the user has exactly one team.', inputSchema: createProcessFlowToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(processFlowEntitySchema), + annotations: createAnnotations, }, withToolErrorBoundary('processflow_create', async (input) => { const supabase = getUserScopedClient(); @@ -241,7 +296,8 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Update Process Flow', description: 'Update process flow metadata such as name, description, context, or viewport.', inputSchema: updateProcessFlowToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(processFlowEntitySchema), + annotations: updateAnnotations, }, withToolErrorBoundary('processflow_update', async ({ process_flow_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -260,7 +316,8 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Delete Process Flow', description: 'Destructive. Deletes a process flow and all nested nodes and edges.', - inputSchema: z.object({ process_flow_id: z.string().uuid() }).strict(), + inputSchema: z.object({ process_flow_id: z.string().uuid().describe('Process flow UUID to delete') }).strict(), + outputSchema: successOutputSchema(deletedRowSchema(processFlowEntitySchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('processflow_delete', async ({ process_flow_id }) => { @@ -275,14 +332,37 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, }), ); + server.registerTool( + 'processflow_nodes_mutate', + { + title: 'Batch Mutate Process Flow Nodes', + description: + 'Atomically apply multiple related node creates, updates, or deletes to one process flow. Prefer this batch tool for coordinated graph changes; use the single-node tools for one isolated change.', + inputSchema: batchMutateProcessFlowNodesSchema, + outputSchema: successOutputSchema(processFlowNodeMutationResultSchema), + annotations: batchMutationAnnotations, + }, + withToolErrorBoundary('processflow_nodes_mutate', async (input) => { + const supabase = getUserScopedClient(); + const { data, error } = await batchMutateProcessFlowNodes(supabase, input); + if (error || !data) { + if (isNotFound(error)) return errorResult('Process flow not found'); + return errorResult('Failed to mutate process flow nodes', describeDbError(error)); + } + + return successResult(data); + }), + ); + server.registerTool( 'processflow_node_create', { title: 'Create Process Flow Node', description: - 'Create a node in a process flow. Node data fields include label, owner_role, systems, inputs, outputs, pain_points, notes, automation_opportunity, frequency, estimated_duration, and time_constraint.', + 'Create one isolated node. Prefer processflow_nodes_mutate for multiple related node changes. Node data fields include label, owner_role, systems, inputs, outputs, pain_points, notes, automation_opportunity, frequency, estimated_duration, and time_constraint.', inputSchema: createProcessFlowNodeSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(processFlowNodeEntitySchema), + annotations: createAnnotations, }, withToolErrorBoundary('processflow_node_create', async (input) => { const supabase = getUserScopedClient(); @@ -298,9 +378,10 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Update Process Flow Node', description: - 'Update a process flow node. Use this for label, ownership, metadata, position, or node data changes including systems, inputs, outputs, pain_points, notes, automation_opportunity, frequency, estimated_duration, and time_constraint.', + 'Update one isolated node. Prefer processflow_nodes_mutate for multiple related node changes. Use this for label, ownership, metadata, position, or node data changes including systems, inputs, outputs, pain_points, notes, automation_opportunity, frequency, estimated_duration, and time_constraint.', inputSchema: updateProcessFlowNodeToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(processFlowNodeEntitySchema), + annotations: updateAnnotations, }, withToolErrorBoundary('processflow_node_update', async ({ process_flow_id, node_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -319,7 +400,13 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Delete Process Flow Node', description: 'Destructive. Deletes a process flow node and any connected edges removed by cascade.', - inputSchema: z.object({ process_flow_id: z.string().uuid(), node_id: z.string().uuid() }).strict(), + inputSchema: z + .object({ + process_flow_id: z.string().uuid().describe('UUID of the process flow containing the node'), + node_id: z.string().uuid().describe('Process flow node UUID to delete'), + }) + .strict(), + outputSchema: successOutputSchema(deletedRowSchema(processFlowNodeEntitySchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('processflow_node_delete', async ({ process_flow_id, node_id }) => { @@ -334,13 +421,37 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, }), ); + server.registerTool( + 'processflow_edges_mutate', + { + title: 'Batch Mutate Process Flow Edges', + description: + 'Atomically apply multiple related edge creates, updates, or deletes to one process flow. Prefer this batch tool for coordinated graph changes; use the single-edge tools for one isolated change.', + inputSchema: batchMutateProcessFlowEdgesSchema, + outputSchema: successOutputSchema(processFlowEdgeMutationResultSchema), + annotations: batchMutationAnnotations, + }, + withToolErrorBoundary('processflow_edges_mutate', async (input) => { + const supabase = getUserScopedClient(); + const { data, error } = await batchMutateProcessFlowEdges(supabase, input); + if (error || !data) { + if (isNotFound(error)) return errorResult('Process flow not found'); + return errorResult('Failed to mutate process flow edges', describeDbError(error)); + } + + return successResult(data); + }), + ); + server.registerTool( 'processflow_edge_create', { title: 'Create Process Flow Edge', - description: 'Create an edge between two nodes in a process flow. Edge data fields include label and condition.', + description: + 'Create one isolated edge between two nodes. Prefer processflow_edges_mutate for multiple related edge changes. Edge data fields include label and condition.', inputSchema: createProcessFlowEdgeSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(processFlowEdgeEntitySchema), + annotations: createAnnotations, }, withToolErrorBoundary('processflow_edge_create', async (input) => { const supabase = getUserScopedClient(); @@ -356,9 +467,10 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Update Process Flow Edge', description: - 'Update a process flow edge. Use this for type changes or edge data updates including label and condition.', + 'Update one isolated edge. Prefer processflow_edges_mutate for multiple related edge changes. Use this for type changes or edge data updates including label and condition.', inputSchema: updateProcessFlowEdgeToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(processFlowEdgeEntitySchema), + annotations: updateAnnotations, }, withToolErrorBoundary('processflow_edge_update', async ({ process_flow_id, edge_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -377,7 +489,13 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Delete Process Flow Edge', description: 'Destructive. Deletes a process flow edge.', - inputSchema: z.object({ process_flow_id: z.string().uuid(), edge_id: z.string().uuid() }).strict(), + inputSchema: z + .object({ + process_flow_id: z.string().uuid().describe('UUID of the process flow containing the edge'), + edge_id: z.string().uuid().describe('Process flow edge UUID to delete'), + }) + .strict(), + outputSchema: successOutputSchema(deletedRowSchema(processFlowEdgeEntitySchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('processflow_edge_delete', async ({ process_flow_id, edge_id }) => { @@ -391,4 +509,26 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, return successResult({ deleted: data }); }), ); + + server.registerTool( + 'processflow_autolayout', + { + title: 'Autolayout Process Flow', + description: + 'Deterministically reposition every node in a process flow without changing graph semantics. Use after structural edits; repeating it against the same graph is safe.', + inputSchema: processFlowAutolayoutSchema, + outputSchema: successOutputSchema(processFlowAutolayoutResultSchema), + annotations: updateAnnotations, + }, + withToolErrorBoundary('processflow_autolayout', async ({ process_flow_id }) => { + const supabase = getUserScopedClient(); + const { data, error } = await autolayoutProcessFlow(supabase, process_flow_id); + if (error || !data) { + if (isNotFound(error)) return errorResult('Process flow not found'); + return errorResult('Failed to lay out process flow', describeDbError(error)); + } + + return successResult(data); + }), + ); } diff --git a/src/integrations/mcp/tools/stories.ts b/src/integrations/mcp/tools/stories.ts index d8ad2af..4d0980e 100644 --- a/src/integrations/mcp/tools/stories.ts +++ b/src/integrations/mcp/tools/stories.ts @@ -5,19 +5,88 @@ import { updateStoryToolSchema } from '@/domain/story-map/schemas'; import type { Supabase } from '@/lib/supabase/types'; import { createStory, deleteStory, getStory, moveStory, reorderStories, updateStory } from '@/storymap/service'; import { buildMutationGuidance } from '../insights/story-map'; +import { + databaseRowSchema, + deletedRowSchema, + mcpUuidSchema, + nonNegativeCountSchema, + successOutputSchema, +} from '../output-schemas'; import { getStoryContext } from '../queries'; import { + createAnnotations, describeDbError, destructiveAnnotations, errorResult, isNotFound, - mutateAnnotations, readAnnotations, successResult, + updateAnnotations, withToolErrorBoundary, } from '../tool-support'; const moveStoryToolSchema = moveStorySchema.extend({ story_id: z.string().uuid() }); +const nullableTextSchema = z.string().nullable(); +const storyContentOutputSchema = z + .object({ + user_story: z.string(), + acceptance_criteria: z.string(), + figma_link: nullableTextSchema.optional(), + edge_cases: nullableTextSchema.optional(), + technical_guidelines: nullableTextSchema.optional(), + }) + .passthrough(); +const storyRowSchema = z + .object({ + id: mcpUuidSchema, + task_id: mcpUuidSchema, + release_id: mcpUuidSchema.nullable(), + title: z.string(), + status: z.string(), + sort_order: z.number().int(), + content: storyContentOutputSchema, + }) + .passthrough(); +const mutationGuidanceSchema = z + .object({ + next_recommended_reads: z.array(z.string()), + verification_hints: z.array(z.string()), + warnings: z.array(z.string()), + }) + .strict(); +const storyContextSchema = z + .object({ + storyId: mcpUuidSchema, + storyTitle: z.string(), + storyStatus: z.string(), + storyMapId: mcpUuidSchema, + storyMapName: z.string(), + activityId: mcpUuidSchema, + activityName: z.string(), + taskId: mcpUuidSchema, + taskName: z.string(), + releaseId: mcpUuidSchema.nullable(), + releaseName: nullableTextSchema, + userStory: z.string(), + acceptanceCriteria: z.string(), + personas: z.array( + z + .object({ + id: mcpUuidSchema, + name: z.string(), + }) + .passthrough(), + ), + agentGuidance: z + .object({ + riskFlags: z.array(z.string()), + missingContext: z.array(z.string()), + verificationFocus: z.array(z.string()), + }) + .passthrough(), + }) + .passthrough(); +const storyMutationSchema = storyRowSchema.extend({ agent_guidance: mutationGuidanceSchema }); export function registerStoryTools(server: McpServer, supabase: Supabase): void { const getUserScopedClient = () => supabase; @@ -27,6 +96,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void title: 'Get Story', description: 'Load one story by ID when full map context is not required.', inputSchema: z.object({ story_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(storyRowSchema.extend({ agent_context: storyContextSchema.nullable() })), annotations: readAnnotations, }, withToolErrorBoundary('story_get', async ({ story_id }) => { @@ -51,7 +121,8 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void title: 'Create Story', description: 'Create a story in a task/release cell. Requires task_id and structured story content.', inputSchema: createStorySchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(storyMutationSchema), + annotations: createAnnotations, }, withToolErrorBoundary('story_create', async (input) => { const supabase = getUserScopedClient(); @@ -75,9 +146,10 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void { title: 'Update Story', description: - 'Update story fields like title, status, or content. Use story_move for task/release placement changes.', + 'Update at least one story field such as title, status, or content. Use story_move for placement changes.', inputSchema: updateStoryToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(storyMutationSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('story_update', async ({ story_id, ...changes }) => { const supabase = getUserScopedClient(); @@ -105,6 +177,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void title: 'Delete Story', description: 'Destructive. Deletes a story from the map.', inputSchema: z.object({ story_id: z.string().uuid() }).strict(), + outputSchema: successOutputSchema(deletedRowSchema(databaseRowSchema)), annotations: destructiveAnnotations, }, withToolErrorBoundary('story_delete', async ({ story_id }) => { @@ -126,7 +199,17 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void title: 'Reorder Stories', description: 'Reorder stories within a specific task+release cell using a full ordered story ID list.', inputSchema: reorderStoriesSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema( + z + .object({ + reordered: nonNegativeCountSchema, + task_id: mcpUuidSchema, + release_id: mcpUuidSchema.nullable(), + agent_guidance: mutationGuidanceSchema, + }) + .strict(), + ), + annotations: updateAnnotations, }, withToolErrorBoundary('story_reorder', async ({ task_id, release_id, order }) => { const supabase = getUserScopedClient(); @@ -152,7 +235,18 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void title: 'Move Story', description: 'Atomically move a story to another task/release cell and set full target order in one operation.', inputSchema: moveStoryToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema( + z + .object({ + moved: mcpUuidSchema, + target_task_id: mcpUuidSchema, + target_release_id: mcpUuidSchema.nullable(), + target_order_size: nonNegativeCountSchema, + agent_guidance: mutationGuidanceSchema, + }) + .strict(), + ), + annotations: updateAnnotations, }, withToolErrorBoundary('story_move', async ({ story_id, ...input }) => { const supabase = getUserScopedClient(); diff --git a/src/integrations/mcp/tools/story-maps.ts b/src/integrations/mcp/tools/story-maps.ts index 7e7f2e7..bc3dd1e 100644 --- a/src/integrations/mcp/tools/story-maps.ts +++ b/src/integrations/mcp/tools/story-maps.ts @@ -17,15 +17,17 @@ import { filterActivitiesForRelease, toStoryPlanningRef, } from '../insights/story-map'; +import { mcpUuidSchema, successOutputSchema } from '../output-schemas'; import { + createAnnotations, describeDbError, errorResult, isNotFound, - mutateAnnotations, readAnnotations, resolveAccessibleTeamId, resolveStoryMapIdByName, successResult, + updateAnnotations, withToolErrorBoundary, } from '../tool-support'; @@ -33,135 +35,134 @@ const createStoryMapToolSchema = createStoryMapSchema.extend({ team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), }); +const nullableTextSchema = z.string().nullable(); +const storyMapRowSchema = z + .object({ + id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema.optional(), + context_markdown: nullableTextSchema.optional(), + }) + .passthrough(); +const releaseRowSchema = z + .object({ + id: mcpUuidSchema, + story_map_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + context_markdown: nullableTextSchema, + sort_order: z.number().int(), + }) + .passthrough(); +const personaRowSchema = z + .object({ + id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema.optional(), + goals: nullableTextSchema.optional(), + }) + .passthrough(); +const storyPlanningRefSchema = z + .object({ + id: mcpUuidSchema, + title: z.string(), + status: z.string(), + release_id: mcpUuidSchema.nullable(), + has_figma_link: z.boolean(), + has_edge_cases: z.boolean(), + }) + .strict(); +const taskContextSchema = z + .object({ + id: mcpUuidSchema, + activity_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + sort_order: z.number().int(), + stories: z.array(storyPlanningRefSchema), + }) + .passthrough(); +const activityContextSchema = z + .object({ + id: mcpUuidSchema, + story_map_id: mcpUuidSchema, + name: z.string(), + description: nullableTextSchema, + sort_order: z.number().int(), + tasks: z.array(taskContextSchema), + }) + .passthrough(); +const storyMapInsightsSchema = z + .object({ + map_summary: z + .object({ + storyMapId: mcpUuidSchema, + storyMapName: z.string(), + activityCount: z.number().int().nonnegative(), + taskCount: z.number().int().nonnegative(), + releaseCount: z.number().int().nonnegative(), + personaCount: z.number().int().nonnegative(), + storyCount: z.number().int().nonnegative(), + }) + .passthrough(), + top_risk_flags: z.array(z.string()), + story_mapping_warnings: z.array(z.string()), + recommended_next_actions: z.array(z.string()), + }) + .passthrough(); +const storyMapContextSchema = storyMapRowSchema.extend({ + activities: z.array(activityContextSchema), + releases: z.array(releaseRowSchema), + planning_lanes: z.array( + z + .object({ + releaseId: mcpUuidSchema.nullable(), + releaseName: z.string(), + }) + .strict(), + ), + personas: z.array(personaRowSchema), + agent_insights: storyMapInsightsSchema, +}); +const releaseContextSchema = z + .object({ + release: releaseRowSchema, + story_map: storyMapRowSchema, + activities: z.array(activityContextSchema), + summary: z + .object({ + storyCount: z.number().int().nonnegative(), + unfinishedCount: z.number().int().nonnegative(), + storiesWithFigmaCount: z.number().int().nonnegative(), + storiesMissingEdgeCasesCount: z.number().int().nonnegative(), + }) + .strict(), + warnings: z.array(z.string()), + }) + .strict(); + +const storyMapLookupSchema = z + .object({ + story_map_id: z.string().uuid().optional().describe('Story map UUID; mutually exclusive with story_map_name'), + story_map_name: z + .string() + .min(1) + .max(200) + .optional() + .describe('Exact story map name; mutually exclusive with story_map_id'), + team_id: z + .string() + .uuid() + .optional() + .describe('Only used with story_map_name to disambiguate identical names across accessible teams'), + }) + .strict() + .refine((input) => Boolean(input.story_map_id) !== Boolean(input.story_map_name), { + message: 'Provide exactly one of story_map_id or story_map_name', + }); + export function registerStoryMapTools(server: McpServer, supabase: Supabase, user: AuthenticatedUser): void { const getUserScopedClient = () => supabase; - server.registerTool( - 'storymap_workflow_guide', - { - title: 'Story Map Workflow Guide', - description: - 'CALL THIS FIRST BEFORE TOUCHING THE STORYMAP. Read-first guide for agents to plan minimal tool calls before any edits.', - annotations: readAnnotations, - }, - withToolErrorBoundary('storymap_workflow_guide', async () => { - return successResult({ - objective: - 'Use BeemSpec as the planning source of truth, act like an expert product-minded implementation partner, and make careful decisions with minimal redundant MCP calls or unnecessary user interruptions.', - operating_mode: [ - 'Act as a product-minded implementation partner, not just a code generator.', - 'Read this guide first, then fetch only the map or story context needed for the current decision.', - 'Preserve the intent of the story map, release slice, and selected story while making local implementation decisions.', - 'Do not invent product decisions when the map, story, release, personas, or linked design should answer them.', - 'When context is missing, prefer a focused clarification or explicit assumption over a risky product decision.', - ], - tool_sequence: [ - '1) Call storymap_list(team_id?) to discover candidate maps (team_id optional when user has one team).', - '2) Call storymap_get(story_map_id) to load story map context, backbone structure, release list, and lightweight story references.', - '3) Call release_get(release_id) when you need release-level context, release-scope review, or the stories inside one release.', - '4) If implementing or deeply refining one story, call story_context_get(story_id) for story-level coding and design context.', - '5) Perform targeted create/update/move/reorder/delete operations as needed.', - '6) Re-read with storymap_get(story_map_id) only after a structural mutation batch or when release planning context has changed.', - ], - tool_usage_rules: [ - 'Call team_list when team context is unknown or the user may have access to multiple teams.', - 'Call storymap_get before structural edits or release planning so you can preserve activity, task, story, and release ordering.', - 'Use release_get when a release has its own context, goals, or review questions and you do not need full story context for every story.', - 'Use story_update for content or status changes; use story_move/task_move for placement changes; use *_reorder only when you already know the full ordered ID list.', - 'Use story_context_get only when one story needs full implementation context, including workflow placement, personas, map context, release context, and any Figma link.', - 'Avoid redundant reads: if you already have the needed story or map context in the current session, continue working instead of re-fetching it.', - 'Treat story map context markdown as the place for durable product context such as higher-level goals, business context, success criteria, key metrics, and prioritization guardrails.', - 'Treat release context markdown as the place for release-specific goals, success criteria, scope guidance, business focus, and technical constraints that apply across stories in that release.', - 'Treat inferred user stories, acceptance criteria, personas, and release plans as drafts unless the user explicitly asks you to synthesize them.', - ], - clarification_policy: [ - 'Ask the user questions only when ambiguity would materially change implementation, acceptance criteria, release choice, or user-visible behavior.', - 'Do all non-blocked work first before asking clarifying questions.', - 'Bundle clarifying questions into one focused round instead of asking multiple tiny follow-ups.', - 'Ask at most one bundled clarification round unless new blockers appear later.', - 'When asking a question, recommend a reasonable default and explain what would change based on the answer.', - 'Do not ask for information that is already available in the story map, story context, personas, release lane, or linked Figma design.', - 'When durable product or release guidance is missing, suggest capturing it in story map or release context markdown instead of repeating it ad hoc in chat.', - ], - safe_vs_unsafe_inference: { - safe_to_infer: [ - 'Reasonable implementation details that do not change the user-visible behavior or product scope.', - 'Small coding decisions that follow established repository conventions, existing architecture, or linked design patterns.', - 'Thin sequencing choices inside an already-defined story when acceptance criteria remain satisfied.', - ], - unsafe_to_infer: [ - 'New product scope, success criteria, or release commitments that are not supported by the map.', - 'User-visible behavior choices when the story, acceptance criteria, or design could lead to meaningfully different outcomes.', - 'Missing UX decisions when a Figma link exists or when a UI choice could affect the workflow, accessibility, or acceptance criteria.', - 'Architectural or data-model changes that create irreversible constraints without clear story support.', - ], - }, - story_mapping_principles: [ - 'Keep the backbone as user workflow steps in narrative order, not engineering components or team ownership lanes.', - 'Place tasks under activities as user tasks, then slice stories into thin end-to-end increments that deliver observable value.', - 'Use releases as usable learning increments or backlog separation, not internal implementation phases.', - 'Keep personas lightweight and only use them when they materially change workflow, story selection, or acceptance criteria.', - 'If activity or task names read like frontend/backend/database/components, treat that as a warning that the map may be organized around implementation structure instead of user workflow.', - 'If a story title sounds like an implementation task rather than a user-visible outcome, preserve the data but flag the issue to the user before broadening execution.', - 'If a story appears too broad, prefer suggesting a thinner end-to-end slice rather than implementing a wide batch of loosely related work.', - 'If releases look like internal phases instead of usable increments, preserve the current structure unless asked to reorganize, but call out the planning risk.', - ], - story_quality_principles: [ - 'Title expresses user-visible value or outcome, not just an implementation task.', - 'User story explains who wants what and why.', - 'Acceptance criteria are specific, observable, and testable.', - 'Story, release, and map-level context should make it clear how the work supports broader goals, success criteria, or business priorities when that context matters.', - 'Edge cases and technical guidelines are included when they materially reduce ambiguity or implementation risk.', - 'If a story lacks enough context to tell what user-visible outcome should change, treat it as underspecified.', - 'If the story is implementation-ready, proceed decisively rather than asking the user to reconfirm obvious next steps.', - ], - implementation_principles: [ - 'release_id = null means backlog. Do not invent releases prematurely when backlog is the more honest state.', - 'When building an entire release, use release_get to inspect release context and the stories in that release before choosing implementation order.', - 'Favor a walking skeleton or critical-path release slice before adding breadth, polish, or component completeness.', - 'Prefer implementing the minimum set of stories that makes the release usable, learnable, or testable in the hands of a user.', - 'Check nearby stories in the same release before coding so you preserve the release intent instead of optimizing one story in isolation.', - 'If the release contains both critical-path and polish stories, implement the critical-path stories first unless the user explicitly chooses otherwise.', - 'If a Figma link is present, treat it as required design context for UI work.', - 'If the acceptance criteria are vague, non-observable, or only describe implementation, pause and clarify before committing to broad execution.', - 'If the story includes meaningful risk areas such as auth, payments, destructive actions, migrations, or permissions, missing edge cases should raise caution.', - 'When a story includes a figma_link, prefer using the Figma MCP server if it is connected to the current agent session.', - 'Use figma_get_design_context first when possible, then figma_get_screenshot if a visual check is still needed.', - 'Do not invent UI details that the linked design can answer directly.', - 'Use story_context_get for the selected story before implementation when the story is the main unit of work.', - 'Implement carefully and thoughtfully: satisfy the story, preserve release intent, and avoid accidental scope expansion.', - 'Keep implementation aligned to the acceptance criteria and avoid solving adjacent problems unless they are required to satisfy the story.', - 'Prefer changes that are easy to verify, easy to explain, and easy to adjust if the story evolves.', - 'When technical guidelines exist, treat them as constraints unless they clearly conflict with repository reality and need user clarification.', - 'When UI work is involved and design context is incomplete, be conservative and explicit about assumptions.', - ], - update_policy: [ - 'If you refine scope, acceptance criteria, or story wording during discussion, update the relevant BeemSpec entities so the map stays trustworthy.', - 'If you create an architecture document or other synthesized planning artifact from the story map and new decisions are made, ask the user whether those decisions should be reflected back into BeemSpec before the session ends.', - 'When asking about BeemSpec follow-through after decisions, explicitly check whether release context, story context, acceptance criteria, edge cases, or technical guidelines should be updated.', - 'If the conversation reveals durable product goals, release goals, metrics, success criteria, or business context that should guide future work, suggest capturing them in story map or release context markdown.', - 'Use move and reorder operations instead of delete-and-recreate when preserving history and ordering matters.', - 'Keep newly synthesized planning content clearly framed as draft unless the user asked you to formalize it.', - ], - anti_patterns: [ - 'Do not behave like a passive code executor that ignores release intent, personas, or workflow context.', - 'Do not ask the user to reconfirm obvious next steps when the map already provides enough direction.', - 'Do not reorganize a story map around frontend/backend/database components.', - 'Do not create giant unsliced stories when thinner end-to-end slices are possible.', - 'Do not delete and recreate entities when move/update preserves history and ordering more safely.', - 'Do not treat personas as decorative metadata if they do not change decisions.', - 'Do not invent product decisions or UI details when BeemSpec context or linked design should answer them.', - ], - verification_checklist: [ - 'Backbone still reads as a coherent user journey from left to right.', - 'Task ordering within each activity still matches workflow order.', - 'Stories are in the correct task and release cell, including backlog vs named release.', - 'Release rows still represent usable increments rather than internal implementation phases.', - 'Any story with a figma_link has enough design context for implementation.', - ], - }); - }), - ); - server.registerTool( 'storymap_list', { @@ -171,6 +172,7 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use inputSchema: z .object({ team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)') }) .strict(), + outputSchema: successOutputSchema(z.array(storyMapRowSchema)), annotations: readAnnotations, }, withToolErrorBoundary('storymap_list', async ({ team_id }) => { @@ -193,14 +195,9 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use { title: 'Get Story Map', description: - 'Primary context loader. Pass story_map_id directly, or pass story_map_name (and optional team_id) for resolution.', - inputSchema: z - .object({ - story_map_id: z.string().uuid().optional().describe('Story map UUID'), - story_map_name: z.string().min(1).max(200).optional().describe('Story map name'), - team_id: z.string().uuid().optional().describe('Team UUID for disambiguating name matches'), - }) - .strict(), + 'Primary context loader. Select exactly one story map by UUID or exact name; team_id only disambiguates name lookup.', + inputSchema: storyMapLookupSchema, + outputSchema: successOutputSchema(storyMapContextSchema), annotations: readAnnotations, }, withToolErrorBoundary('storymap_get', async ({ story_map_id, story_map_name, team_id }) => { @@ -275,6 +272,7 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use description: 'Load one release with release context and lightweight story references. Use this for release planning and scope review.', inputSchema: z.object({ release_id: z.string().uuid().describe('Release UUID') }).strict(), + outputSchema: successOutputSchema(releaseContextSchema), annotations: readAnnotations, }, withToolErrorBoundary('release_get', async ({ release_id }) => { @@ -330,7 +328,8 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use description: 'Create a new story map container. team_id is optional when user has exactly one team. Call storymap_get afterward for full context.', inputSchema: createStoryMapToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(storyMapRowSchema), + annotations: createAnnotations, }, withToolErrorBoundary('storymap_create', async (input) => { const supabase = getUserScopedClient(); @@ -354,9 +353,10 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use { title: 'Update Story Map', description: - 'Update story map metadata (name/description). Re-read with storymap_get to continue planning safely.', + 'Update one or more story map metadata fields. At least one change is required; re-read with storymap_get when continuing planning.', inputSchema: updateStoryMapToolSchema, - annotations: mutateAnnotations, + outputSchema: successOutputSchema(storyMapRowSchema), + annotations: updateAnnotations, }, withToolErrorBoundary('storymap_update', async ({ story_map_id, ...changes }) => { const supabase = getUserScopedClient();