feat(lib): bundle MCP servers from .common/lib into factory/lib/

Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

.gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc)
bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:17:12 +03:00
parent 9eeae13e72
commit ca669d96e1
116 changed files with 25331 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { extractLinks } from './parser.js';
import { listPages, readPageContent, type ListOptions } from './corpus.js';
import { buildGraphFromParsed, WikiGraph, type ParsedPage } from './graph.js';
interface CacheEntry {
mtimeMs: number;
page: ParsedPage;
}
/**
* mtime-revalidated graph cache.
* First `load` builds the whole corpus; later loads `stat` files and re-read +
* re-parse only the ones whose mtime changed (or that appeared). Removed files
* drop out. No on-disk index, always fresh.
*/
export class GraphCache {
private entries = new Map<string, CacheEntry>();
private graph: WikiGraph | null = null;
private signature: string | null = null;
private readonly opts: ListOptions;
/** Count of file reads performed across this cache's lifetime (for tests/metrics). */
reads = 0;
constructor(opts: ListOptions = {}) {
this.opts = opts;
}
load(corpusDir: string): WikiGraph {
const files = listPages(corpusDir, this.opts);
const signature = files
.map((f) => `${f.path}:${f.mtimeMs}`)
.sort()
.join('|');
if (this.graph && this.signature === signature) return this.graph;
const live = new Set<string>();
for (const f of files) {
live.add(f.path);
const cached = this.entries.get(f.path);
if (cached && cached.mtimeMs === f.mtimeMs) continue; // unchanged → keep parsed
const content = readPageContent(f);
this.reads++;
this.entries.set(f.path, {
mtimeMs: f.mtimeMs,
page: { slug: f.slug, dir: f.dir, links: extractLinks(content) },
});
}
// drop deleted files
for (const path of [...this.entries.keys()]) {
if (!live.has(path)) this.entries.delete(path);
}
const pages = [...this.entries.values()].map((e) => e.page);
this.graph = buildGraphFromParsed(pages);
this.signature = signature;
return this.graph;
}
}

View File

@@ -0,0 +1,59 @@
import { readdirSync, statSync, readFileSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
export interface PageFile {
/** Page basename without `.md`, original case. */
slug: string;
/** Directory relative to corpus root, '' for root-level pages. POSIX-ish, using OS sep. */
dir: string;
/** Absolute path on disk. */
path: string;
/** Last-modified time in ms (for cache revalidation). */
mtimeMs: number;
}
/**
* Top-level dirs excluded from the graph by default. In the Karpathy wiki schema
* `raw/` (verbatim ingested docs) and `sources/` (provenance summaries) are NOT
* graph pages — they share basenames with canonical concept/entity pages and would
* collapse into them (false collisions) and inject provenance edges. `assets/` is
* binary. The graph models the canonical concept/entity network only.
*/
export const DEFAULT_EXCLUDE: readonly string[] = ['raw', 'sources', 'assets'];
export interface ListOptions {
/** Top-level dir names to skip. Defaults to {@link DEFAULT_EXCLUDE}; pass `[]` to index everything. */
exclude?: readonly string[];
}
function firstSegment(dir: string): string {
if (dir === '') return '';
return dir.split(/[\\/]/)[0];
}
/** List every `.md` file under `corpusDir` (recursive, minus excluded dirs), with mtime. */
export function listPages(corpusDir: string, opts: ListOptions = {}): PageFile[] {
const exclude = new Set(opts.exclude ?? DEFAULT_EXCLUDE);
const entries = readdirSync(corpusDir, { recursive: true }) as string[];
const pages: PageFile[] = [];
for (const rel of entries) {
if (!rel.endsWith('.md')) continue;
const dirRaw = dirname(rel);
const dir = dirRaw === '.' ? '' : dirRaw;
if (exclude.has(firstSegment(dir))) continue;
const abs = join(corpusDir, rel);
const st = statSync(abs);
if (!st.isFile()) continue;
pages.push({
slug: basename(rel, '.md'),
dir,
path: abs,
mtimeMs: st.mtimeMs,
});
}
return pages;
}
export function readPageContent(page: PageFile): string {
return readFileSync(page.path, 'utf8');
}

221
lib/wiki-graph/src/graph.ts Normal file
View File

@@ -0,0 +1,221 @@
import { extractLinks, type WikiLink } from './parser.js';
import { buildSlugIndex, resolveTarget, type FileRef } from './resolver.js';
import { listPages, readPageContent, type PageFile, type ListOptions } from './corpus.js';
export interface ParsedPage {
slug: string;
dir: string;
links: WikiLink[];
}
export interface GraphStats {
nodes: number;
edges: number;
/** Connected components over the undirected projection. */
components: number;
/** Number of distinct unresolved link targets. */
dangling: number;
/** Number of basename slugs claimed by more than one file. */
collisions: number;
}
export interface OrphanReport {
/** Nodes with no incoming and no outgoing edges, sorted. */
pages: string[];
/** Distinct unresolved link targets, sorted. */
dangling: string[];
}
interface BuiltGraph {
nodes: Set<string>;
out: Map<string, Set<string>>;
in: Map<string, Set<string>>;
dangling: Set<string>;
collisions: number;
}
/** In-memory wiki link graph. Node ids are lowercased slugs. */
export class WikiGraph {
private readonly nodes: Set<string>;
private readonly out: Map<string, Set<string>>;
private readonly in: Map<string, Set<string>>;
private readonly danglingTargets: Set<string>;
private readonly collisionCount: number;
constructor(g: BuiltGraph) {
this.nodes = g.nodes;
this.out = g.out;
this.in = g.in;
this.danglingTargets = g.dangling;
this.collisionCount = g.collisions;
}
/** Shortest chain between two pages over the undirected projection; [] if none. */
path(from: string, to: string): string[] {
const start = from.toLowerCase();
const goal = to.toLowerCase();
if (!this.nodes.has(start) || !this.nodes.has(goal)) return [];
if (start === goal) return [start];
const prev = new Map<string, string | null>([[start, null]]);
const queue: string[] = [start];
while (queue.length > 0) {
const cur = queue.shift()!;
if (cur === goal) break;
for (const next of this.undirectedNeighbors(cur)) {
if (!prev.has(next)) {
prev.set(next, cur);
queue.push(next);
}
}
}
if (!prev.has(goal)) return [];
const chain: string[] = [];
let node: string | null = goal;
while (node !== null) {
chain.unshift(node);
node = prev.get(node) ?? null;
}
return chain;
}
/** Pages reachable via outgoing edges within `depth` hops, sorted. */
neighbors(node: string, depth = 1): string[] {
const start = node.toLowerCase();
if (!this.nodes.has(start)) return [];
const seen = new Set<string>([start]);
let frontier = [start];
for (let d = 0; d < depth; d++) {
const nextFrontier: string[] = [];
for (const cur of frontier) {
for (const next of this.out.get(cur) ?? []) {
if (!seen.has(next)) {
seen.add(next);
nextFrontier.push(next);
}
}
}
frontier = nextFrontier;
}
seen.delete(start);
return [...seen].sort();
}
/** Pages that link TO `node`, sorted. */
backlinks(node: string): string[] {
const key = node.toLowerCase();
return [...(this.in.get(key) ?? [])].sort();
}
orphans(): OrphanReport {
const pages: string[] = [];
for (const n of this.nodes) {
const outDeg = this.out.get(n)?.size ?? 0;
const inDeg = this.in.get(n)?.size ?? 0;
if (outDeg === 0 && inDeg === 0) pages.push(n);
}
return {
pages: pages.sort(),
dangling: [...this.danglingTargets].sort(),
};
}
stats(): GraphStats {
let edges = 0;
for (const set of this.out.values()) edges += set.size;
return {
nodes: this.nodes.size,
edges,
components: this.countComponents(),
dangling: this.danglingTargets.size,
collisions: this.collisionCount,
};
}
private undirectedNeighbors(node: string): Set<string> {
const acc = new Set<string>();
for (const n of this.out.get(node) ?? []) acc.add(n);
for (const n of this.in.get(node) ?? []) acc.add(n);
return acc;
}
private countComponents(): number {
const seen = new Set<string>();
let count = 0;
for (const start of this.nodes) {
if (seen.has(start)) continue;
count++;
const stack = [start];
seen.add(start);
while (stack.length > 0) {
const cur = stack.pop()!;
for (const next of this.undirectedNeighbors(cur)) {
if (!seen.has(next)) {
seen.add(next);
stack.push(next);
}
}
}
}
return count;
}
}
/** Build a graph from already-parsed pages (no disk, no parsing). */
export function buildGraphFromParsed(pages: ParsedPage[]): WikiGraph {
const refs: FileRef[] = pages.map((p) => ({ slug: p.slug, dir: p.dir }));
const index = buildSlugIndex(refs);
let collisions = 0;
for (const bucket of index.values()) if (bucket.length > 1) collisions++;
const nodes = new Set<string>();
const out = new Map<string, Set<string>>();
const inAdj = new Map<string, Set<string>>();
const dangling = new Set<string>();
for (const p of pages) {
const node = p.slug.toLowerCase();
nodes.add(node);
if (!out.has(node)) out.set(node, new Set());
if (!inAdj.has(node)) inAdj.set(node, new Set());
}
for (const p of pages) {
const node = p.slug.toLowerCase();
for (const link of p.links) {
const resolved = resolveTarget(link.target, p.dir, index);
if (resolved === null) {
// resolution is case-insensitive, so dedupe dangling on the same key —
// [[Foo]] and [[foo]] are the one missing page, not two
dangling.add(link.target.toLowerCase());
continue;
}
if (resolved.node === node) continue; // skip self-links
out.get(node)!.add(resolved.node);
inAdj.get(resolved.node)!.add(node);
}
}
return new WikiGraph({ nodes, out, in: inAdj, dangling, collisions });
}
/** Build a graph from already-read pages (parses content, no disk access). */
export function buildGraph(
pages: Array<{ slug: string; dir: string; content: string }>,
): WikiGraph {
return buildGraphFromParsed(
pages.map((p) => ({ slug: p.slug, dir: p.dir, links: extractLinks(p.content) })),
);
}
/** Read a `.wiki/` corpus from disk and build the graph. Provenance dirs excluded by default. */
export function buildGraphFromDir(corpusDir: string, opts: ListOptions = {}): WikiGraph {
const files: PageFile[] = listPages(corpusDir, opts);
const pages = files.map((f) => ({
slug: f.slug,
dir: f.dir,
content: readPageContent(f),
}));
return buildGraph(pages);
}

View File

@@ -0,0 +1,25 @@
export { extractLinks, type WikiLink } from './parser.js';
export {
buildSlugIndex,
resolveTarget,
type FileRef,
type ResolveResult,
type SlugIndex,
} from './resolver.js';
export {
listPages,
readPageContent,
DEFAULT_EXCLUDE,
type PageFile,
type ListOptions,
} from './corpus.js';
export {
WikiGraph,
buildGraph,
buildGraphFromParsed,
buildGraphFromDir,
type ParsedPage,
type GraphStats,
type OrphanReport,
} from './graph.js';
export { GraphCache } from './cache.js';

View File

@@ -0,0 +1,30 @@
export interface WikiLink {
/** Raw target slug as written inside [[...]], left of any `|`. */
target: string;
/** Display alias after `|`, or null when absent. */
alias: string | null;
}
const LINK_RE = /\[\[([^\]]+)\]\]/g;
/**
* Extract `[[wikilinks]]` from page content.
* `[[slug]]` → { target: 'slug', alias: null }.
* `[[slug|Label]]` → { target: 'slug', alias: 'Label' }.
*/
export function extractLinks(content: string): WikiLink[] {
const links: WikiLink[] = [];
for (const match of content.matchAll(LINK_RE)) {
const inner = match[1];
const pipe = inner.indexOf('|');
if (pipe === -1) {
links.push({ target: inner.trim(), alias: null });
} else {
links.push({
target: inner.slice(0, pipe).trim(),
alias: inner.slice(pipe + 1).trim(),
});
}
}
return links;
}

View File

@@ -0,0 +1,49 @@
export interface FileRef {
/** Page basename without `.md`, original case. */
slug: string;
/** Directory of the file relative to the corpus root (e.g. 'concepts'). */
dir: string;
}
export interface ResolveResult {
/** Canonical node id — lowercased slug. */
node: string;
/** True when more than one file shares this basename slug. */
collision: boolean;
}
/** Index of lowercased-slug → all files claiming that slug. */
export type SlugIndex = Map<string, FileRef[]>;
export function buildSlugIndex(refs: FileRef[]): SlugIndex {
const index: SlugIndex = new Map();
for (const ref of refs) {
const key = ref.slug.toLowerCase();
const bucket = index.get(key);
if (bucket) bucket.push(ref);
else index.set(key, [ref]);
}
return index;
}
/**
* Resolve a `[[target]]` to a canonical node id.
* - case-insensitive (slugs are lowercase-kebab by convention);
* - unresolved targets (placeholders, links to uncreated pages) → null (dangling);
* - basename collision: node id is the bare slug, so ALL files sharing a basename
* collapse into ONE node — their edges merge and the pages become indistinguishable
* in the graph. There is no file-pick (the v1 graph keys on slug, not path); `collision`
* is the only signal. Acceptable while collisions are rare (0 on modulair); a
* dir-qualified node id is a phase-2 decision. `_fromDir` is kept for that future
* same-dir disambiguation. See wiki-graph-design.md §5.
*/
export function resolveTarget(
target: string,
_fromDir: string,
index: SlugIndex,
): ResolveResult | null {
const key = target.trim().toLowerCase();
const bucket = index.get(key);
if (!bucket || bucket.length === 0) return null;
return { node: key, collision: bucket.length > 1 };
}

View File

@@ -0,0 +1,55 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type ServerResult,
} from '@modelcontextprotocol/sdk/types.js';
import { readFileSync } from 'node:fs';
import { GraphCache } from './cache.js';
import { makeWikiGraphTools } from './tools.js';
function readPackageVersion(): string {
const pkgUrl = new URL('../package.json', import.meta.url);
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8')) as { version?: unknown };
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
throw new Error(`package.json at ${pkgUrl.pathname} missing string "version" field`);
}
return pkg.version;
}
async function main(): Promise<void> {
const cache = new GraphCache();
const tools = makeWikiGraphTools(cache);
const byName = new Map(tools.map((t) => [t.name, t]));
const server = new Server(
{ name: 'wiki-graph', version: readPackageVersion() },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<ServerResult> => {
const tool = byName.get(req.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
isError: true,
};
}
// ToolResult is a structural CallToolResult; cast over the widened SDK union.
return tool.handler(req.params.arguments ?? {}) as ServerResult;
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
await main();

166
lib/wiki-graph/src/tools.ts Normal file
View File

@@ -0,0 +1,166 @@
import { GraphCache } from './cache.js';
import type { WikiGraph } from './graph.js';
export interface ToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
export interface ToolDef {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, unknown>;
required: string[];
};
handler: (args: Record<string, unknown>) => ToolResult;
}
function text(s: string, isError = false): ToolResult {
return { content: [{ type: 'text', text: s }], isError };
}
const CORPUS_PROP = {
corpus: {
type: 'string',
description: 'Absolute path to the `.wiki/` corpus directory.',
},
};
/**
* Build the 5 v1 wiki-graph tools over a shared mtime cache.
* Each tool takes `corpus` (abs path to a `.wiki/`); the engine parses
* server-side, so the corpus never enters the LLM context.
*/
export function makeWikiGraphTools(cache: GraphCache): ToolDef[] {
function load(args: Record<string, unknown>): WikiGraph | ToolResult {
const corpus = args.corpus;
if (typeof corpus !== 'string' || corpus.length === 0) {
return text('Missing required "corpus" (absolute path to a .wiki/ directory).', true);
}
try {
return cache.load(corpus);
} catch (e) {
return text(`Failed to read corpus "${corpus}": ${(e as Error).message}`, true);
}
}
function str(args: Record<string, unknown>, key: string): string | null {
const v = args[key];
return typeof v === 'string' && v.length > 0 ? v : null;
}
return [
{
name: 'path',
description:
'Shortest chain of pages between two wiki pages (undirected). The main relational query — "what connects X and Y".',
inputSchema: {
type: 'object',
properties: {
...CORPUS_PROP,
from: { type: 'string', description: 'Start page slug.' },
to: { type: 'string', description: 'Target page slug.' },
},
required: ['corpus', 'from', 'to'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const from = str(args, 'from');
const to = str(args, 'to');
if (!from || !to) return text('path requires "from" and "to" slugs.', true);
const chain = g.path(from, to);
if (chain.length === 0) return text(`No path between "${from}" and "${to}".`);
return text(`Path (${chain.length} nodes): ${chain.join(' → ')}`);
},
},
{
name: 'neighbors',
description: 'Pages reachable via outgoing links within N hops (direction respected).',
inputSchema: {
type: 'object',
properties: {
...CORPUS_PROP,
node: { type: 'string', description: 'Page slug.' },
depth: { type: 'number', description: 'Hop radius (default 1).' },
},
required: ['corpus', 'node'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const node = str(args, 'node');
if (!node) return text('neighbors requires a "node" slug.', true);
const depthRaw = args.depth;
const depth =
typeof depthRaw === 'number' && Number.isInteger(depthRaw) && depthRaw >= 1
? depthRaw
: 1;
const list = g.neighbors(node, depth);
if (list.length === 0) {
return text(`"${node}" has no outgoing links within depth ${depth}.`);
}
return text(`Neighbors of "${node}" (depth ${depth}): ${list.join(', ')}`);
},
},
{
name: 'backlinks',
description: 'Pages that link TO this page (incoming references).',
inputSchema: {
type: 'object',
properties: {
...CORPUS_PROP,
node: { type: 'string', description: 'Page slug.' },
},
required: ['corpus', 'node'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const node = str(args, 'node');
if (!node) return text('backlinks requires a "node" slug.', true);
const list = g.backlinks(node);
if (list.length === 0) return text(`Nothing links to "${node}".`);
return text(`Backlinks of "${node}": ${list.join(', ')}`);
},
},
{
name: 'orphans',
description: 'Wiki health: pages with no links at all, plus dangling (unresolved) link targets.',
inputSchema: {
type: 'object',
properties: { ...CORPUS_PROP },
required: ['corpus'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const o = g.orphans();
return text(
`Orphan pages (${o.pages.length}): ${o.pages.join(', ') || '—'}\n` +
`Dangling links (${o.dangling.length}): ${o.dangling.join(', ') || '—'}`,
);
},
},
{
name: 'stats',
description: 'Corpus counters: nodes, edges, connected components, dangling links, basename collisions.',
inputSchema: {
type: 'object',
properties: { ...CORPUS_PROP },
required: ['corpus'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const s = g.stats();
return text(
`nodes=${s.nodes} edges=${s.edges} components=${s.components} ` +
`dangling=${s.dangling} collisions=${s.collisions}`,
);
},
},
];
}