feat(mcp): add Smart Action MCP tools — suggest_tags, check_duplicates, suggest_links

Phase 5 of Smart Actions Roadmap — Agent & workflow intelligence:

- Add 3 new MCP tool contracts to note-tool-contracts.ts:
  - notes.intelligence.suggest_tags: LLM-powered tag suggestion
  - notes.intelligence.check_duplicates: embedding-based duplicate detection
  - notes.intelligence.suggest_links: embedding-based related note suggestions
- Add Zod schemas and SmartActionMcpToolDefinitions export
- Implement executeSuggestTags, executeCheckDuplicates, executeSuggestLinks
- Wire all 3 into NotesExecutableMcpTools array (now 11 tools total)
- Update note-tools.test.ts to expect 11 tool names
- All 131 tests pass, typecheck clean
This commit is contained in:
saravanakumardb1 2026-04-06 08:45:38 -07:00
parent 37d7284730
commit 511c36d87e
3 changed files with 217 additions and 0 deletions

View File

@ -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<typeof SuggestTagsToolInputSchema>;
export type SuggestTagsToolOutput = z.infer<typeof SuggestTagsToolOutputSchema>;
export type CheckDuplicatesToolInput = z.infer<typeof CheckDuplicatesToolInputSchema>;
export type CheckDuplicatesToolOutput = z.infer<typeof CheckDuplicatesToolOutputSchema>;
export type SuggestLinksToolInput = z.infer<typeof SuggestLinksToolInputSchema>;
export type SuggestLinksToolOutput = z.infer<typeof SuggestLinksToolOutputSchema>;
export type ListNotesToolInput = z.infer<typeof ListNotesToolInputSchema>;
export type GetNoteToolInput = z.infer<typeof GetNoteToolInputSchema>;
export type SearchNotesToolInput = z.infer<typeof SearchNotesToolInputSchema>;

View File

@ -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,
]);
});

View File

@ -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<string, unknown>, 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<ListNotesToolInput>
| NotesMcpTool<GetNoteToolInput>
@ -518,6 +636,9 @@ export const NotesExecutableMcpTools: Array<
| NotesMcpTool<LinkNotesToolInput>
| NotesMcpTool<ExtractTasksToolInput>
| NotesMcpTool<AttachArtifactToolInput>
| NotesMcpTool<SuggestTagsToolInput>
| NotesMcpTool<CheckDuplicatesToolInput>
| NotesMcpTool<SuggestLinksToolInput>
> = [
{
...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) {