fix(mcp-server): align notes tool outputs with contracts
This commit is contained in:
parent
ec3dd4bd66
commit
aff78c55a4
219
services/mcp-server/src/modules/notes/notes-tools.test.ts
Normal file
219
services/mcp-server/src/modules/notes/notes-tools.test.ts
Normal file
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user