diff --git a/backend/src/mcp/palace-tools.test.ts b/backend/src/mcp/palace-tools.test.ts new file mode 100644 index 0000000..85b0bd5 --- /dev/null +++ b/backend/src/mcp/palace-tools.test.ts @@ -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([]); + }); +}); diff --git a/backend/src/mcp/palace-tools.ts b/backend/src/mcp/palace-tools.ts new file mode 100644 index 0000000..a832706 --- /dev/null +++ b/backend/src/mcp/palace-tools.ts @@ -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; +type MempalaceStoreInput = z.infer; +type MempalaceWakeUpInput = z.infer; +type MempalaceQueryEntityInput = z.infer; +type MempalaceTimelineInput = z.infer; +type MempalaceListWingsInput = z.infer; + +// ── 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[] = 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, + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + ] + : [];