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