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 { registerTool } from '../tools/registry.js';
|
||||||
import { config } from '../../lib/config.js';
|
import { config } from '../../lib/config.js';
|
||||||
import {
|
import {
|
||||||
|
type NoteDoc,
|
||||||
notesCreateAgentAction,
|
notesCreateAgentAction,
|
||||||
notesCreateDraft,
|
notesCreateDraft,
|
||||||
notesGet,
|
notesGet,
|
||||||
@ -28,6 +29,49 @@ function requireUserId(req: McpToolRequest): string {
|
|||||||
return req.jwtPayload.sub;
|
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({
|
registerTool({
|
||||||
name: 'notes.notes.list',
|
name: 'notes.notes.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.',
|
||||||
@ -36,12 +80,18 @@ registerTool({
|
|||||||
workspaceId: z.string().min(1).max(128),
|
workspaceId: z.string().min(1).max(128),
|
||||||
status: z.enum(['draft', 'active', 'archived']).optional(),
|
status: z.enum(['draft', 'active', 'archived']).optional(),
|
||||||
tag: z.string().min(1).max(64).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),
|
offset: z.coerce.number().min(0).default(0),
|
||||||
}),
|
}),
|
||||||
async execute(args, req) {
|
async execute(args, req) {
|
||||||
requireUserId(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) {
|
async execute(args, req) {
|
||||||
requireUserId(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),
|
query: z.string().min(1).max(200),
|
||||||
status: z.enum(['draft', 'active', 'archived']).optional(),
|
status: z.enum(['draft', 'active', 'archived']).optional(),
|
||||||
tag: z.string().min(1).max(64).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),
|
offset: z.coerce.number().min(0).default(0),
|
||||||
}),
|
}),
|
||||||
async execute(args, req) {
|
async execute(args, req) {
|
||||||
requireUserId(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 {
|
return {
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
state: 'proposed',
|
state: 'proposed',
|
||||||
note: {
|
note: mapNote({
|
||||||
id: noteId,
|
id: noteId,
|
||||||
|
productId: 'bytelyst-notes',
|
||||||
|
userId,
|
||||||
workspaceId: args.workspaceId,
|
workspaceId: args.workspaceId,
|
||||||
title: args.title,
|
title: args.title,
|
||||||
body: args.body,
|
body: args.body,
|
||||||
@ -115,10 +181,12 @@ registerTool({
|
|||||||
links: args.links,
|
links: args.links,
|
||||||
sourceType: args.sourceType,
|
sourceType: args.sourceType,
|
||||||
sourceUri: args.sourceUri,
|
sourceUri: args.sourceUri,
|
||||||
agentId: args.agentId,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
createdBy: userId,
|
||||||
|
updatedBy: userId,
|
||||||
|
agentId: args.agentId,
|
||||||
|
}),
|
||||||
idempotencyKey: args.idempotencyKey,
|
idempotencyKey: args.idempotencyKey,
|
||||||
correlationId,
|
correlationId,
|
||||||
};
|
};
|
||||||
@ -161,20 +229,7 @@ registerTool({
|
|||||||
return {
|
return {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
state: 'draft',
|
state: 'draft',
|
||||||
note: {
|
note: mapNote(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,
|
|
||||||
},
|
|
||||||
idempotencyKey: args.idempotencyKey,
|
idempotencyKey: args.idempotencyKey,
|
||||||
correlationId,
|
correlationId,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user