diff --git a/backend/.env.example b/backend/.env.example index 7cadf84..2d798b1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,14 +1,14 @@ PORT=4016 HOST=0.0.0.0 NODE_ENV=development -SERVICE_NAME=bytelyst-notes-backend +SERVICE_NAME=notelett-backend CORS_ORIGIN= COSMOS_ENDPOINT= COSMOS_KEY= COSMOS_DATABASE=bytelyst JWT_SECRET= DB_PROVIDER=cosmos -PRODUCT_ID=bytelyst-notes +PRODUCT_ID=notelett PLATFORM_SERVICE_URL=http://localhost:4003 EXTRACTION_SERVICE_URL=http://localhost:4005 MCP_SERVER_URL=http://localhost:4007 diff --git a/backend/src/mcp/note-tool-contracts.ts b/backend/src/mcp/note-tool-contracts.ts index 8bf389f..176e122 100644 --- a/backend/src/mcp/note-tool-contracts.ts +++ b/backend/src/mcp/note-tool-contracts.ts @@ -6,6 +6,10 @@ export const NOTES_MCP_TOOL_NAMES = { get: 'notes.notes.get', search: 'notes.notes.search', createDraft: 'notes.notes.create_draft', + updateNote: 'notes.notes.update', + linkNotes: 'notes.relationships.link', + extractTasks: 'notes.tasks.extract', + attachArtifact: 'notes.artifacts.attach', } as const; export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']); @@ -96,6 +100,99 @@ export const CreateNoteDraftToolOutputSchema = z.object({ correlationId: z.string().optional(), }); +export const UpdateNoteToolInputSchema = z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + title: z.string().min(1).max(500).optional(), + body: z.string().min(1).max(50000).optional(), + status: z.enum(['draft', 'active', 'archived']).optional(), + tags: z.array(z.string().min(1).max(64)).optional(), + agentId: z.string().max(128).optional(), + dryRun: z.boolean().default(false), + idempotencyKey: z.string().max(255).optional(), + correlationId: z.string().max(255).optional(), +}); + +export const UpdateNoteToolOutputSchema = z.object({ + dryRun: z.boolean(), + note: GetNoteToolOutputSchema, + idempotencyKey: z.string().optional(), + correlationId: z.string().optional(), +}); + +export const LinkNotesToolInputSchema = z.object({ + workspaceId: z.string().min(1).max(128), + fromNoteId: z.string().min(1).max(128), + toNoteId: z.string().min(1).max(128), + relationshipType: z.enum(['related', 'references', 'parent', 'child', 'duplicate', 'task-source', 'artifact-source']), + agentId: z.string().max(128).optional(), + idempotencyKey: z.string().max(255).optional(), + correlationId: z.string().max(255).optional(), +}); + +export const LinkNotesToolOutputSchema = z.object({ + id: z.string(), + fromNoteId: z.string(), + toNoteId: z.string(), + relationshipType: z.string(), + idempotencyKey: z.string().optional(), + correlationId: z.string().optional(), +}); + +export const ExtractTasksToolInputSchema = z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + agentId: z.string().max(128).optional(), + dryRun: z.boolean().default(false), + idempotencyKey: z.string().max(255).optional(), + correlationId: z.string().max(255).optional(), +}); + +export const ExtractedTaskSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + status: z.enum(['open', 'in_progress', 'completed', 'canceled']), + source: z.enum(['manual', 'extracted']), +}); + +export const ExtractTasksToolOutputSchema = z.object({ + dryRun: z.boolean(), + noteId: z.string(), + tasks: z.array(ExtractedTaskSchema), + idempotencyKey: z.string().optional(), + correlationId: z.string().optional(), +}); + +export const AttachArtifactToolInputSchema = z.object({ + workspaceId: z.string().min(1).max(128), + noteId: z.string().min(1).max(128), + artifactType: z.enum(['file', 'summary', 'extraction', 'citation', 'export']), + title: z.string().min(1).max(500), + description: z.string().max(4000).optional(), + blobPath: z.string().max(2000).optional(), + contentType: z.string().max(255).optional(), + sizeBytes: z.number().int().min(0).optional(), + agentId: z.string().max(128).optional(), + dryRun: z.boolean().default(false), + idempotencyKey: z.string().max(255).optional(), + correlationId: z.string().max(255).optional(), +}); + +export const AttachArtifactToolOutputSchema = z.object({ + dryRun: z.boolean(), + artifact: z.object({ + id: z.string(), + noteId: z.string(), + artifactType: z.string(), + title: z.string(), + description: z.string().optional(), + blobPath: z.string().optional(), + }), + idempotencyKey: z.string().optional(), + correlationId: z.string().optional(), +}); + export const NotesMcpToolDefinitions = { list: { name: NOTES_MCP_TOOL_NAMES.list, @@ -129,13 +226,53 @@ export const NotesMcpToolDefinitions = { outputSchema: CreateNoteDraftToolOutputSchema, readOnly: false, }, + updateNote: { + name: NOTES_MCP_TOOL_NAMES.updateNote, + description: 'Update an existing note (title, body, status, tags). Supports dry-run, idempotency, and correlation metadata.', + requiredRole: 'admin' as const, + inputSchema: UpdateNoteToolInputSchema, + outputSchema: UpdateNoteToolOutputSchema, + readOnly: false, + }, + linkNotes: { + name: NOTES_MCP_TOOL_NAMES.linkNotes, + description: 'Create a typed relationship between two notes in the same workspace.', + requiredRole: 'admin' as const, + inputSchema: LinkNotesToolInputSchema, + outputSchema: LinkNotesToolOutputSchema, + readOnly: false, + }, + extractTasks: { + name: NOTES_MCP_TOOL_NAMES.extractTasks, + description: 'Extract actionable tasks from a note body using simple heuristics. Supports dry-run.', + requiredRole: 'admin' as const, + inputSchema: ExtractTasksToolInputSchema, + outputSchema: ExtractTasksToolOutputSchema, + readOnly: false, + }, + attachArtifact: { + name: NOTES_MCP_TOOL_NAMES.attachArtifact, + description: 'Attach an artifact (file, summary, extraction, citation, export) to a note. Supports dry-run.', + requiredRole: 'admin' as const, + inputSchema: AttachArtifactToolInputSchema, + outputSchema: AttachArtifactToolOutputSchema, + readOnly: false, + }, }; export type ListNotesToolInput = z.infer; export type GetNoteToolInput = z.infer; export type SearchNotesToolInput = z.infer; export type CreateNoteDraftToolInput = z.infer; +export type UpdateNoteToolInput = z.infer; +export type LinkNotesToolInput = z.infer; +export type ExtractTasksToolInput = z.infer; +export type AttachArtifactToolInput = z.infer; export type ListNotesToolOutput = z.infer; export type GetNoteToolOutput = z.infer; export type SearchNotesToolOutput = z.infer; export type CreateNoteDraftToolOutput = z.infer; +export type UpdateNoteToolOutput = z.infer; +export type LinkNotesToolOutput = z.infer; +export type ExtractTasksToolOutput = z.infer; +export type AttachArtifactToolOutput = z.infer; diff --git a/backend/src/mcp/note-tools.test.ts b/backend/src/mcp/note-tools.test.ts index 2084793..2eabdff 100644 --- a/backend/src/mcp/note-tools.test.ts +++ b/backend/src/mcp/note-tools.test.ts @@ -43,6 +43,10 @@ describe('note executable MCP tools', () => { NOTES_MCP_TOOL_NAMES.get, NOTES_MCP_TOOL_NAMES.search, NOTES_MCP_TOOL_NAMES.createDraft, + NOTES_MCP_TOOL_NAMES.updateNote, + NOTES_MCP_TOOL_NAMES.linkNotes, + NOTES_MCP_TOOL_NAMES.extractTasks, + NOTES_MCP_TOOL_NAMES.attachArtifact, ]); }); diff --git a/backend/src/mcp/note-tools.ts b/backend/src/mcp/note-tools.ts index c5f87b9..dac0a0f 100644 --- a/backend/src/mcp/note-tools.ts +++ b/backend/src/mcp/note-tools.ts @@ -1,22 +1,36 @@ import { randomUUID } from 'node:crypto'; import type { ZodTypeAny } from 'zod'; import { PRODUCT_ID } from '../lib/product-config.js'; -import { createNote, getNote, listNotes } from '../modules/notes/repository.js'; +import { createNote, getNote, listNotes, updateNote } 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 { createRelationship } from '../modules/note-relationships/repository.js'; +import type { NoteRelationshipDoc } from '../modules/note-relationships/types.js'; +import { createNoteTask } from '../modules/note-tasks/repository.js'; +import type { NoteTaskDoc } from '../modules/note-tasks/types.js'; +import { createNoteArtifact } from '../modules/note-artifacts/repository.js'; +import type { NoteArtifactDoc } from '../modules/note-artifacts/types.js'; import { + AttachArtifactToolOutputSchema, CreateNoteDraftToolOutputSchema, + ExtractTasksToolOutputSchema, GetNoteToolOutputSchema, + LinkNotesToolOutputSchema, ListNotesToolOutputSchema, NOTES_MCP_TOOL_NAMES, NotesMcpToolDefinitions, SearchNotesToolOutputSchema, + UpdateNoteToolOutputSchema, + type AttachArtifactToolInput, type CreateNoteDraftToolInput, + type ExtractTasksToolInput, type GetNoteToolInput, + type LinkNotesToolInput, type ListNotesToolInput, type NoteToolRole, type SearchNotesToolInput, + type UpdateNoteToolInput, } from './note-tool-contracts.js'; export interface NotesMcpLogger { @@ -221,11 +235,289 @@ async function executeCreateDraft(args: CreateNoteDraftToolInput, req: NotesMcpR }); } +async function executeUpdateNote(args: UpdateNoteToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const existing = await getNote(args.noteId, args.workspaceId); + if (!existing || existing.userId !== userId || existing.productId !== PRODUCT_ID) { + throw new Error('Note not found'); + } + + const updates: Partial = { + updatedAt: new Date().toISOString(), + updatedBy: userId, + }; + if (args.title !== undefined) updates.title = args.title; + if (args.body !== undefined) updates.body = args.body; + if (args.status !== undefined) updates.status = args.status; + if (args.tags !== undefined) updates.tags = args.tags; + + if (args.dryRun) { + const preview: NoteDoc = { ...existing, ...updates }; + return UpdateNoteToolOutputSchema.parse({ + dryRun: true, + note: mapNote(preview), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); + } + + const updated = await updateNote(args.noteId, args.workspaceId, updates); + if (!updated) throw new Error('Update failed'); + + const action: NoteAgentActionDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + noteId: args.noteId, + actorId: args.agentId ?? userId, + actorType: args.agentId ? 'agent' : 'human', + toolName: NOTES_MCP_TOOL_NAMES.updateNote, + actionType: 'update', + state: 'applied', + reason: 'Updated via MCP update tool', + afterSummary: `${updated.title}\n\n${updated.body}`.slice(0, 4000), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + workflowId: req.id, + createdAt: updates.updatedAt!, + updatedAt: updates.updatedAt!, + createdBy: userId, + updatedBy: userId, + }; + await createNoteAgentAction(action); + + return UpdateNoteToolOutputSchema.parse({ + dryRun: false, + note: mapNote(updated), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); +} + +async function executeLinkNotes(args: LinkNotesToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const now = new Date().toISOString(); + + const relationship: NoteRelationshipDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + fromNoteId: args.fromNoteId, + toNoteId: args.toNoteId, + relationshipType: args.relationshipType, + createdAt: now, + createdBy: userId, + updatedAt: now, + updatedBy: userId, + }; + + const created = await createRelationship(relationship); + + const action: NoteAgentActionDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + noteId: args.fromNoteId, + actorId: args.agentId ?? userId, + actorType: args.agentId ? 'agent' : 'human', + toolName: NOTES_MCP_TOOL_NAMES.linkNotes, + actionType: 'update', + state: 'applied', + reason: `Linked ${args.fromNoteId} → ${args.toNoteId} (${args.relationshipType})`, + afterSummary: `Relationship: ${args.relationshipType}`, + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + workflowId: req.id, + createdAt: now, + updatedAt: now, + createdBy: userId, + updatedBy: userId, + }; + await createNoteAgentAction(action); + + return LinkNotesToolOutputSchema.parse({ + id: created.id, + fromNoteId: created.fromNoteId, + toNoteId: created.toNoteId, + relationshipType: created.relationshipType, + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); +} + +function extractTasksFromBody(body: string): Array<{ title: string; description?: string }> { + const lines = body.split('\n'); + const tasks: Array<{ title: string; description?: string }> = []; + for (const line of lines) { + const trimmed = line.trim(); + const match = trimmed.match(/^[-*]\s*\[[ x]?\]\s*(.+)$/i) ?? trimmed.match(/^(?:TODO|FIXME|ACTION|TASK):\s*(.+)$/i); + if (match) { + tasks.push({ title: match[1].trim() }); + } + } + return tasks; +} + +async function executeExtractTasks(args: ExtractTasksToolInput, 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'); + } + + const extracted = extractTasksFromBody(note.body); + const now = new Date().toISOString(); + + const taskDocs: NoteTaskDoc[] = extracted.map(t => ({ + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + noteId: args.noteId, + title: t.title, + description: t.description, + status: 'open' as const, + source: 'extracted' as const, + createdAt: now, + createdBy: userId, + updatedAt: now, + updatedBy: userId, + })); + + if (!args.dryRun) { + for (const task of taskDocs) { + await createNoteTask(task); + } + + const action: NoteAgentActionDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + noteId: args.noteId, + actorId: args.agentId ?? userId, + actorType: args.agentId ? 'agent' : 'human', + toolName: NOTES_MCP_TOOL_NAMES.extractTasks, + actionType: 'extract_tasks', + state: 'applied', + reason: `Extracted ${taskDocs.length} tasks from note body`, + afterSummary: taskDocs.map(t => t.title).join('\n'), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + workflowId: req.id, + createdAt: now, + updatedAt: now, + createdBy: userId, + updatedBy: userId, + }; + await createNoteAgentAction(action); + } + + return ExtractTasksToolOutputSchema.parse({ + dryRun: args.dryRun, + noteId: args.noteId, + tasks: taskDocs.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + status: t.status, + source: t.source, + })), + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); +} + +async function executeAttachArtifact(args: AttachArtifactToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const now = new Date().toISOString(); + + const artifactDoc: NoteArtifactDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + noteId: args.noteId, + artifactType: args.artifactType, + title: args.title, + description: args.description, + blobPath: args.blobPath, + contentType: args.contentType, + sizeBytes: args.sizeBytes, + createdAt: now, + createdBy: userId, + updatedAt: now, + updatedBy: userId, + }; + + if (args.dryRun) { + return AttachArtifactToolOutputSchema.parse({ + dryRun: true, + artifact: { + id: artifactDoc.id, + noteId: artifactDoc.noteId, + artifactType: artifactDoc.artifactType, + title: artifactDoc.title, + description: artifactDoc.description, + blobPath: artifactDoc.blobPath, + }, + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); + } + + const created = await createNoteArtifact(artifactDoc); + + const action: NoteAgentActionDoc = { + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: args.workspaceId, + userId, + noteId: args.noteId, + actorId: args.agentId ?? userId, + actorType: args.agentId ? 'agent' : 'human', + toolName: NOTES_MCP_TOOL_NAMES.attachArtifact, + actionType: 'attach_citation', + state: 'applied', + reason: `Attached ${args.artifactType}: ${args.title}`, + afterSummary: `Artifact: ${args.title} (${args.artifactType})`, + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + workflowId: req.id, + createdAt: now, + updatedAt: now, + createdBy: userId, + updatedBy: userId, + }; + await createNoteAgentAction(action); + + return AttachArtifactToolOutputSchema.parse({ + dryRun: false, + artifact: { + id: created.id, + noteId: created.noteId, + artifactType: created.artifactType, + title: created.title, + description: created.description, + blobPath: created.blobPath, + }, + idempotencyKey: args.idempotencyKey, + correlationId: args.correlationId ?? req.id, + }); +} + export const NotesExecutableMcpTools: Array< | NotesMcpTool | NotesMcpTool | NotesMcpTool | NotesMcpTool + | NotesMcpTool + | NotesMcpTool + | NotesMcpTool + | NotesMcpTool > = [ { ...NotesMcpToolDefinitions.list, @@ -243,6 +535,22 @@ export const NotesExecutableMcpTools: Array< ...NotesMcpToolDefinitions.createDraft, execute: executeCreateDraft, }, + { + ...NotesMcpToolDefinitions.updateNote, + execute: executeUpdateNote, + }, + { + ...NotesMcpToolDefinitions.linkNotes, + execute: executeLinkNotes, + }, + { + ...NotesMcpToolDefinitions.extractTasks, + execute: executeExtractTasks, + }, + { + ...NotesMcpToolDefinitions.attachArtifact, + execute: executeAttachArtifact, + }, ]; export function getNotesExecutableMcpTool(name: string) {