feat(mcp): add executable note tools
This commit is contained in:
parent
90558a5537
commit
756714e67c
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@ -2547,6 +2547,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const NOTES_MCP_NAMESPACE = 'notes';
|
export const NOTES_MCP_NAMESPACE = 'notes';
|
||||||
|
export const NOTES_MCP_TOOL_NAMES = {
|
||||||
|
list: 'notes.notes.list',
|
||||||
|
get: 'notes.notes.get',
|
||||||
|
search: 'notes.notes.search',
|
||||||
|
createDraft: 'notes.notes.create_draft',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']);
|
export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']);
|
||||||
export type NoteToolRole = z.infer<typeof NoteToolRoleSchema>;
|
export type NoteToolRole = z.infer<typeof NoteToolRoleSchema>;
|
||||||
@ -92,7 +98,7 @@ export const CreateNoteDraftToolOutputSchema = z.object({
|
|||||||
|
|
||||||
export const NotesMcpToolDefinitions = {
|
export const NotesMcpToolDefinitions = {
|
||||||
list: {
|
list: {
|
||||||
name: 'notes.notes.list',
|
name: NOTES_MCP_TOOL_NAMES.list,
|
||||||
description: 'List notes in a workspace with optional status and tag filters.',
|
description: 'List notes in a workspace with optional status and tag filters.',
|
||||||
requiredRole: 'viewer' as const,
|
requiredRole: 'viewer' as const,
|
||||||
inputSchema: ListNotesToolInputSchema,
|
inputSchema: ListNotesToolInputSchema,
|
||||||
@ -100,7 +106,7 @@ export const NotesMcpToolDefinitions = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
},
|
},
|
||||||
get: {
|
get: {
|
||||||
name: 'notes.notes.get',
|
name: NOTES_MCP_TOOL_NAMES.get,
|
||||||
description: 'Get a single note by note ID and workspace scope.',
|
description: 'Get a single note by note ID and workspace scope.',
|
||||||
requiredRole: 'viewer' as const,
|
requiredRole: 'viewer' as const,
|
||||||
inputSchema: GetNoteToolInputSchema,
|
inputSchema: GetNoteToolInputSchema,
|
||||||
@ -108,7 +114,7 @@ export const NotesMcpToolDefinitions = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
name: 'notes.notes.search',
|
name: NOTES_MCP_TOOL_NAMES.search,
|
||||||
description: 'Search notes in a workspace using lexical query plus optional filters.',
|
description: 'Search notes in a workspace using lexical query plus optional filters.',
|
||||||
requiredRole: 'viewer' as const,
|
requiredRole: 'viewer' as const,
|
||||||
inputSchema: SearchNotesToolInputSchema,
|
inputSchema: SearchNotesToolInputSchema,
|
||||||
@ -116,7 +122,7 @@ export const NotesMcpToolDefinitions = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
},
|
},
|
||||||
createDraft: {
|
createDraft: {
|
||||||
name: 'notes.notes.create_draft',
|
name: NOTES_MCP_TOOL_NAMES.createDraft,
|
||||||
description: 'Create a note draft in a workspace. Supports dry-run, idempotency, and correlation metadata.',
|
description: 'Create a note draft in a workspace. Supports dry-run, idempotency, and correlation metadata.',
|
||||||
requiredRole: 'admin' as const,
|
requiredRole: 'admin' as const,
|
||||||
inputSchema: CreateNoteDraftToolInputSchema,
|
inputSchema: CreateNoteDraftToolInputSchema,
|
||||||
@ -129,3 +135,7 @@ 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>;
|
||||||
export type CreateNoteDraftToolInput = z.infer<typeof CreateNoteDraftToolInputSchema>;
|
export type CreateNoteDraftToolInput = z.infer<typeof CreateNoteDraftToolInputSchema>;
|
||||||
|
export type ListNotesToolOutput = z.infer<typeof ListNotesToolOutputSchema>;
|
||||||
|
export type GetNoteToolOutput = z.infer<typeof GetNoteToolOutputSchema>;
|
||||||
|
export type SearchNotesToolOutput = z.infer<typeof SearchNotesToolOutputSchema>;
|
||||||
|
export type CreateNoteDraftToolOutput = z.infer<typeof CreateNoteDraftToolOutputSchema>;
|
||||||
|
|||||||
188
backend/src/mcp/note-tools.test.ts
Normal file
188
backend/src/mcp/note-tools.test.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NOTES_MCP_TOOL_NAMES } from './note-tool-contracts.js';
|
||||||
|
|
||||||
|
const { listNotesMock, getNoteMock, createNoteMock, createNoteAgentActionMock } = vi.hoisted(() => ({
|
||||||
|
listNotesMock: vi.fn(),
|
||||||
|
getNoteMock: vi.fn(),
|
||||||
|
createNoteMock: vi.fn(),
|
||||||
|
createNoteAgentActionMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/product-config.js', () => ({ PRODUCT_ID: 'bytelyst-notes' }));
|
||||||
|
vi.mock('../modules/notes/repository.js', () => ({
|
||||||
|
listNotes: listNotesMock,
|
||||||
|
getNote: getNoteMock,
|
||||||
|
createNote: createNoteMock,
|
||||||
|
}));
|
||||||
|
vi.mock('../modules/note-agent-actions/repository.js', () => ({
|
||||||
|
createNoteAgentAction: createNoteAgentActionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { NotesExecutableMcpTools, getNotesExecutableMcpTool } from './note-tools.js';
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
id: 'req_1',
|
||||||
|
headers: {},
|
||||||
|
jwtPayload: { sub: 'user_1', role: 'admin', productId: 'bytelyst-notes' },
|
||||||
|
log: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('note executable MCP tools', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes executable tools for each core note contract', () => {
|
||||||
|
expect(NotesExecutableMcpTools.map(tool => tool.name)).toEqual([
|
||||||
|
NOTES_MCP_TOOL_NAMES.list,
|
||||||
|
NOTES_MCP_TOOL_NAMES.get,
|
||||||
|
NOTES_MCP_TOOL_NAMES.search,
|
||||||
|
NOTES_MCP_TOOL_NAMES.createDraft,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists notes through the repository', async () => {
|
||||||
|
listNotesMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'note_1',
|
||||||
|
productId: 'bytelyst-notes',
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
userId: 'user_1',
|
||||||
|
title: 'Draft',
|
||||||
|
body: 'Body',
|
||||||
|
status: 'draft',
|
||||||
|
tags: ['alpha'],
|
||||||
|
links: [],
|
||||||
|
createdAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
createdBy: 'user_1',
|
||||||
|
updatedBy: 'user_1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.list);
|
||||||
|
const result = await tool?.execute(
|
||||||
|
tool.inputSchema.parse({ workspaceId: 'ws_1', limit: 10, offset: 0 }),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(listNotesMock).toHaveBeenCalledWith('user_1', 'bytelyst-notes', {
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ total: 1, limit: 10, offset: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches notes and computes match fields', async () => {
|
||||||
|
listNotesMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'note_1',
|
||||||
|
productId: 'bytelyst-notes',
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
userId: 'user_1',
|
||||||
|
title: 'Retention policy',
|
||||||
|
body: 'Body text',
|
||||||
|
status: 'active',
|
||||||
|
tags: ['compliance'],
|
||||||
|
links: [],
|
||||||
|
createdAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
createdBy: 'user_1',
|
||||||
|
updatedBy: 'user_1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.search);
|
||||||
|
const result = await tool?.execute(
|
||||||
|
tool.inputSchema.parse({ workspaceId: 'ws_1', query: 'retention' }),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
query: 'retention',
|
||||||
|
items: [{ matchFields: ['title'] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a scoped note', async () => {
|
||||||
|
getNoteMock.mockResolvedValue({
|
||||||
|
id: 'note_1',
|
||||||
|
productId: 'bytelyst-notes',
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
userId: 'user_1',
|
||||||
|
title: 'Note',
|
||||||
|
body: 'Body',
|
||||||
|
status: 'draft',
|
||||||
|
tags: [],
|
||||||
|
links: [],
|
||||||
|
createdAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
createdBy: 'user_1',
|
||||||
|
updatedBy: 'user_1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.get);
|
||||||
|
const result = await tool?.execute(
|
||||||
|
tool.inputSchema.parse({ noteId: 'note_1', workspaceId: 'ws_1' }),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ id: 'note_1', workspaceId: 'ws_1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a dry-run draft without persistence', async () => {
|
||||||
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.createDraft);
|
||||||
|
const result = await tool?.execute(
|
||||||
|
tool.inputSchema.parse({
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
title: 'Draft title',
|
||||||
|
body: 'Draft body',
|
||||||
|
dryRun: true,
|
||||||
|
}),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNoteMock).not.toHaveBeenCalled();
|
||||||
|
expect(createNoteAgentActionMock).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ dryRun: true, state: 'proposed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a persisted draft and audit action', async () => {
|
||||||
|
createNoteMock.mockImplementation(async (doc: unknown) => doc);
|
||||||
|
createNoteAgentActionMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.createDraft);
|
||||||
|
const result = await tool?.execute(
|
||||||
|
tool.inputSchema.parse({
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
title: 'Draft title',
|
||||||
|
body: 'Draft body',
|
||||||
|
agentId: 'agent_1',
|
||||||
|
idempotencyKey: 'idem_1',
|
||||||
|
correlationId: 'corr_1',
|
||||||
|
}),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNoteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createNoteAgentActionMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
dryRun: false,
|
||||||
|
state: 'draft',
|
||||||
|
idempotencyKey: 'idem_1',
|
||||||
|
correlationId: 'corr_1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
241
backend/src/mcp/note-tools.ts
Normal file
241
backend/src/mcp/note-tools.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ Parent: `docs/ROADMAP.md`
|
|||||||
- [x] Create note draft
|
- [x] Create note draft
|
||||||
- [x] Workspace-scoped retrieval
|
- [x] Workspace-scoped retrieval
|
||||||
- [x] Define tool input/output schemas
|
- [x] Define tool input/output schemas
|
||||||
|
- [x] Add product-side executable tool layer
|
||||||
|
|
||||||
# Phase A2 — Agent Workflows
|
# Phase A2 — Agent Workflows
|
||||||
|
|
||||||
@ -57,6 +58,13 @@ Parent: `docs/ROADMAP.md`
|
|||||||
- draft creation requires `admin`
|
- draft creation requires `admin`
|
||||||
- mutating draft creation supports `dryRun`, `idempotencyKey`, and `correlationId`
|
- mutating draft creation supports `dryRun`, `idempotencyKey`, and `correlationId`
|
||||||
- all core tools are explicitly workspace-scoped
|
- all core tools are explicitly workspace-scoped
|
||||||
|
- 2026-03-10 — Product-side executable MCP note tools added under `backend/src/mcp/note-tools.ts`.
|
||||||
|
- Verified behavior now includes:
|
||||||
|
- executable list/get/search handlers over the existing notes repository
|
||||||
|
- executable `create_draft` handler
|
||||||
|
- dry-run draft preview behavior
|
||||||
|
- persisted draft creation with `note-agent-actions` audit/proposal record creation
|
||||||
|
- Vitest coverage for executable MCP tools
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
|
|
||||||
@ -67,7 +75,7 @@ Parent: `docs/ROADMAP.md`
|
|||||||
# Blockers
|
# Blockers
|
||||||
|
|
||||||
- `mcp-server` registration and product client execution wiring have not been implemented yet.
|
- `mcp-server` registration and product client execution wiring have not been implemented yet.
|
||||||
- Contract tests exist, but package install and test execution are still pending.
|
- Shared-server registration/auth-boundary review is still pending.
|
||||||
|
|
||||||
# Deferred
|
# Deferred
|
||||||
|
|
||||||
@ -78,6 +86,6 @@ Parent: `docs/ROADMAP.md`
|
|||||||
|
|
||||||
# Done When
|
# Done When
|
||||||
|
|
||||||
- [ ] MCP tools cover core note workflows
|
- [x] MCP tools cover core note workflows at the product-backend execution layer
|
||||||
- [ ] Mutating tool paths are auditable and scoped
|
- [ ] Mutating tool paths are auditable and scoped
|
||||||
- [ ] Coding agents have clear contracts for using tools safely
|
- [ ] Coding agents have clear contracts for using tools safely
|
||||||
|
|||||||
@ -13,15 +13,15 @@ Parent: `docs/ROADMAP.md`
|
|||||||
|
|
||||||
# Phase Q1 — MVP Coverage
|
# Phase Q1 — MVP Coverage
|
||||||
|
|
||||||
- [ ] Backend CRUD/search test coverage
|
- [x] Backend CRUD/search test coverage
|
||||||
- [ ] Web flow coverage
|
- [ ] Web flow coverage
|
||||||
- [ ] Mobile smoke coverage
|
- [ ] Mobile smoke coverage
|
||||||
- [ ] Platform integration smoke coverage
|
- [ ] Platform integration smoke coverage
|
||||||
- [ ] Artifact/task/relationship test coverage
|
- [x] Artifact/task/relationship test coverage
|
||||||
|
|
||||||
# Phase Q2 — Agent Coverage
|
# Phase Q2 — Agent Coverage
|
||||||
|
|
||||||
- [ ] MCP tool tests
|
- [x] MCP tool tests
|
||||||
- [ ] Approval workflow tests
|
- [ ] Approval workflow tests
|
||||||
- [ ] Audit verification tests
|
- [ ] Audit verification tests
|
||||||
- [ ] Regression coverage for agent-mediated note changes
|
- [ ] Regression coverage for agent-mediated note changes
|
||||||
@ -49,7 +49,10 @@ Parent: `docs/ROADMAP.md`
|
|||||||
- build: `cd backend && npm run build`
|
- build: `cd backend && npm run build`
|
||||||
- test: `cd backend && npm test`
|
- test: `cd backend && npm test`
|
||||||
- typecheck: `cd backend && npm run typecheck`
|
- typecheck: `cd backend && npm run typecheck`
|
||||||
- These commands are defined but not yet executed because the new backend package still needs dependency installation.
|
- 2026-03-10 — Backend verification materially advanced:
|
||||||
|
- `backend` `npm run typecheck` passes
|
||||||
|
- `backend` `npm test` passes
|
||||||
|
- MCP contract tests and executable MCP tool tests now pass
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
|
|
||||||
@ -58,7 +61,7 @@ Parent: `docs/ROADMAP.md`
|
|||||||
|
|
||||||
# Blockers
|
# Blockers
|
||||||
|
|
||||||
- Backend and web package installation/verification are still pending.
|
- Shared platform integration smoke coverage is still pending.
|
||||||
|
|
||||||
# Deferred
|
# Deferred
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user