feat: MCP server v1.2.0 — guide system + read/write guide tools

Adds full guide system to MCP standalone server. System guides (schema,
creating-nodes, edges, dimensions, extract) are bundled and immutable.
Users can create up to 10 custom guides via rah_write_guide. Syncs guide
infrastructure from main repo to OS repo app code.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-08 11:15:13 +11:00
co-authored by Claude Sonnet 4.5
parent 2465e7562c
commit be675be4e0
19 changed files with 985 additions and 18 deletions
+118 -15
View File
@@ -6,64 +6,110 @@ import matter from 'gray-matter';
export interface GuideMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Guide extends GuideMeta {
content: string;
}
const MAX_USER_GUIDES = 10;
const GUIDES_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/guides'
);
const BUNDLED_GUIDES_DIR = path.join(
const SYSTEM_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides/system'
);
const USER_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides'
);
// System guide names (immutable, always re-seeded)
const SYSTEM_GUIDE_NAMES = new Set([
'schema',
'creating-nodes',
'edges',
'dimensions',
'extract',
]);
function ensureGuidesDir(): void {
if (!fs.existsSync(GUIDES_DIR)) {
fs.mkdirSync(GUIDES_DIR, { recursive: true });
}
}
function seedDefaultGuides(): void {
if (!fs.existsSync(BUNDLED_GUIDES_DIR)) return;
/**
* Seed system guides — always overwritten on app start to stay current.
* User guides are only seeded if they don't already exist.
*/
function seedGuides(): void {
// Always re-seed system guides (immutable, kept up to date)
if (fs.existsSync(SYSTEM_GUIDES_DIR)) {
const systemFiles = fs.readdirSync(SYSTEM_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of systemFiles) {
const dest = path.join(GUIDES_DIR, file);
fs.copyFileSync(path.join(SYSTEM_GUIDES_DIR, file), dest);
}
}
const bundled = fs.readdirSync(BUNDLED_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of bundled) {
const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(BUNDLED_GUIDES_DIR, file), dest);
// Seed default user guides only if they don't exist
if (fs.existsSync(USER_GUIDES_DIR)) {
const userFiles = fs.readdirSync(USER_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of userFiles) {
const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(USER_GUIDES_DIR, file), dest);
}
}
}
}
let initialized = false;
function init(): void {
if (initialized) return;
ensureGuidesDir();
const existing = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
if (existing.length === 0) {
seedDefaultGuides();
}
seedGuides();
initialized = true;
}
function isSystemGuide(filename: string): boolean {
const name = filename.replace('.md', '');
return SYSTEM_GUIDE_NAMES.has(name);
}
export function listGuides(): GuideMeta[] {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.map(file => {
const guides = files.map(file => {
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
const { data } = matter(raw);
const immutable = isSystemGuide(file) || data.immutable === true;
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
immutable,
};
});
// System guides first, then user guides alphabetically
return guides.sort((a, b) => {
if (a.immutable && !b.immutable) return -1;
if (!a.immutable && b.immutable) return 1;
return a.name.localeCompare(b.name);
});
}
export function readGuide(name: string): Guide | null {
init();
// Try exact filename first, then lowercase
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
@@ -74,9 +120,11 @@ export function readGuide(name: string): Guide | null {
if (fs.existsSync(filepath)) {
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
const immutable = isSystemGuide(filename) || data.immutable === true;
return {
name: data.name || name,
description: data.description || '',
immutable,
content: content.trim(),
};
}
@@ -85,9 +133,64 @@ export function readGuide(name: string): Guide | null {
return null;
}
export function writeGuide(name: string, content: string): void {
export function writeGuide(name: string, content: string): { success: boolean; error?: string } {
init();
const filename = `${name.toLowerCase()}.md`;
// Reject writes to immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be modified.` };
}
// Check user guide cap for new guides
const filepath = path.join(GUIDES_DIR, filename);
if (!fs.existsSync(filepath)) {
const userGuideCount = getUserGuideCount();
if (userGuideCount >= MAX_USER_GUIDES) {
return { success: false, error: `Maximum of ${MAX_USER_GUIDES} custom guides reached. Delete a guide first.` };
}
}
fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
export function deleteGuide(name: string): { success: boolean; error?: string } {
init();
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
// Reject deletes of immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be deleted.` };
}
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
return { success: true };
}
}
return { success: false, error: `Guide "${name}" not found.` };
}
export function getUserGuideCount(): number {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.filter(f => !isSystemGuide(f)).length;
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const guides = listGuides();
const systemCount = guides.filter(g => g.immutable).length;
const userCount = guides.filter(g => !g.immutable).length;
return {
userGuides: userCount,
maxUserGuides: MAX_USER_GUIDES,
systemGuides: systemCount,
};
}