diff --git a/backend/package-lock.json b/backend/package-lock.json index 9bd5d03..acfb9dc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2547,6 +2547,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/backend/src/mcp/note-tool-contracts.ts b/backend/src/mcp/note-tool-contracts.ts index 18dcb82..8bf389f 100644 --- a/backend/src/mcp/note-tool-contracts.ts +++ b/backend/src/mcp/note-tool-contracts.ts @@ -1,6 +1,12 @@ import { z } from 'zod'; export const NOTES_MCP_NAMESPACE = 'notes'; +export const NOTES_MCP_TOOL_NAMES = { + list: 'notes.notes.list', + get: 'notes.notes.get', + search: 'notes.notes.search', + createDraft: 'notes.notes.create_draft', +} as const; export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']); export type NoteToolRole = z.infer; @@ -92,7 +98,7 @@ export const CreateNoteDraftToolOutputSchema = z.object({ export const NotesMcpToolDefinitions = { list: { - name: 'notes.notes.list', + name: NOTES_MCP_TOOL_NAMES.list, description: 'List notes in a workspace with optional status and tag filters.', requiredRole: 'viewer' as const, inputSchema: ListNotesToolInputSchema, @@ -100,7 +106,7 @@ export const NotesMcpToolDefinitions = { readOnly: true, }, get: { - name: 'notes.notes.get', + name: NOTES_MCP_TOOL_NAMES.get, description: 'Get a single note by note ID and workspace scope.', requiredRole: 'viewer' as const, inputSchema: GetNoteToolInputSchema, @@ -108,7 +114,7 @@ export const NotesMcpToolDefinitions = { readOnly: true, }, search: { - name: 'notes.notes.search', + name: NOTES_MCP_TOOL_NAMES.search, description: 'Search notes in a workspace using lexical query plus optional filters.', requiredRole: 'viewer' as const, inputSchema: SearchNotesToolInputSchema, @@ -116,7 +122,7 @@ export const NotesMcpToolDefinitions = { readOnly: true, }, createDraft: { - name: 'notes.notes.create_draft', + name: NOTES_MCP_TOOL_NAMES.createDraft, description: 'Create a note draft in a workspace. Supports dry-run, idempotency, and correlation metadata.', requiredRole: 'admin' as const, inputSchema: CreateNoteDraftToolInputSchema, @@ -129,3 +135,7 @@ export type ListNotesToolInput = z.infer; export type GetNoteToolInput = z.infer; export type SearchNotesToolInput = z.infer; export type CreateNoteDraftToolInput = z.infer; +export type ListNotesToolOutput = z.infer; +export type GetNoteToolOutput = z.infer; +export type SearchNotesToolOutput = z.infer; +export type CreateNoteDraftToolOutput = z.infer; diff --git a/backend/src/mcp/note-tools.test.ts b/backend/src/mcp/note-tools.test.ts new file mode 100644 index 0000000..32b23ac --- /dev/null +++ b/backend/src/mcp/note-tools.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NOTES_MCP_TOOL_NAMES } from './note-tool-contracts.js'; + +const { listNotesMock, getNoteMock, createNoteMock, createNoteAgentActionMock } = vi.hoisted(() => ({ + listNotesMock: vi.fn(), + getNoteMock: vi.fn(), + createNoteMock: vi.fn(), + createNoteAgentActionMock: vi.fn(), +})); + +vi.mock('../lib/product-config.js', () => ({ PRODUCT_ID: 'bytelyst-notes' })); +vi.mock('../modules/notes/repository.js', () => ({ + listNotes: listNotesMock, + getNote: getNoteMock, + createNote: createNoteMock, +})); +vi.mock('../modules/note-agent-actions/repository.js', () => ({ + createNoteAgentAction: createNoteAgentActionMock, +})); + +import { NotesExecutableMcpTools, getNotesExecutableMcpTool } from './note-tools.js'; + +const req = { + id: 'req_1', + headers: {}, + jwtPayload: { sub: 'user_1', role: 'admin', productId: 'bytelyst-notes' }, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +}; + +describe('note executable MCP tools', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exposes executable tools for each core note contract', () => { + expect(NotesExecutableMcpTools.map(tool => tool.name)).toEqual([ + NOTES_MCP_TOOL_NAMES.list, + NOTES_MCP_TOOL_NAMES.get, + NOTES_MCP_TOOL_NAMES.search, + NOTES_MCP_TOOL_NAMES.createDraft, + ]); + }); + + it('lists notes through the repository', async () => { + listNotesMock.mockResolvedValue({ + items: [ + { + id: 'note_1', + productId: 'bytelyst-notes', + workspaceId: 'ws_1', + userId: 'user_1', + title: 'Draft', + body: 'Body', + status: 'draft', + tags: ['alpha'], + links: [], + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T00:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + }, + ], + total: 1, + }); + + const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.list); + const result = await tool?.execute( + tool.inputSchema.parse({ workspaceId: 'ws_1', limit: 10, offset: 0 }), + req + ); + + expect(listNotesMock).toHaveBeenCalledWith('user_1', 'bytelyst-notes', { + workspaceId: 'ws_1', + limit: 10, + offset: 0, + }); + expect(result).toMatchObject({ total: 1, limit: 10, offset: 0 }); + }); + + it('searches notes and computes match fields', async () => { + listNotesMock.mockResolvedValue({ + items: [ + { + id: 'note_1', + productId: 'bytelyst-notes', + workspaceId: 'ws_1', + userId: 'user_1', + title: 'Retention policy', + body: 'Body text', + status: 'active', + tags: ['compliance'], + links: [], + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T00:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + }, + ], + total: 1, + }); + + const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.search); + const result = await tool?.execute( + tool.inputSchema.parse({ workspaceId: 'ws_1', query: 'retention' }), + req + ); + + expect(result).toMatchObject({ + query: 'retention', + items: [{ matchFields: ['title'] }], + }); + }); + + it('gets a scoped note', async () => { + getNoteMock.mockResolvedValue({ + id: 'note_1', + productId: 'bytelyst-notes', + workspaceId: 'ws_1', + userId: 'user_1', + title: 'Note', + body: 'Body', + status: 'draft', + tags: [], + links: [], + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T00:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + }); + + const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.get); + const result = await tool?.execute( + tool.inputSchema.parse({ noteId: 'note_1', workspaceId: 'ws_1' }), + req + ); + + expect(result).toMatchObject({ id: 'note_1', workspaceId: 'ws_1' }); + }); + + it('returns a dry-run draft without persistence', async () => { + const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.createDraft); + const result = await tool?.execute( + tool.inputSchema.parse({ + workspaceId: 'ws_1', + title: 'Draft title', + body: 'Draft body', + dryRun: true, + }), + req + ); + + expect(createNoteMock).not.toHaveBeenCalled(); + expect(createNoteAgentActionMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ dryRun: true, state: 'proposed' }); + }); + + it('creates a persisted draft and audit action', async () => { + createNoteMock.mockImplementation(async (doc: unknown) => doc); + createNoteAgentActionMock.mockResolvedValue(undefined); + + const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.createDraft); + const result = await tool?.execute( + tool.inputSchema.parse({ + workspaceId: 'ws_1', + title: 'Draft title', + body: 'Draft body', + agentId: 'agent_1', + idempotencyKey: 'idem_1', + correlationId: 'corr_1', + }), + req + ); + + expect(createNoteMock).toHaveBeenCalledTimes(1); + expect(createNoteAgentActionMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + dryRun: false, + state: 'draft', + idempotencyKey: 'idem_1', + correlationId: 'corr_1', + }); + }); +}); diff --git a/backend/src/mcp/note-tools.ts b/backend/src/mcp/note-tools.ts new file mode 100644 index 0000000..dd62b82 --- /dev/null +++ b/backend/src/mcp/note-tools.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'node:crypto'; +import { PRODUCT_ID } from '../lib/product-config.js'; +import { createNote, getNote, listNotes } from '../modules/notes/repository.js'; +import type { NoteDoc } from '../modules/notes/types.js'; +import { createNoteAgentAction } from '../modules/note-agent-actions/repository.js'; +import type { NoteAgentActionDoc } from '../modules/note-agent-actions/types.js'; +import { + CreateNoteDraftToolOutputSchema, + GetNoteToolOutputSchema, + ListNotesToolOutputSchema, + NOTES_MCP_TOOL_NAMES, + NotesMcpToolDefinitions, + SearchNotesToolOutputSchema, + type CreateNoteDraftToolInput, + type GetNoteToolInput, + type ListNotesToolInput, + type NoteToolRole, + type SearchNotesToolInput, +} from './note-tool-contracts.js'; + +export interface NotesMcpLogger { + info(obj: Record, msg?: string): void; + warn(obj: Record, msg?: string): void; + error(obj: Record, msg?: string): void; + debug(obj: Record, msg?: string): void; +} + +export interface NotesMcpRequest { + id: string; + headers: { authorization?: string | undefined }; + jwtPayload?: { sub?: string; role?: string; productId?: string }; + log: NotesMcpLogger; +} + +export interface NotesMcpTool { + name: string; + description: string; + requiredRole: NoteToolRole; + readOnly: boolean; + inputSchema: { parse(input: unknown): TInput }; + execute(args: TInput, req: NotesMcpRequest): Promise; +} + +function requireUserId(req: NotesMcpRequest): string { + const userId = req.jwtPayload?.sub; + if (!userId) { + throw new Error('Authenticated user is required'); + } + return userId; +} + +function mapNoteSummary(note: NoteDoc) { + return { + id: note.id, + workspaceId: note.workspaceId, + title: note.title, + status: note.status, + updatedAt: note.updatedAt, + tags: note.tags, + }; +} + +function mapNote(note: NoteDoc) { + return GetNoteToolOutputSchema.parse({ + id: note.id, + workspaceId: note.workspaceId, + title: note.title, + body: note.body, + status: note.status, + tags: note.tags, + links: note.links, + sourceType: note.sourceType, + sourceUri: note.sourceUri, + agentId: note.agentId, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + }); +} + +async function executeListNotes(args: ListNotesToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const result = await listNotes(userId, PRODUCT_ID, args); + return ListNotesToolOutputSchema.parse({ + items: result.items.map(mapNoteSummary), + total: result.total, + limit: args.limit, + offset: args.offset, + }); +} + +async function executeGetNote(args: GetNoteToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const note = await getNote(args.noteId, args.workspaceId); + if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) { + throw new Error('Note not found'); + } + return mapNote(note); +} + +function getMatchFields(note: NoteDoc, query: string): Array<'title' | 'body' | 'tags'> { + const lower = query.toLowerCase(); + const fields: Array<'title' | 'body' | 'tags'> = []; + if (note.title.toLowerCase().includes(lower)) { + fields.push('title'); + } + if (note.body.toLowerCase().includes(lower)) { + fields.push('body'); + } + if (note.tags.some(tag => tag.toLowerCase().includes(lower))) { + fields.push('tags'); + } + return fields; +} + +async function executeSearchNotes(args: SearchNotesToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const result = await listNotes(userId, PRODUCT_ID, { + workspaceId: args.workspaceId, + status: args.status, + tag: args.tag, + search: args.query, + limit: args.limit, + offset: args.offset, + }); + return SearchNotesToolOutputSchema.parse({ + query: args.query, + items: result.items.map(note => ({ + ...mapNoteSummary(note), + matchFields: getMatchFields(note, args.query), + })), + total: result.total, + limit: args.limit, + offset: args.offset, + }); +} + +function buildDraftNote(args: CreateNoteDraftToolInput, userId: string): NoteDoc { + const now = new Date().toISOString(); + return { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + title: args.title, + body: args.body, + status: 'draft', + tags: args.tags, + links: args.links, + sourceType: args.sourceType, + sourceUri: args.sourceUri, + createdAt: now, + updatedAt: now, + createdBy: userId, + updatedBy: userId, + agentId: args.agentId, + }; +} + +async function recordCreateDraftAction( + note: NoteDoc, + args: CreateNoteDraftToolInput, + req: NotesMcpRequest, + userId: string +) { + const action: NoteAgentActionDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: note.workspaceId, + userId, + noteId: note.id, + actorId: args.agentId ?? userId, + actorType: args.agentId ? 'agent' : 'human', + toolName: NOTES_MCP_TOOL_NAMES.createDraft, + actionType: 'create', + state: 'proposed', + reason: 'Created via MCP create_draft tool', + afterSummary: `${note.title}\n\n${note.body}`.slice(0, 4000), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + workflowId: req.id, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + createdBy: userId, + updatedBy: userId, + }; + await createNoteAgentAction(action); +} + +async function executeCreateDraft(args: CreateNoteDraftToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const draft = buildDraftNote(args, userId); + + if (args.dryRun) { + return CreateNoteDraftToolOutputSchema.parse({ + dryRun: true, + state: 'proposed', + note: mapNote(draft), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); + } + + const created = await createNote(draft); + await recordCreateDraftAction(created, args, req, userId); + + return CreateNoteDraftToolOutputSchema.parse({ + dryRun: false, + state: 'draft', + note: mapNote(created), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); +} + +export const NotesExecutableMcpTools: Array< + | NotesMcpTool + | NotesMcpTool + | NotesMcpTool + | NotesMcpTool +> = [ + { + ...NotesMcpToolDefinitions.list, + execute: executeListNotes, + }, + { + ...NotesMcpToolDefinitions.get, + execute: executeGetNote, + }, + { + ...NotesMcpToolDefinitions.search, + execute: executeSearchNotes, + }, + { + ...NotesMcpToolDefinitions.createDraft, + execute: executeCreateDraft, + }, +]; + +export function getNotesExecutableMcpTool(name: string) { + return NotesExecutableMcpTools.find(tool => tool.name === name); +} diff --git a/docs/roadmaps/05_MCP_AGENT_ROADMAP.md b/docs/roadmaps/05_MCP_AGENT_ROADMAP.md index 231516d..91c8a4e 100644 --- a/docs/roadmaps/05_MCP_AGENT_ROADMAP.md +++ b/docs/roadmaps/05_MCP_AGENT_ROADMAP.md @@ -20,6 +20,7 @@ Parent: `docs/ROADMAP.md` - [x] Create note draft - [x] Workspace-scoped retrieval - [x] Define tool input/output schemas +- [x] Add product-side executable tool layer # Phase A2 — Agent Workflows @@ -57,6 +58,13 @@ Parent: `docs/ROADMAP.md` - draft creation requires `admin` - mutating draft creation supports `dryRun`, `idempotencyKey`, and `correlationId` - all core tools are explicitly workspace-scoped +- 2026-03-10 — Product-side executable MCP note tools added under `backend/src/mcp/note-tools.ts`. +- Verified behavior now includes: + - executable list/get/search handlers over the existing notes repository + - executable `create_draft` handler + - dry-run draft preview behavior + - persisted draft creation with `note-agent-actions` audit/proposal record creation + - Vitest coverage for executable MCP tools # Open Questions @@ -67,7 +75,7 @@ Parent: `docs/ROADMAP.md` # Blockers - `mcp-server` registration and product client execution wiring have not been implemented yet. -- Contract tests exist, but package install and test execution are still pending. +- Shared-server registration/auth-boundary review is still pending. # Deferred @@ -78,6 +86,6 @@ Parent: `docs/ROADMAP.md` # Done When -- [ ] MCP tools cover core note workflows +- [x] MCP tools cover core note workflows at the product-backend execution layer - [ ] Mutating tool paths are auditable and scoped - [ ] Coding agents have clear contracts for using tools safely diff --git a/docs/roadmaps/07_QA_RELEASE_DOCS_ROADMAP.md b/docs/roadmaps/07_QA_RELEASE_DOCS_ROADMAP.md index 2b264fc..d6e637a 100644 --- a/docs/roadmaps/07_QA_RELEASE_DOCS_ROADMAP.md +++ b/docs/roadmaps/07_QA_RELEASE_DOCS_ROADMAP.md @@ -13,15 +13,15 @@ Parent: `docs/ROADMAP.md` # Phase Q1 — MVP Coverage -- [ ] Backend CRUD/search test coverage +- [x] Backend CRUD/search test coverage - [ ] Web flow coverage - [ ] Mobile smoke coverage - [ ] Platform integration smoke coverage -- [ ] Artifact/task/relationship test coverage +- [x] Artifact/task/relationship test coverage # Phase Q2 — Agent Coverage -- [ ] MCP tool tests +- [x] MCP tool tests - [ ] Approval workflow tests - [ ] Audit verification tests - [ ] Regression coverage for agent-mediated note changes @@ -49,7 +49,10 @@ Parent: `docs/ROADMAP.md` - build: `cd backend && npm run build` - test: `cd backend && npm test` - typecheck: `cd backend && npm run typecheck` -- These commands are defined but not yet executed because the new backend package still needs dependency installation. +- 2026-03-10 — Backend verification materially advanced: + - `backend` `npm run typecheck` passes + - `backend` `npm test` passes + - MCP contract tests and executable MCP tool tests now pass # Open Questions @@ -58,7 +61,7 @@ Parent: `docs/ROADMAP.md` # Blockers -- Backend and web package installation/verification are still pending. +- Shared platform integration smoke coverage is still pending. # Deferred