learning_ai_notes/backend/src/mcp/note-tools.ts
2026-03-10 09:12:09 -07:00

242 lines
6.6 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { PRODUCT_ID } from '../lib/product-config.js';
import { createNote, getNote, listNotes } 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 {
CreateNoteDraftToolOutputSchema,
GetNoteToolOutputSchema,
ListNotesToolOutputSchema,
NOTES_MCP_TOOL_NAMES,
NotesMcpToolDefinitions,
SearchNotesToolOutputSchema,
type CreateNoteDraftToolInput,
type GetNoteToolInput,
type ListNotesToolInput,
type NoteToolRole,
type SearchNotesToolInput,
} from './note-tool-contracts.js';
export interface NotesMcpLogger {
info(obj: Record<string, unknown>, msg?: string): void;
warn(obj: Record<string, unknown>, msg?: string): void;
error(obj: Record<string, unknown>, msg?: string): void;
debug(obj: Record<string, unknown>, msg?: string): void;
}
export interface NotesMcpRequest {
id: string;
headers: { authorization?: string | undefined };
jwtPayload?: { sub?: string; role?: string; productId?: string };
log: NotesMcpLogger;
}
export interface NotesMcpTool<TInput> {
name: string;
description: string;
requiredRole: NoteToolRole;
readOnly: boolean;
inputSchema: { parse(input: unknown): TInput };
execute(args: TInput, req: NotesMcpRequest): Promise<unknown>;
}
function requireUserId(req: NotesMcpRequest): string {
const userId = req.jwtPayload?.sub;
if (!userId) {
throw new Error('Authenticated user is required');
}
return userId;
}
function mapNoteSummary(note: NoteDoc) {
return {
id: note.id,
workspaceId: note.workspaceId,
title: note.title,
status: note.status,
updatedAt: note.updatedAt,
tags: note.tags,
};
}
function mapNote(note: NoteDoc) {
return GetNoteToolOutputSchema.parse({
id: note.id,
workspaceId: note.workspaceId,
title: note.title,
body: note.body,
status: note.status,
tags: note.tags,
links: note.links,
sourceType: note.sourceType,
sourceUri: note.sourceUri,
agentId: note.agentId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
});
}
async function executeListNotes(args: ListNotesToolInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
const result = await listNotes(userId, PRODUCT_ID, args);
return ListNotesToolOutputSchema.parse({
items: result.items.map(mapNoteSummary),
total: result.total,
limit: args.limit,
offset: args.offset,
});
}
async function executeGetNote(args: GetNoteToolInput, 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');
}
return mapNote(note);
}
function getMatchFields(note: NoteDoc, query: string): Array<'title' | 'body' | 'tags'> {
const lower = query.toLowerCase();
const fields: Array<'title' | 'body' | 'tags'> = [];
if (note.title.toLowerCase().includes(lower)) {
fields.push('title');
}
if (note.body.toLowerCase().includes(lower)) {
fields.push('body');
}
if (note.tags.some(tag => tag.toLowerCase().includes(lower))) {
fields.push('tags');
}
return fields;
}
async function executeSearchNotes(args: SearchNotesToolInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
const result = await listNotes(userId, PRODUCT_ID, {
workspaceId: args.workspaceId,
status: args.status,
tag: args.tag,
search: args.query,
limit: args.limit,
offset: args.offset,
});
return SearchNotesToolOutputSchema.parse({
query: args.query,
items: result.items.map(note => ({
...mapNoteSummary(note),
matchFields: getMatchFields(note, args.query),
})),
total: result.total,
limit: args.limit,
offset: args.offset,
});
}
function buildDraftNote(args: CreateNoteDraftToolInput, userId: string): NoteDoc {
const now = new Date().toISOString();
return {
id: randomUUID(),
productId: PRODUCT_ID,
workspaceId: args.workspaceId,
userId,
title: args.title,
body: args.body,
status: 'draft',
tags: args.tags,
links: args.links,
sourceType: args.sourceType,
sourceUri: args.sourceUri,
createdAt: now,
updatedAt: now,
createdBy: userId,
updatedBy: userId,
agentId: args.agentId,
};
}
async function recordCreateDraftAction(
note: NoteDoc,
args: CreateNoteDraftToolInput,
req: NotesMcpRequest,
userId: string
) {
const action: NoteAgentActionDoc = {
id: randomUUID(),
productId: PRODUCT_ID,
workspaceId: note.workspaceId,
userId,
noteId: note.id,
actorId: args.agentId ?? userId,
actorType: args.agentId ? 'agent' : 'human',
toolName: NOTES_MCP_TOOL_NAMES.createDraft,
actionType: 'create',
state: 'proposed',
reason: 'Created via MCP create_draft tool',
afterSummary: `${note.title}\n\n${note.body}`.slice(0, 4000),
idempotencyKey: args.idempotencyKey,
correlationId: args.correlationId ?? req.id,
workflowId: req.id,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
createdBy: userId,
updatedBy: userId,
};
await createNoteAgentAction(action);
}
async function executeCreateDraft(args: CreateNoteDraftToolInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
const draft = buildDraftNote(args, userId);
if (args.dryRun) {
return CreateNoteDraftToolOutputSchema.parse({
dryRun: true,
state: 'proposed',
note: mapNote(draft),
idempotencyKey: args.idempotencyKey,
correlationId: args.correlationId ?? req.id,
});
}
const created = await createNote(draft);
await recordCreateDraftAction(created, args, req, userId);
return CreateNoteDraftToolOutputSchema.parse({
dryRun: false,
state: 'draft',
note: mapNote(created),
idempotencyKey: args.idempotencyKey,
correlationId: args.correlationId ?? req.id,
});
}
export const NotesExecutableMcpTools: Array<
| NotesMcpTool<ListNotesToolInput>
| NotesMcpTool<GetNoteToolInput>
| NotesMcpTool<SearchNotesToolInput>
| NotesMcpTool<CreateNoteDraftToolInput>
> = [
{
...NotesMcpToolDefinitions.list,
execute: executeListNotes,
},
{
...NotesMcpToolDefinitions.get,
execute: executeGetNote,
},
{
...NotesMcpToolDefinitions.search,
execute: executeSearchNotes,
},
{
...NotesMcpToolDefinitions.createDraft,
execute: executeCreateDraft,
},
];
export function getNotesExecutableMcpTool(name: string) {
return NotesExecutableMcpTools.find(tool => tool.name === name);
}