diff --git a/backend/src/mcp/note-tool-contracts.ts b/backend/src/mcp/note-tool-contracts.ts index 176e122..23b9e4e 100644 --- a/backend/src/mcp/note-tool-contracts.ts +++ b/backend/src/mcp/note-tool-contracts.ts @@ -10,6 +10,9 @@ export const NOTES_MCP_TOOL_NAMES = { linkNotes: 'notes.relationships.link', extractTasks: 'notes.tasks.extract', attachArtifact: 'notes.artifacts.attach', + suggestTags: 'notes.intelligence.suggest_tags', + checkDuplicates: 'notes.intelligence.check_duplicates', + suggestLinks: 'notes.intelligence.suggest_links', } as const; export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']); @@ -260,6 +263,84 @@ export const NotesMcpToolDefinitions = { }, }; +// ── Smart Action MCP tool schemas ───────────────────────────── + +export const SuggestTagsToolInputSchema = z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), +}); + +export const SuggestTagsToolOutputSchema = z.object({ + noteId: z.string(), + tags: z.array(z.string()), +}); + +export const CheckDuplicatesToolInputSchema = z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + threshold: z.coerce.number().min(0).max(1).default(0.85), + limit: z.coerce.number().int().min(1).max(20).default(5), +}); + +export const SimilarNoteSchema = z.object({ + id: z.string(), + title: z.string(), + similarity: z.number(), +}); + +export const CheckDuplicatesToolOutputSchema = z.object({ + noteId: z.string(), + duplicates: z.array(SimilarNoteSchema), +}); + +export const SuggestLinksToolInputSchema = z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + threshold: z.coerce.number().min(0).max(1).default(0.6), + limit: z.coerce.number().int().min(1).max(10).default(5), +}); + +export const SuggestLinksToolOutputSchema = z.object({ + noteId: z.string(), + suggestions: z.array(SimilarNoteSchema), +}); + +// ── Smart Action MCP tool definitions ───────────────────────── + +export const SmartActionMcpToolDefinitions = { + suggestTags: { + name: NOTES_MCP_TOOL_NAMES.suggestTags, + description: 'Use LLM to suggest 3-5 tags for a note based on its title and body.', + requiredRole: 'viewer' as const, + inputSchema: SuggestTagsToolInputSchema, + outputSchema: SuggestTagsToolOutputSchema, + readOnly: true, + }, + checkDuplicates: { + name: NOTES_MCP_TOOL_NAMES.checkDuplicates, + description: 'Check for duplicate or near-duplicate notes using embedding similarity.', + requiredRole: 'viewer' as const, + inputSchema: CheckDuplicatesToolInputSchema, + outputSchema: CheckDuplicatesToolOutputSchema, + readOnly: true, + }, + suggestLinks: { + name: NOTES_MCP_TOOL_NAMES.suggestLinks, + description: 'Suggest related notes to link based on embedding similarity, excluding already-linked notes.', + requiredRole: 'viewer' as const, + inputSchema: SuggestLinksToolInputSchema, + outputSchema: SuggestLinksToolOutputSchema, + readOnly: true, + }, +}; + +export type SuggestTagsToolInput = z.infer; +export type SuggestTagsToolOutput = z.infer; +export type CheckDuplicatesToolInput = z.infer; +export type CheckDuplicatesToolOutput = z.infer; +export type SuggestLinksToolInput = z.infer; +export type SuggestLinksToolOutput = z.infer; + export type ListNotesToolInput = z.infer; export type GetNoteToolInput = z.infer; export type SearchNotesToolInput = z.infer; diff --git a/backend/src/mcp/note-tools.test.ts b/backend/src/mcp/note-tools.test.ts index 2eabdff..a7b47a5 100644 --- a/backend/src/mcp/note-tools.test.ts +++ b/backend/src/mcp/note-tools.test.ts @@ -47,6 +47,9 @@ describe('note executable MCP tools', () => { NOTES_MCP_TOOL_NAMES.linkNotes, NOTES_MCP_TOOL_NAMES.extractTasks, NOTES_MCP_TOOL_NAMES.attachArtifact, + NOTES_MCP_TOOL_NAMES.suggestTags, + NOTES_MCP_TOOL_NAMES.checkDuplicates, + NOTES_MCP_TOOL_NAMES.suggestLinks, ]); }); diff --git a/backend/src/mcp/note-tools.ts b/backend/src/mcp/note-tools.ts index dac0a0f..af733ae 100644 --- a/backend/src/mcp/note-tools.ts +++ b/backend/src/mcp/note-tools.ts @@ -22,6 +22,10 @@ import { NotesMcpToolDefinitions, SearchNotesToolOutputSchema, UpdateNoteToolOutputSchema, + SuggestTagsToolOutputSchema, + CheckDuplicatesToolOutputSchema, + SuggestLinksToolOutputSchema, + SmartActionMcpToolDefinitions, type AttachArtifactToolInput, type CreateNoteDraftToolInput, type ExtractTasksToolInput, @@ -31,7 +35,12 @@ import { type NoteToolRole, type SearchNotesToolInput, type UpdateNoteToolInput, + type SuggestTagsToolInput, + type CheckDuplicatesToolInput, + type SuggestLinksToolInput, } from './note-tool-contracts.js'; +import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../lib/embeddings.js'; +import { llm } from '../lib/llm.js'; export interface NotesMcpLogger { info(obj: Record, msg?: string): void; @@ -509,6 +518,115 @@ async function executeAttachArtifact(args: AttachArtifactToolInput, req: NotesMc }); } +// ── Smart Action MCP tool implementations ───────────────────── + +async function executeSuggestTags(args: SuggestTagsToolInput, 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 plain = stripHtmlForEmbedding(note.body ?? ''); + const provider = llm(); + const result = await provider.chatCompletion({ + messages: [ + { role: 'system', content: 'Suggest 3-5 tags for this note. Return ONLY a JSON array of lowercase tag strings, e.g. ["tag1","tag2"]. No other text.' }, + { role: 'user', content: `Title: ${note.title}\n\n${plain.slice(0, 4000)}` }, + ], + temperature: 0.3, + maxTokens: 128, + }); + + let tags: string[] = []; + try { + tags = JSON.parse(result.content.trim()); + if (!Array.isArray(tags)) tags = []; + } catch { /* empty */ } + + return SuggestTagsToolOutputSchema.parse({ noteId: args.noteId, tags: tags.filter((t: unknown) => typeof t === 'string').slice(0, 5) }); +} + +async function executeCheckDuplicates(args: CheckDuplicatesToolInput, 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 plain = stripHtmlForEmbedding(note.body ?? ''); + const noteEmbedding = await embedText(plain); + if (!noteEmbedding) { + return CheckDuplicatesToolOutputSchema.parse({ noteId: args.noteId, duplicates: [] }); + } + + const { items: allNotes } = await listNotes(userId, PRODUCT_ID, { + workspaceId: args.workspaceId, + limit: 100, + offset: 0, + }); + + const duplicates: Array<{ id: string; title: string; similarity: number }> = []; + for (const other of allNotes) { + if (other.id === args.noteId) continue; + let otherEmb = other.embedding; + if (!otherEmb) { + const otherPlain = stripHtmlForEmbedding(other.body ?? ''); + if (otherPlain.length < 20) continue; + otherEmb = await embedText(otherPlain) ?? undefined; + } + if (!otherEmb) continue; + const sim = cosineSimilarity(noteEmbedding, otherEmb); + if (sim >= args.threshold) { + duplicates.push({ id: other.id, title: other.title, similarity: Math.round(sim * 100) / 100 }); + } + } + + duplicates.sort((a, b) => b.similarity - a.similarity); + return CheckDuplicatesToolOutputSchema.parse({ noteId: args.noteId, duplicates: duplicates.slice(0, args.limit) }); +} + +async function executeSuggestLinks(args: SuggestLinksToolInput, 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 plain = stripHtmlForEmbedding(note.body ?? ''); + const noteEmbedding = await embedText(plain); + if (!noteEmbedding) { + return SuggestLinksToolOutputSchema.parse({ noteId: args.noteId, suggestions: [] }); + } + + const { items: allNotes } = await listNotes(userId, PRODUCT_ID, { + workspaceId: args.workspaceId, + limit: 100, + offset: 0, + }); + + const existingLinks = new Set(note.links ?? []); + const suggestions: Array<{ id: string; title: string; similarity: number }> = []; + + for (const other of allNotes) { + if (other.id === args.noteId || existingLinks.has(other.id)) continue; + let otherEmb = other.embedding; + if (!otherEmb) { + const otherPlain = stripHtmlForEmbedding(other.body ?? ''); + if (otherPlain.length < 20) continue; + otherEmb = await embedText(otherPlain) ?? undefined; + } + if (!otherEmb) continue; + const sim = cosineSimilarity(noteEmbedding, otherEmb); + if (sim >= args.threshold) { + suggestions.push({ id: other.id, title: other.title, similarity: Math.round(sim * 100) / 100 }); + } + } + + suggestions.sort((a, b) => b.similarity - a.similarity); + return SuggestLinksToolOutputSchema.parse({ noteId: args.noteId, suggestions: suggestions.slice(0, args.limit) }); +} + export const NotesExecutableMcpTools: Array< | NotesMcpTool | NotesMcpTool @@ -518,6 +636,9 @@ export const NotesExecutableMcpTools: Array< | NotesMcpTool | NotesMcpTool | NotesMcpTool + | NotesMcpTool + | NotesMcpTool + | NotesMcpTool > = [ { ...NotesMcpToolDefinitions.list, @@ -551,6 +672,18 @@ export const NotesExecutableMcpTools: Array< ...NotesMcpToolDefinitions.attachArtifact, execute: executeAttachArtifact, }, + { + ...SmartActionMcpToolDefinitions.suggestTags, + execute: executeSuggestTags, + }, + { + ...SmartActionMcpToolDefinitions.checkDuplicates, + execute: executeCheckDuplicates, + }, + { + ...SmartActionMcpToolDefinitions.suggestLinks, + execute: executeSuggestLinks, + }, ]; export function getNotesExecutableMcpTool(name: string) {