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
+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();
});
});
});