import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import Database from 'better-sqlite3'; let tempRoot: string; let tempHome: string; let dbPath: string; function createStandaloneDb(targetPath: string) { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); const db = new Database(targetPath); db.exec(` CREATE TABLE nodes ( id INTEGER PRIMARY KEY, title TEXT, description TEXT, source TEXT, link TEXT, event_date TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, metadata TEXT, embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, chunk_status TEXT DEFAULT 'not_chunked' ); CREATE TABLE edges ( id INTEGER PRIMARY KEY, from_node_id INTEGER NOT NULL, to_node_id INTEGER NOT NULL, source TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, context TEXT, explanation TEXT ); CREATE TABLE chunks ( id INTEGER PRIMARY KEY, node_id INTEGER NOT NULL, chunk_idx INTEGER NOT NULL, text TEXT NOT NULL, embedding_type TEXT, metadata TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); `); const now = new Date().toISOString(); const insertNode = db.prepare(` INSERT INTO nodes (id, title, description, source, link, event_date, created_at, updated_at, metadata, chunk_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); insertNode.run( 1, 'Standalone Test Node', 'This is a standalone MCP test node used for retrieval and orientation.', 'A concrete source text about building a useful graph of atomic units of context.', null, null, now, now, JSON.stringify({ captured_by: 'human' }), 'chunked' ); insertNode.run( 2, 'Connected Support Node', 'A supporting node connected to the main standalone test node.', 'Related source text for standalone MCP testing.', null, null, now, now, JSON.stringify({ captured_by: 'human' }), 'not_chunked' ); db.prepare(` INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 1, 1, 2, 'test', now, JSON.stringify({ explanation: 'Supports the main test node.' }), 'Supports the main test node.' ); db.prepare(` INSERT INTO chunks (id, node_id, chunk_idx, text, embedding_type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 1, 1, 0, 'A concrete source text about building a useful graph of atomic units of context.', 'text', JSON.stringify({}), now ); db.close(); } async function withStandaloneClient(fn: (client: Client) => Promise) { const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(process.cwd(), 'apps', 'mcp-server-standalone', 'index.js')], cwd: process.cwd(), env: { ...process.env, HOME: tempHome, RAH_DB_PATH: dbPath, } as Record, stderr: 'pipe', }); const client = new Client({ name: 'ra-h-standalone-contract-test', version: '1.0.0' }); await client.connect(transport); try { return await fn(client); } finally { await transport.close(); } } function getStructured(result: unknown) { return (result as { structuredContent?: unknown }).structuredContent as T; } beforeAll(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rah-standalone-test-')); tempHome = path.join(tempRoot, 'home'); dbPath = path.join(tempHome, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite'); }); beforeEach(() => { fs.rmSync(tempHome, { recursive: true, force: true }); createStandaloneDb(dbPath); }); afterAll(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); describe('standalone MCP server contract', () => { it('returns orientation data with skills and no guides leakage', async () => { await withStandaloneClient(async (client) => { const result = await client.callTool({ name: 'getContext', arguments: {}, }); const structured = getStructured<{ stats: { nodeCount: number; edgeCount: number }; hubNodes: Array<{ title: string }>; skills: Array<{ name: string }>; }>(result); expect(structured.stats.nodeCount).toBe(2); expect(structured.skills).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'Onboarding' }), expect.objectContaining({ name: 'Create Skill' }), expect.objectContaining({ name: 'Refine' }), ])); expect(structured).not.toHaveProperty('guides'); }); }); it('supports direct lookup and broader retrieval in standalone MCP', async () => { await withStandaloneClient(async (client) => { const queryResult = await client.callTool({ name: 'queryNodes', arguments: { query: 'Standalone Test Node', }, }); const queryStructured = getStructured<{ count: number; nodes: Array<{ id: number; title: string }> }>(queryResult); expect(queryStructured.count).toBeGreaterThan(0); expect(queryStructured.nodes[0]?.title).toBe('Standalone Test Node'); const retrievalResult = await client.callTool({ name: 'retrieveQueryContext', arguments: { query: 'help me think about atomic units of context', focused_node_id: 1, }, }); const retrievalStructured = getStructured<{ focused_node_id: number | null; nodes: Array<{ id: number }> }>(retrievalResult); expect(retrievalStructured.focused_node_id).toBe(1); expect(retrievalStructured.nodes.some((node) => node.id === 1)).toBe(true); }); }); it('lists, reads, writes, and deletes shared skills through standalone MCP', async () => { await withStandaloneClient(async (client) => { const listResult = await client.callTool({ name: 'listSkills', arguments: {}, }); const listed = getStructured<{ count: number; skills: Array<{ name: string }> }>(listResult); expect(listed.count).toBe(3); expect(listed.skills).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'Onboarding' }), expect.objectContaining({ name: 'Create Skill' }), expect.objectContaining({ name: 'Refine' }), ])); const readResult = await client.callTool({ name: 'readSkill', arguments: { name: 'refine' }, }); const readStructured = getStructured<{ name: string; content: string }>(readResult); expect(readStructured.name).toBe('Refine'); expect(readStructured.content).toContain('# Refine'); await client.callTool({ name: 'writeSkill', arguments: { name: 'capture-source', content: '---\nname: Capture Source\ndescription: Preserve source carefully.\n---\n\n# Capture Source\n', }, }); const afterWrite = await client.callTool({ name: 'listSkills', arguments: {}, }); const afterWriteStructured = getStructured<{ skills: Array<{ name: string }> }>(afterWrite); expect(afterWriteStructured.skills).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'Capture Source' }), ])); await client.callTool({ name: 'deleteSkill', arguments: { name: 'capture-source' }, }); const afterDelete = await client.callTool({ name: 'listSkills', arguments: {}, }); const afterDeleteStructured = getStructured<{ skills: Array<{ name: string }> }>(afterDelete); expect(afterDeleteStructured.skills.some((skill) => skill.name === 'Capture Source')).toBe(false); }); }); });