feat(palace): MCP memory tools — search, store, wake-up, KG query, timeline, list wings (N6)

This commit is contained in:
saravanakumardb1 2026-04-10 01:37:31 -07:00
parent 5ce5a4b3cc
commit c7c1ebad74
2 changed files with 288 additions and 0 deletions

View File

@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
vi.mock('../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
vi.mock('../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
import { resetMemoryDatastore, TEST_USER_ID, TEST_PRODUCT_ID } from '../test-helpers.js';
import { ensureWing, ensureRoom, storeMemory, addTriple } from '../modules/palace/repository.js';
import { PalaceMcpToolDefinitions, PALACE_MCP_TOOL_NAMES } from './palace-tools.js';
import type { NotesMcpRequest } from './note-tools.js';
const USER_A = TEST_USER_ID;
const PRODUCT = TEST_PRODUCT_ID;
const mockReq: NotesMcpRequest = {
id: 'req-1',
headers: { authorization: 'Bearer test-token' },
jwtPayload: { sub: USER_A, role: 'admin', productId: PRODUCT },
log: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
};
function getTool(name: string) {
const tool = PalaceMcpToolDefinitions.find(t => t.name === name);
if (!tool) throw new Error(`Tool ${name} not found`);
return tool;
}
describe('Palace MCP Tools (N6)', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('all 6 palace MCP tools are registered', () => {
expect(PalaceMcpToolDefinitions.length).toBe(6);
const names = PalaceMcpToolDefinitions.map(t => t.name);
expect(names).toContain(PALACE_MCP_TOOL_NAMES.search);
expect(names).toContain(PALACE_MCP_TOOL_NAMES.store);
expect(names).toContain(PALACE_MCP_TOOL_NAMES.wakeUp);
expect(names).toContain(PALACE_MCP_TOOL_NAMES.queryEntity);
expect(names).toContain(PALACE_MCP_TOOL_NAMES.timeline);
expect(names).toContain(PALACE_MCP_TOOL_NAMES.listWings);
});
it('mempalace_search returns ranked results', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use JWT for auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'events', 'Deployed v2');
const tool = getTool(PALACE_MCP_TOOL_NAMES.search);
const result = await tool.execute({ query: 'JWT', limit: 10 }, mockReq) as unknown[];
expect(result.length).toBe(1);
});
it('mempalace_store persists and is searchable', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
const storeTool = getTool(PALACE_MCP_TOOL_NAMES.store);
const storeResult = await storeTool.execute({
wingId: wing.id,
roomId: room.id,
hall: 'decisions',
content: 'Use Fastify 5 for all new backends',
}, mockReq) as { stored: boolean };
expect(storeResult.stored).toBe(true);
const searchTool = getTool(PALACE_MCP_TOOL_NAMES.search);
const searchResult = await searchTool.execute({ query: 'Fastify', limit: 10 }, mockReq) as unknown[];
expect(searchResult.length).toBe(1);
});
it('mempalace_wake_up returns structured context', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use JWT');
const tool = getTool(PALACE_MCP_TOOL_NAMES.wakeUp);
const result = await tool.execute({ wingId: wing.id }, mockReq) as { wingName: string; text: string };
expect(result.wingName).toBe('Work');
expect(result.text.length).toBeGreaterThan(0);
});
it('mempalace_query_entity returns user-scoped triples', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'React', 'replaced_by', 'Svelte', 0.8);
const tool = getTool(PALACE_MCP_TOOL_NAMES.queryEntity);
const result = await tool.execute({ entity: 'React' }, mockReq) as unknown[];
expect(result.length).toBe(1);
});
it('MCP tools enforce userId from JWT', async () => {
const reqNoUser: NotesMcpRequest = {
...mockReq,
jwtPayload: { role: 'admin', productId: PRODUCT },
};
const tool = getTool(PALACE_MCP_TOOL_NAMES.listWings);
await expect(tool.execute({}, reqNoUser)).rejects.toThrow('Authenticated user is required');
});
it('graceful response when palace is empty', async () => {
const tool = getTool(PALACE_MCP_TOOL_NAMES.listWings);
const result = await tool.execute({}, mockReq) as unknown[];
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,175 @@
/**
* Palace MCP tools extend NoteLett's MCP system with memory operations.
*
* 6 tools: search, store, wake_up, query_entity, timeline, list_wings
*/
import { z } from 'zod';
import type { ZodTypeAny } from 'zod';
import { PRODUCT_ID } from '../lib/product-config.js';
import { embedText } from '../lib/embeddings.js';
import { config } from '../lib/config.js';
import * as repo from '../modules/palace/repository.js';
import { buildNoteLettWakeUp } from '../modules/palace/wakeup.js';
import { HALL_TYPES } from '../modules/palace/types.js';
import type { HallType } from '../modules/palace/types.js';
import type { NotesMcpRequest, NotesMcpTool } from './note-tools.js';
// ── Schemas ─────────────────────────────────────────
export const MempalaceSearchInputSchema = z.object({
query: z.string().min(1).max(500),
wingId: z.string().max(128).optional(),
limit: z.coerce.number().int().min(1).max(50).default(10),
});
export const MempalaceStoreInputSchema = z.object({
wingId: z.string().min(1).max(128),
roomId: z.string().min(1).max(128),
hall: z.enum(HALL_TYPES),
content: z.string().min(1).max(5000),
sourceNoteId: z.string().max(128).optional(),
});
export const MempalaceWakeUpInputSchema = z.object({
wingId: z.string().min(1).max(128),
task: z.string().max(500).optional(),
});
export const MempalaceQueryEntityInputSchema = z.object({
entity: z.string().min(1).max(256),
});
export const MempalaceTimelineInputSchema = z.object({
entity: z.string().min(1).max(256),
});
export const MempalaceListWingsInputSchema = z.object({});
// ── Types ───────────────────────────────────────────
type MempalaceSearchInput = z.infer<typeof MempalaceSearchInputSchema>;
type MempalaceStoreInput = z.infer<typeof MempalaceStoreInputSchema>;
type MempalaceWakeUpInput = z.infer<typeof MempalaceWakeUpInputSchema>;
type MempalaceQueryEntityInput = z.infer<typeof MempalaceQueryEntityInputSchema>;
type MempalaceTimelineInput = z.infer<typeof MempalaceTimelineInputSchema>;
type MempalaceListWingsInput = z.infer<typeof MempalaceListWingsInputSchema>;
// ── Helpers ─────────────────────────────────────────
function requireUserId(req: NotesMcpRequest): string {
const userId = req.jwtPayload?.sub;
if (!userId) throw new Error('Authenticated user is required');
return userId;
}
// ── Tool Implementations ────────────────────────────
async function executeMempalaceSearch(args: MempalaceSearchInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
const embedding = await embedText(args.query);
if (embedding) {
return repo.searchHybrid(userId, PRODUCT_ID, args.query, embedding, args.wingId, args.limit);
}
return repo.searchText(userId, PRODUCT_ID, args.query, args.wingId, args.limit);
}
async function executeMempalaceStore(args: MempalaceStoreInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
const embedding = await embedText(args.content);
const isDup = await repo.isNearDuplicate(
userId, PRODUCT_ID, args.roomId, args.hall as HallType, args.content, embedding,
);
if (isDup) return { stored: false, reason: 'duplicate' };
const mem = await repo.storeMemory(
userId, PRODUCT_ID, args.wingId, args.roomId,
args.hall as HallType, args.content, args.sourceNoteId, embedding,
);
return { stored: true, memory: mem };
}
async function executeMempalaceWakeUp(args: MempalaceWakeUpInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
return buildNoteLettWakeUp(userId, PRODUCT_ID, args.wingId, args.task);
}
async function executeMempalaceQueryEntity(args: MempalaceQueryEntityInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
return repo.queryEntity(userId, PRODUCT_ID, args.entity);
}
async function executeMempalaceTimeline(args: MempalaceTimelineInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
return repo.entityTimeline(userId, PRODUCT_ID, args.entity);
}
async function executeMempalaceListWings(_args: MempalaceListWingsInput, req: NotesMcpRequest) {
const userId = requireUserId(req);
return repo.listWings(userId, PRODUCT_ID);
}
// ── Tool Definitions ────────────────────────────────
export const PALACE_MCP_TOOL_NAMES = {
search: 'notes.mempalace.search',
store: 'notes.mempalace.store',
wakeUp: 'notes.mempalace.wake_up',
queryEntity: 'notes.mempalace.query_entity',
timeline: 'notes.mempalace.timeline',
listWings: 'notes.mempalace.list_wings',
} as const;
export const PalaceMcpToolDefinitions: NotesMcpTool<unknown>[] = config.PALACE_ENABLED
? [
{
name: PALACE_MCP_TOOL_NAMES.search,
description: 'Search memories in the palace (semantic + text hybrid)',
requiredRole: 'viewer',
readOnly: true,
inputSchema: MempalaceSearchInputSchema as ZodTypeAny,
execute: executeMempalaceSearch as (args: unknown, req: NotesMcpRequest) => Promise<unknown>,
},
{
name: PALACE_MCP_TOOL_NAMES.store,
description: 'Explicitly store a memory in the palace',
requiredRole: 'admin',
readOnly: false,
inputSchema: MempalaceStoreInputSchema as ZodTypeAny,
execute: executeMempalaceStore as (args: unknown, req: NotesMcpRequest) => Promise<unknown>,
},
{
name: PALACE_MCP_TOOL_NAMES.wakeUp,
description: 'Get wake-up context (L0+L1+L2) for a workspace',
requiredRole: 'viewer',
readOnly: true,
inputSchema: MempalaceWakeUpInputSchema as ZodTypeAny,
execute: executeMempalaceWakeUp as (args: unknown, req: NotesMcpRequest) => Promise<unknown>,
},
{
name: PALACE_MCP_TOOL_NAMES.queryEntity,
description: 'Query knowledge graph triples about an entity',
requiredRole: 'viewer',
readOnly: true,
inputSchema: MempalaceQueryEntityInputSchema as ZodTypeAny,
execute: executeMempalaceQueryEntity as (args: unknown, req: NotesMcpRequest) => Promise<unknown>,
},
{
name: PALACE_MCP_TOOL_NAMES.timeline,
description: 'Get chronological timeline of facts about an entity',
requiredRole: 'viewer',
readOnly: true,
inputSchema: MempalaceTimelineInputSchema as ZodTypeAny,
execute: executeMempalaceTimeline as (args: unknown, req: NotesMcpRequest) => Promise<unknown>,
},
{
name: PALACE_MCP_TOOL_NAMES.listWings,
description: 'List all palace wings (workspace memory containers)',
requiredRole: 'viewer',
readOnly: true,
inputSchema: MempalaceListWingsInputSchema as ZodTypeAny,
execute: executeMempalaceListWings as (args: unknown, req: NotesMcpRequest) => Promise<unknown>,
},
]
: [];