feat(palace): MCP memory tools — search, store, wake-up, KG query, timeline, list wings (N6)
This commit is contained in:
parent
5ce5a4b3cc
commit
c7c1ebad74
113
backend/src/mcp/palace-tools.test.ts
Normal file
113
backend/src/mcp/palace-tools.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
175
backend/src/mcp/palace-tools.ts
Normal file
175
backend/src/mcp/palace-tools.ts
Normal 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>,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
Loading…
Reference in New Issue
Block a user