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',
|
linkNotes: 'notes.relationships.link',
|
||||||
extractTasks: 'notes.tasks.extract',
|
extractTasks: 'notes.tasks.extract',
|
||||||
attachArtifact: 'notes.artifacts.attach',
|
attachArtifact: 'notes.artifacts.attach',
|
||||||
|
suggestTags: 'notes.intelligence.suggest_tags',
|
||||||
|
checkDuplicates: 'notes.intelligence.check_duplicates',
|
||||||
|
suggestLinks: 'notes.intelligence.suggest_links',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']);
|
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 ListNotesToolInput = z.infer<typeof ListNotesToolInputSchema>;
|
||||||
export type GetNoteToolInput = z.infer<typeof GetNoteToolInputSchema>;
|
export type GetNoteToolInput = z.infer<typeof GetNoteToolInputSchema>;
|
||||||
export type SearchNotesToolInput = z.infer<typeof SearchNotesToolInputSchema>;
|
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.linkNotes,
|
||||||
NOTES_MCP_TOOL_NAMES.extractTasks,
|
NOTES_MCP_TOOL_NAMES.extractTasks,
|
||||||
NOTES_MCP_TOOL_NAMES.attachArtifact,
|
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,
|
NotesMcpToolDefinitions,
|
||||||
SearchNotesToolOutputSchema,
|
SearchNotesToolOutputSchema,
|
||||||
UpdateNoteToolOutputSchema,
|
UpdateNoteToolOutputSchema,
|
||||||
|
SuggestTagsToolOutputSchema,
|
||||||
|
CheckDuplicatesToolOutputSchema,
|
||||||
|
SuggestLinksToolOutputSchema,
|
||||||
|
SmartActionMcpToolDefinitions,
|
||||||
type AttachArtifactToolInput,
|
type AttachArtifactToolInput,
|
||||||
type CreateNoteDraftToolInput,
|
type CreateNoteDraftToolInput,
|
||||||
type ExtractTasksToolInput,
|
type ExtractTasksToolInput,
|
||||||
@ -31,7 +35,12 @@ import {
|
|||||||
type NoteToolRole,
|
type NoteToolRole,
|
||||||
type SearchNotesToolInput,
|
type SearchNotesToolInput,
|
||||||
type UpdateNoteToolInput,
|
type UpdateNoteToolInput,
|
||||||
|
type SuggestTagsToolInput,
|
||||||
|
type CheckDuplicatesToolInput,
|
||||||
|
type SuggestLinksToolInput,
|
||||||
} from './note-tool-contracts.js';
|
} from './note-tool-contracts.js';
|
||||||
|
import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../lib/embeddings.js';
|
||||||
|
import { llm } from '../lib/llm.js';
|
||||||
|
|
||||||
export interface NotesMcpLogger {
|
export interface NotesMcpLogger {
|
||||||
info(obj: Record<string, unknown>, msg?: string): void;
|
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<
|
export const NotesExecutableMcpTools: Array<
|
||||||
| NotesMcpTool<ListNotesToolInput>
|
| NotesMcpTool<ListNotesToolInput>
|
||||||
| NotesMcpTool<GetNoteToolInput>
|
| NotesMcpTool<GetNoteToolInput>
|
||||||
@ -518,6 +636,9 @@ export const NotesExecutableMcpTools: Array<
|
|||||||
| NotesMcpTool<LinkNotesToolInput>
|
| NotesMcpTool<LinkNotesToolInput>
|
||||||
| NotesMcpTool<ExtractTasksToolInput>
|
| NotesMcpTool<ExtractTasksToolInput>
|
||||||
| NotesMcpTool<AttachArtifactToolInput>
|
| NotesMcpTool<AttachArtifactToolInput>
|
||||||
|
| NotesMcpTool<SuggestTagsToolInput>
|
||||||
|
| NotesMcpTool<CheckDuplicatesToolInput>
|
||||||
|
| NotesMcpTool<SuggestLinksToolInput>
|
||||||
> = [
|
> = [
|
||||||
{
|
{
|
||||||
...NotesMcpToolDefinitions.list,
|
...NotesMcpToolDefinitions.list,
|
||||||
@ -551,6 +672,18 @@ export const NotesExecutableMcpTools: Array<
|
|||||||
...NotesMcpToolDefinitions.attachArtifact,
|
...NotesMcpToolDefinitions.attachArtifact,
|
||||||
execute: executeAttachArtifact,
|
execute: executeAttachArtifact,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...SmartActionMcpToolDefinitions.suggestTags,
|
||||||
|
execute: executeSuggestTags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...SmartActionMcpToolDefinitions.checkDuplicates,
|
||||||
|
execute: executeCheckDuplicates,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...SmartActionMcpToolDefinitions.suggestLinks,
|
||||||
|
execute: executeSuggestLinks,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getNotesExecutableMcpTool(name: string) {
|
export function getNotesExecutableMcpTool(name: string) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user