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:
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let nodeProject: string;
|
||||
let unknownProject: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-'));
|
||||
await mkdir(join(wikiRoot, 'node'), { recursive: true });
|
||||
await mkdir(join(wikiRoot, 'embedded'), { recursive: true });
|
||||
await mkdir(join(wikiRoot, 'cross'), { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(wikiRoot, 'node', 'yarn-windows.md'),
|
||||
'---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body',
|
||||
);
|
||||
await writeFile(
|
||||
join(wikiRoot, 'embedded', 'stm32-flash.md'),
|
||||
'---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body',
|
||||
);
|
||||
await writeFile(
|
||||
join(wikiRoot, 'cross', 'git-trick.md'),
|
||||
'---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body',
|
||||
);
|
||||
|
||||
nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
|
||||
await writeFile(join(nodeProject, 'package.json'), '{}');
|
||||
|
||||
unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
|
||||
});
|
||||
|
||||
function call(tools: ReturnType<typeof makeKnowledgeTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('knowledge.search (node project)', () => {
|
||||
it('shows node + cross by default, hides embedded', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: 'yarn' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
|
||||
expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']);
|
||||
});
|
||||
|
||||
it('domain="all" shows everything', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results.length).toBe(3);
|
||||
});
|
||||
|
||||
it('explicit domain switches filter', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
|
||||
expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.search (unknown project)', () => {
|
||||
it('returns all when cwd domain unknown', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.get', () => {
|
||||
it('returns full page including frontmatter', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' });
|
||||
expect(r.content[0].text).toContain('domain: node');
|
||||
expect(r.content[0].text).toContain('yarn body');
|
||||
});
|
||||
|
||||
it('errors on missing slug', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' });
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.suggest_promote', () => {
|
||||
it('returns candidates from project .wiki/concepts', async () => {
|
||||
await mkdir(join(nodeProject, '.wiki', 'concepts'), { recursive: true });
|
||||
await writeFile(
|
||||
join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'),
|
||||
'---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.',
|
||||
);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.suggest_promote', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.candidates).toHaveLength(1);
|
||||
expect(parsed.candidates[0].slug).toBe('docker-quirk');
|
||||
expect(parsed.candidates[0].suggested_domain).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedCommit extends CommitFileArgs {}
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}): {
|
||||
backend: Backend;
|
||||
commits: RecordedCommit[];
|
||||
files: Record<string, FileWithSha>;
|
||||
} {
|
||||
const files = { ...initial };
|
||||
const commits: RecordedCommit[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args) {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixedNow = () => new Date('2026-04-29T12:00:00Z');
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'projects-wiki', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
async function makeCacheFile(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kw-cache-'));
|
||||
const f = join(dir, 'tasks.json');
|
||||
await writeCache(f, fixture);
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('knowledge.ingest — preview', () => {
|
||||
it('returns preview without committing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'tasks-routing-rule',
|
||||
body: 'Read local STATUS.md for current project.',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/concepts/tasks-routing-rule.md');
|
||||
expect(parsed.content).toContain('title: tasks-routing-rule');
|
||||
expect(parsed.content).toContain('source_project: modules-db');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project with hint', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.ingest — confirm', () => {
|
||||
it('writes page + index + log (3 commits) and removes (none yet) placeholder', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'routing-rule',
|
||||
body: 'rule body',
|
||||
frontmatter: { title: 'Tasks Routing Rule', tags: ['routing'] },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits).toHaveLength(3);
|
||||
const paths = parsed.commits.map((c: { path: string }) => c.path);
|
||||
expect(paths).toEqual(['.wiki/concepts/routing-rule.md', '.wiki/index.md', '.wiki/log.md']);
|
||||
expect(files['alpha:.wiki/concepts/routing-rule.md'].content).toContain('Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/index.md'].content).toContain('- [routing-rule](concepts/routing-rule.md) — Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('## [2026-04-29] ingest | concepts/routing-rule');
|
||||
// Three commits = page + index + log
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
|
||||
it('routes victor/books to owner=victor', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'victor/books',
|
||||
type: 'concepts',
|
||||
slug: 'pagination',
|
||||
body: 'how to paginate',
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('victor');
|
||||
expect(parsed.target_repo).toBe('books');
|
||||
expect(commits[0].user).toBe('victor');
|
||||
expect(commits[0].repo).toBe('books');
|
||||
});
|
||||
|
||||
it('rejects when page already exists', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/concepts/x.md': { content: '---\ntitle: x\n---\n', sha: 's' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('already exists');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects unknown target_project (cache miss)', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'ghost/repo',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('not in cache');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('write tools disabled when no backend/auth', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('Write tools disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.promote', () => {
|
||||
it('preview returns sources content with raw_path frontmatter', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/raw/x.md': { content: '# raw\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'x',
|
||||
body: 'summary of raw',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/sources/x.md');
|
||||
expect(parsed.content).toContain('raw_path: raw/x.md');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'alpha',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects when raw page is missing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'missing',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('raw page not found');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('confirm writes sources + updates index/log', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({
|
||||
'alpha:.wiki/raw/notes.md': { content: '# notes\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'notes',
|
||||
body: 'summary text',
|
||||
frontmatter: { title: 'Notes Summary' },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits.map((c: { path: string }) => c.path)).toEqual([
|
||||
'.wiki/sources/notes.md',
|
||||
'.wiki/index.md',
|
||||
'.wiki/log.md',
|
||||
]);
|
||||
expect(files['alpha:.wiki/sources/notes.md'].content).toContain('raw_path: raw/notes.md');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('promote | raw/notes → sources/notes');
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user