fix(mcp-server): align notes tool outputs with contracts

This commit is contained in:
saravanakumardb1 2026-03-10 09:54:08 -07:00
parent ec3dd4bd66
commit aff78c55a4
2 changed files with 296 additions and 22 deletions

View 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',
});
});
});

View File

@ -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,
};