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:
parent
37d7284730
commit
511c36d87e
@ -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>;
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user