Files
gbrain/test/skill-catalog.test.ts
T
ca13f40820 v0.41.36.0 feat(mcp): publish agent skills (list_skills / get_skill) for thin clients (#1661)
* feat(skill-catalog): host-repo skill catalog core + mcp config keys

New src/core/skill-catalog.ts resolves the agent repo's skills dir, builds a
flat catalog, fetches one skill's prose, and gates publishing. Path confinement
(manifest-vetted lookup + realpath + SKILL.md file-type check), 256KB response
cap, frontmatter allowlist, and D7 tool cross-reference live here. config.ts
gains the mcp.publish_skills / mcp.skills_dir keys (+ KNOWN_CONFIG entries).

* feat(mcp): list_skills + get_skill read ops for thin-client skill discovery

Two read-scope, non-localOnly ops (dynamic-import skill-catalog to avoid the
cycle) let Codex/Claude Code/Perplexity discover and follow the agent's skills
over gbrain serve. Descriptions + the instructional envelope constants are
pinned in operations-descriptions.ts.

* feat(mcp): default new installs to publish skills; consent prompt on upgrade

gbrain init writes mcp.publish_skills:true (file plane) so new installs publish
by default. gbrain upgrade prompts existing installs once (DB plane), strongly
recommending opt-in, showing the resolved skills dir + that SKILL.md contents
become readable by authorized remote MCP callers.

* test(skill-catalog): catalog, security-confinement, transport-gate, description pins

40 cases: buildSkillCatalog/getSkillDetail/D7 split (skill-catalog.test.ts),
path traversal + symlink + poisoned-manifest + oversize + non-SKILL.md +
gate (skill-catalog-security.test.ts), and real dispatchToolCall gate+scope+plane
coverage (skill-catalog-transports.test.ts). Plus list_skills/get_skill
description + envelope pins.

* chore: bump version and changelog (v0.41.36.0)

MCP skill catalog (list_skills / get_skill) — thin clients can discover and
follow the agent repo's skills over gbrain serve. PR2 (tarball/install) filed
in TODOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document skill-catalog (list_skills/get_skill + mcp config keys) for v0.41.36.0

Add the src/core/skill-catalog.ts Key-files annotation to CLAUDE.md covering the
two new read-scope MCP ops, the mcp.publish_skills / mcp.skills_dir config keys,
the full trust-boundary mitigation stack, and the init/upgrade wiring.
Regenerate llms-full.txt to match (CI build-llms drift gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:11:25 -07:00

164 lines
6.9 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { join } from 'path';
import type { OperationContext } from '../src/core/operations.ts';
import {
buildSkillCatalog,
getSkillDetail,
crossReferenceTools,
oneLineDescription,
resolveSkillMdPath,
} from '../src/core/skill-catalog.ts';
const FIXTURE = join(import.meta.dir, 'fixtures', 'skill-catalog', 'skills');
/** Minimal ctx stub — the pure catalog functions only read remote/auth. */
function ctx(remote: boolean, scopes?: string[]): OperationContext {
return {
remote,
auth: scopes ? { token: 't', clientId: 'c', scopes } : undefined,
config: {} as OperationContext['config'],
engine: null as unknown as OperationContext['engine'],
logger: { info() {}, warn() {}, error() {} },
dryRun: false,
sourceId: 'default',
} as OperationContext;
}
describe('buildSkillCatalog', () => {
test('lists real skills, excludes _conventions, derives broken', () => {
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
const names = res.skills.map(s => s.name).sort();
expect(names).toContain('brain-ops');
expect(names).toContain('query-helper');
expect(names).toContain('broken'); // malformed frontmatter → derived name, listed
expect(names).not.toContain('_conventions');
expect(res.count).toBe(res.skills.length);
expect(res.schema_version).toBe(1);
expect(res.skills_dir_source).toBe('config');
});
test('malformed-frontmatter skill is listed with empty triggers, no throw', () => {
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
const broken = res.skills.find(s => s.name === 'broken')!;
expect(broken).toBeDefined();
expect(broken.triggers).toEqual([]);
expect(broken.tools).toEqual([]);
expect(broken.writes_pages).toBe(false);
});
test('triggers union frontmatter + RESOLVER.md, deduped', () => {
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
const qh = res.skills.find(s => s.name === 'query-helper')!;
// frontmatter trigger + the RESOLVER.md row both present
expect(qh.triggers).toContain('find a page');
expect(qh.triggers).toContain('where is my note');
});
test('D7 usable/unavailable split honors caller scope', () => {
const read = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config')
.skills.find(s => s.name === 'brain-ops')!;
expect(read.usable_tools.sort()).toEqual(['query', 'search']);
// put_page is write-scope (blocked for read), web_search isn't an op at all
expect(read.unavailable_tools.sort()).toEqual(['put_page', 'web_search']);
const admin = buildSkillCatalog(ctx(true, ['admin']), FIXTURE, 'config')
.skills.find(s => s.name === 'brain-ops')!;
expect(admin.usable_tools.sort()).toEqual(['put_page', 'query', 'search']);
expect(admin.unavailable_tools).toEqual(['web_search']);
});
test('local caller (remote=false) can use any real op', () => {
const local = buildSkillCatalog(ctx(false), FIXTURE, 'config')
.skills.find(s => s.name === 'brain-ops')!;
expect(local.usable_tools.sort()).toEqual(['put_page', 'query', 'search']);
expect(local.unavailable_tools).toEqual(['web_search']); // still not an op
});
test('section filter narrows the result set', () => {
const all = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
const someSection = all.skills[0].section;
const filtered = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config', {
section: someSection,
});
expect(filtered.skills.every(s => s.section === someSection)).toBe(true);
const nomatch = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config', {
section: 'no-such-section-xyz',
});
expect(nomatch.skills).toEqual([]);
});
test('instructions envelope present and shaped', () => {
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
expect(res.instructions.fetch_op).toBe('get_skill');
expect(res.instructions.how_to_use.length).toBeGreaterThan(0);
expect(res.instructions.available_brain_tools).toContain('search');
// read scope must NOT advertise a write op as available
expect(res.instructions.available_brain_tools).not.toContain('put_page');
});
test('empty dir → count 0 but instructions still present', () => {
const empty = join(import.meta.dir, 'fixtures', 'skill-catalog', 'does-not-exist');
const res = buildSkillCatalog(ctx(true, ['read']), empty, 'config');
expect(res.count).toBe(0);
expect(res.skills).toEqual([]);
expect(res.instructions.fetch_op).toBe('get_skill');
});
});
describe('getSkillDetail', () => {
test('returns prose body + allowlisted frontmatter (drops writes_to/sources)', () => {
const res = getSkillDetail(ctx(true, ['read']), FIXTURE, 'brain-ops');
expect(res.name).toBe('brain-ops');
expect(res.body).toContain('Brain-first lookup');
expect(res.body).not.toContain('---'); // fence stripped
// allowlist: name/description/triggers/tools/writes_pages/mutating only
const fmKeys = Object.keys(res.frontmatter).sort();
expect(fmKeys).not.toContain('writes_to');
expect(fmKeys).not.toContain('sources');
expect(fmKeys).not.toContain('raw');
expect(res.frontmatter.writes_pages).toBe(true);
// the dropped fields must not leak anywhere in the response
const blob = JSON.stringify(res);
expect(blob).not.toContain('/abs/path/should/be/dropped.ts');
expect(blob).not.toContain('people/');
});
test('mirrors the D7 tool split + client_guidance', () => {
const res = getSkillDetail(ctx(true, ['read']), FIXTURE, 'brain-ops');
expect(res.usable_tools.sort()).toEqual(['query', 'search']);
expect(res.unavailable_tools.sort()).toEqual(['put_page', 'web_search']);
expect(res.client_guidance.mutating).toBe(true);
expect(res.client_guidance.protocol.length).toBeGreaterThan(0);
});
});
describe('oneLineDescription', () => {
test('prefers frontmatter description', () => {
expect(oneLineDescription('description: hello there', '# h\nbody')).toBe('hello there');
});
test('falls back to first prose line, skipping headings', () => {
expect(oneLineDescription('', '# Title\n\nFirst real line.')).toBe('First real line.');
});
test('empty when nothing usable', () => {
expect(oneLineDescription('', '# only a heading')).toBe('');
});
});
describe('crossReferenceTools', () => {
test('unknown tool is unavailable; read op usable for read scope', () => {
const { usable_tools, unavailable_tools } = crossReferenceTools(
['search', 'put_page', 'totally_not_an_op'],
ctx(true, ['read']),
);
expect(usable_tools).toEqual(['search']);
expect(unavailable_tools.sort()).toEqual(['put_page', 'totally_not_an_op']);
});
});
describe('resolveSkillMdPath (happy path)', () => {
test('resolves a real skill to its SKILL.md', () => {
const p = resolveSkillMdPath(FIXTURE, 'brain-ops');
expect(p.endsWith('/brain-ops/SKILL.md')).toBe(true);
});
});