242 lines
6.6 KiB
TypeScript
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);
|
|
}
|