feat: sync shared skills contract and graph-first discipline

This commit is contained in:
“BeeRad”
2026-04-17 20:58:21 +10:00
parent 5f4d1f61c2
commit 7e663abfb0
64 changed files with 1924 additions and 1902 deletions
+261
View File
@@ -0,0 +1,261 @@
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<T>(fn: (client: Client) => Promise<T>) {
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<string, string>,
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<T>(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);
});
});
});
+111 -4
View File
@@ -26,6 +26,7 @@ type RequestLogEntry = {
let server: http.Server;
let baseUrl = '';
let nodes: NodeRecord[] = [];
let skills: Array<{ name: string; description: string; immutable?: boolean; content?: string }> = [];
let requestLog: RequestLogEntry[] = [];
let nextNodeId = 1;
@@ -35,6 +36,11 @@ function nowIso(): string {
function resetState() {
nodes = [];
skills = [
{ name: 'onboarding', description: 'Initial setup guidance.', immutable: false, content: '# onboarding\n' },
{ name: 'create-skill', description: 'Create or rewrite a reusable skill.', immutable: false, content: '# create-skill\n' },
{ name: 'refine', description: 'Refine a node or small set of nodes.', immutable: false, content: '# refine\n' },
];
requestLog = [];
nextNodeId = 1;
}
@@ -168,10 +174,53 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
});
}
if (method === 'GET' && pathname === '/api/guides') {
if (method === 'GET' && pathname === '/api/skills') {
return sendJson(res, 200, {
success: true,
data: [{ name: 'schema' }, { name: 'creating-nodes' }],
data: skills.map(({ content, ...skill }) => skill),
});
}
if (method === 'POST' && pathname === '/api/skills') {
const name = typeof body?.name === 'string' ? body.name : '';
const content = typeof body?.content === 'string' ? body.content : '';
const existing = skills.find((skill) => skill.name === name);
if (existing) {
existing.content = content;
} else {
skills.push({ name, description: '', immutable: false, content });
}
return sendJson(res, 200, {
success: true,
message: `Skill "${name}" saved`,
});
}
if (method === 'GET' && pathname.startsWith('/api/skills/')) {
const name = decodeURIComponent(pathname.split('/').pop() || '');
const skill = skills.find((entry) => entry.name === name);
if (!skill) {
return sendJson(res, 404, { success: false, error: 'Skill not found.' });
}
return sendJson(res, 200, {
success: true,
data: {
name: skill.name,
description: skill.description,
immutable: !!skill.immutable,
content: skill.content || '',
},
});
}
if (method === 'DELETE' && pathname.startsWith('/api/skills/')) {
const name = decodeURIComponent(pathname.split('/').pop() || '');
skills = skills.filter((entry) => entry.name !== name);
return sendJson(res, 200, {
success: true,
message: `Skill "${name}" deleted`,
});
}
@@ -343,16 +392,74 @@ describe('stdio MCP server contract', () => {
const graphStructured = getStructured<{
stats: { nodeCount: number; edgeCount: number };
hubNodes: Array<{ title: string }>;
guides: string[];
skills: Array<{ name: string }>;
}>(graphContextResult);
expect(graphStructured.stats).toEqual({ nodeCount: 1, edgeCount: 3 });
expect(graphStructured.hubNodes).toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })])
);
expect(graphStructured.guides).toEqual(expect.arrayContaining(['schema', 'creating-nodes']));
expect(graphStructured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'onboarding' }),
expect.objectContaining({ name: 'create-skill' }),
expect.objectContaining({ name: 'refine' }),
]));
expect(graphStructured).not.toHaveProperty('contexts');
expect(graphStructured.stats).not.toHaveProperty('contextCount');
});
});
it('lists, reads, writes, and deletes shared skills through packaged MCP', async () => {
await withMcpClient(async (client) => {
const listResult = await client.callTool({
name: 'rah_list_skills',
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: 'rah_read_skill',
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: 'rah_write_skill',
arguments: {
name: 'capture',
content: '---\nname: capture\ndescription: Capture guidance\n---\n\nCapture skill body.',
},
});
const writeRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/skills');
expect(writeRequest?.body).toMatchObject({
name: 'capture',
});
const afterWrite = await client.callTool({
name: 'rah_list_skills',
arguments: {},
});
const afterWriteStructured = getStructured<{ skills: Array<{ name: string }> }>(afterWrite);
expect(afterWriteStructured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'capture' }),
]));
await client.callTool({
name: 'rah_delete_skill',
arguments: { name: 'capture' },
});
const deleteRequest = requestLog.find((entry) => entry.method === 'DELETE' && entry.pathname === '/api/skills/capture');
expect(deleteRequest).toBeDefined();
});
});
});
+149
View File
@@ -0,0 +1,149 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const SKILL_FIXTURES = {
onboarding: `---
name: Onboarding
description: "Initial setup guidance."
---
# Onboarding
`,
'create-skill': `---
name: Create Skill
description: "Create or rewrite a reusable skill."
---
# Create Skill
`,
refine: `---
name: Refine
description: "Refine a node or small set of nodes."
---
# Refine
`,
};
const LEGACY_AUDIT_SKILL = `---
name: Audit
description: "Legacy audit skill."
---
# Audit
`;
const CUSTOM_SKILL = `---
name: Custom Capture
description: "A custom user skill."
---
# Custom Capture
`;
let originalCwd: string;
let tempRoot: string;
let tempHome: string;
function writeFile(filepath: string, content: string) {
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, content, 'utf8');
}
function createCanonicalSkillSet(rootDir: string) {
const skillDir = path.join(rootDir, 'src/config/skills');
for (const [name, content] of Object.entries(SKILL_FIXTURES)) {
writeFile(path.join(skillDir, `${name}.md`), content);
}
}
async function loadSkillService() {
vi.resetModules();
vi.doMock('os', () => ({
default: { homedir: () => tempHome },
homedir: () => tempHome,
}));
return import('@/services/skills/skillService');
}
beforeEach(() => {
originalCwd = process.cwd();
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rah-skill-root-'));
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rah-skill-home-'));
createCanonicalSkillSet(tempRoot);
process.chdir(tempRoot);
});
afterEach(() => {
process.chdir(originalCwd);
vi.doUnmock('os');
vi.resetModules();
fs.rmSync(tempRoot, { recursive: true, force: true });
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('skillService', () => {
it('seeds the canonical three-skill set into the live skills folder', async () => {
const skillService = await loadSkillService();
const skills = skillService.listSkills();
expect(skills.map((skill) => skill.name)).toEqual([
'Create Skill',
'Onboarding',
'Refine',
]);
const liveSkillDir = path.join(tempHome, 'Library/Application Support/RA-H/skills');
expect(fs.readdirSync(liveSkillDir).sort()).toEqual([
'.seed-migrated-2026-03-07-skills-overhaul',
'create-skill.md',
'onboarding.md',
'refine.md',
]);
});
it('hard-prunes retired built-in skills while preserving non-retired custom skills', async () => {
const liveSkillDir = path.join(tempHome, 'Library/Application Support/RA-H/skills');
writeFile(path.join(liveSkillDir, 'audit.md'), LEGACY_AUDIT_SKILL);
writeFile(path.join(liveSkillDir, 'custom-capture.md'), CUSTOM_SKILL);
const skillService = await loadSkillService();
const skills = skillService.listSkills();
expect(skills.map((skill) => skill.name)).toEqual([
'Create Skill',
'Custom Capture',
'Onboarding',
'Refine',
]);
expect(fs.existsSync(path.join(liveSkillDir, 'audit.md'))).toBe(false);
expect(fs.existsSync(path.join(liveSkillDir, 'custom-capture.md'))).toBe(true);
});
it('supports normalized skill CRUD against the live skills folder', async () => {
const skillService = await loadSkillService();
const content = `---
name: Capture Source
description: "Preserve raw source while writing a strong description."
---
# Capture Source
`;
expect(skillService.writeSkill('Capture Source', content)).toEqual({ success: true });
const created = skillService.readSkill('capture-source');
expect(created?.name).toBe('Capture Source');
expect(created?.description).toContain('Preserve raw source');
expect(created?.content).toContain('# Capture Source');
expect(skillService.deleteSkill('Capture Source')).toEqual({ success: true });
expect(skillService.readSkill('capture-source')).toBeNull();
});
});