From aff78c55a4acc63dd6f303ae13d68cef9a5319aa Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 10 Mar 2026 09:54:08 -0700 Subject: [PATCH] fix(mcp-server): align notes tool outputs with contracts --- .../src/modules/notes/notes-tools.test.ts | 219 ++++++++++++++++++ .../src/modules/notes/notes-tools.ts | 99 ++++++-- 2 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 services/mcp-server/src/modules/notes/notes-tools.test.ts diff --git a/services/mcp-server/src/modules/notes/notes-tools.test.ts b/services/mcp-server/src/modules/notes/notes-tools.test.ts new file mode 100644 index 00000000..1d4ccb4d --- /dev/null +++ b/services/mcp-server/src/modules/notes/notes-tools.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getTool } from '../tools/registry.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const fetchMock = vi.fn(); + +const req: McpToolRequest = { + id: 'req_1', + headers: { authorization: 'Bearer token_1' }, + jwtPayload: { sub: 'user_1', role: 'admin', productId: 'bytelyst-notes' }, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +}; + +beforeAll(() => { + process.env.JWT_SECRET ??= 'test-secret'; + process.env.BYTELYST_NOTES_BACKEND_URL ??= 'http://localhost:4016'; + vi.stubGlobal('fetch', fetchMock); + return import('./notes-tools.js'); +}); + +afterEach(() => { + fetchMock.mockReset(); +}); + +describe('notes MCP tools', () => { + it('maps list output to the shared MCP contract shape', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { + id: 'note_1', + productId: 'bytelyst-notes', + workspaceId: 'ws_1', + userId: 'user_1', + title: 'Weekly plan', + body: 'body text', + status: 'draft', + tags: ['planning'], + links: [], + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T01:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + }, + ], + total: 1, + limit: 50, + offset: 0, + }), + }); + + const tool = getTool('notes.notes.list'); + const result = await tool?.execute({ workspaceId: 'ws_1', limit: 50, offset: 0 }, req); + + expect(result).toEqual({ + items: [ + { + id: 'note_1', + workspaceId: 'ws_1', + title: 'Weekly plan', + status: 'draft', + updatedAt: '2026-03-10T01:00:00.000Z', + tags: ['planning'], + }, + ], + total: 1, + limit: 50, + offset: 0, + }); + }); + + it('maps search output and derives matchFields from the query', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'plan', + items: [ + { + id: 'note_1', + productId: 'bytelyst-notes', + workspaceId: 'ws_1', + userId: 'user_1', + title: 'Weekly plan', + body: 'Plan the sprint and align tags', + status: 'draft', + tags: ['planning'], + links: [], + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T01:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + }, + ], + total: 1, + limit: 25, + offset: 0, + }), + }); + + const tool = getTool('notes.notes.search'); + const result = await tool?.execute( + { workspaceId: 'ws_1', query: 'plan', limit: 25, offset: 0 }, + req + ); + + expect(result).toEqual({ + query: 'plan', + items: [ + { + id: 'note_1', + workspaceId: 'ws_1', + title: 'Weekly plan', + status: 'draft', + updatedAt: '2026-03-10T01:00:00.000Z', + tags: ['planning'], + matchFields: ['title', 'body', 'tags'], + }, + ], + total: 1, + limit: 25, + offset: 0, + }); + }); + + it('returns the shared create_draft response shape and records audit metadata', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'note_1', + productId: 'bytelyst-notes', + workspaceId: 'ws_1', + userId: 'user_1', + title: 'Draft title', + body: 'Draft body', + status: 'draft', + tags: ['draft'], + links: ['https://example.com'], + sourceType: 'web', + sourceUri: 'https://example.com', + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T00:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + agentId: 'agent_1', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'action_1', + noteId: 'note_1', + state: 'proposed', + toolName: 'notes.notes.create_draft', + }), + }); + + const tool = getTool('notes.notes.create_draft'); + const result = await tool?.execute( + { + workspaceId: 'ws_1', + title: 'Draft title', + body: 'Draft body', + tags: ['draft'], + links: ['https://example.com'], + sourceType: 'web', + sourceUri: 'https://example.com', + agentId: 'agent_1', + idempotencyKey: 'idem_1', + correlationId: 'corr_1', + dryRun: false, + }, + req + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + method: 'POST', + }); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + workspaceId: 'ws_1', + noteId: 'note_1', + actorId: 'agent_1', + actorType: 'agent', + toolName: 'notes.notes.create_draft', + actionType: 'create', + state: 'proposed', + idempotencyKey: 'idem_1', + correlationId: 'corr_1', + workflowId: 'req_1', + }); + expect(result).toEqual({ + dryRun: false, + state: 'draft', + note: { + id: 'note_1', + workspaceId: 'ws_1', + title: 'Draft title', + body: 'Draft body', + status: 'draft', + tags: ['draft'], + links: ['https://example.com'], + sourceType: 'web', + sourceUri: 'https://example.com', + agentId: 'agent_1', + createdAt: '2026-03-10T00:00:00.000Z', + updatedAt: '2026-03-10T00:00:00.000Z', + }, + idempotencyKey: 'idem_1', + correlationId: 'corr_1', + }); + }); +}); diff --git a/services/mcp-server/src/modules/notes/notes-tools.ts b/services/mcp-server/src/modules/notes/notes-tools.ts index 80e3c04c..89f76ace 100644 --- a/services/mcp-server/src/modules/notes/notes-tools.ts +++ b/services/mcp-server/src/modules/notes/notes-tools.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { registerTool } from '../tools/registry.js'; import { config } from '../../lib/config.js'; import { + type NoteDoc, notesCreateAgentAction, notesCreateDraft, notesGet, @@ -28,6 +29,49 @@ function requireUserId(req: McpToolRequest): string { return req.jwtPayload.sub; } +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 { + 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, + }; +} + +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; +} + registerTool({ name: 'notes.notes.list', description: 'List notes in a workspace with optional status and tag filters.', @@ -36,12 +80,18 @@ registerTool({ workspaceId: z.string().min(1).max(128), status: z.enum(['draft', 'active', 'archived']).optional(), tag: z.string().min(1).max(64).optional(), - limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).default(config.QUERY_DEFAULT_LIMIT), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).default(50), offset: z.coerce.number().min(0).default(0), }), async execute(args, req) { requireUserId(req); - return notesList(args, { token: tokenOf(req), requestId: req.id }); + const result = await notesList(args, { token: tokenOf(req), requestId: req.id }); + return { + items: result.items.map(mapNoteSummary), + total: result.total, + limit: args.limit, + offset: args.offset, + }; }, }); @@ -55,7 +105,11 @@ registerTool({ }), async execute(args, req) { requireUserId(req); - return notesGet(args.noteId, args.workspaceId, { token: tokenOf(req), requestId: req.id }); + const note = await notesGet(args.noteId, args.workspaceId, { + token: tokenOf(req), + requestId: req.id, + }); + return mapNote(note); }, }); @@ -68,12 +122,22 @@ registerTool({ query: z.string().min(1).max(200), status: z.enum(['draft', 'active', 'archived']).optional(), tag: z.string().min(1).max(64).optional(), - limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).default(config.QUERY_DEFAULT_LIMIT), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).default(25), offset: z.coerce.number().min(0).default(0), }), async execute(args, req) { requireUserId(req); - return notesSearch(args, { token: tokenOf(req), requestId: req.id }); + const result = await notesSearch(args, { token: tokenOf(req), requestId: req.id }); + return { + query: args.query, + items: result.items.map(note => ({ + ...mapNoteSummary(note), + matchFields: getMatchFields(note, args.query), + })), + total: result.total, + limit: args.limit, + offset: args.offset, + }; }, }); @@ -105,8 +169,10 @@ registerTool({ return { dryRun: true, state: 'proposed', - note: { + note: mapNote({ id: noteId, + productId: 'bytelyst-notes', + userId, workspaceId: args.workspaceId, title: args.title, body: args.body, @@ -115,10 +181,12 @@ registerTool({ links: args.links, sourceType: args.sourceType, sourceUri: args.sourceUri, - agentId: args.agentId, createdAt: now, updatedAt: now, - }, + createdBy: userId, + updatedBy: userId, + agentId: args.agentId, + }), idempotencyKey: args.idempotencyKey, correlationId, }; @@ -161,20 +229,7 @@ registerTool({ return { dryRun: false, state: 'draft', - note: { - 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, - }, + note: mapNote(note), idempotencyKey: args.idempotencyKey, correlationId, };