* feat(mcp): make the agent surface self-describing (staging _meta, company identity, clean summaries)
A pass over the MCP server's agent-facing surface so an agent can act
correctly without parsing description prose:
- Machine-readable staging contract: deriveToolMeta() attaches _meta to
tools/list (and search detail=full) — { requires_approval, approve_tool,
preflight? } — keyed off the STAGED_OPERATION_SCHEMA output schema. Literal
_meta (e.g. UI widget hints) wins on collision. TOOL_PREFLIGHT_MAP names the
read-only pre-flight for the few writes that have one (year-end readiness,
VAT validate, depreciation proposal). Guarded by staging-meta.test.ts.
- Company identity in gnubok_get_agent_briefing: returns a `company` block
(id, name, org_number, entity_type, accounting_method) so the agent can
confirm WHICH entity it operates on and pick the right settlement account
(accrual = credit 1510; cash = debit 19xx) before any write. Best-effort —
a missing row never blocks the briefing. Covered by agent-briefing.test.ts.
- toSummary(): trims the long, keyword-stuffed SKILL.md frontmatter into clean
one-liners for gnubok_list_skills / gnubok_get_agent_briefing so the client
never truncates one mid-sentence; full bodies stay in gnubok_load_skill.
Covered by to-summary.test.ts.
- bank-reconciliation skill: a match/link decision tree (what you have x
whether a verifikat exists) and kontant- vs faktureringsmetoden settlement
accounts.
- Prose/description clarifications: "Stages"/"Stages for approval" on the
link tools; propose_dispositioner/accruals note there is no dedicated MCP
poster; server-info documents _meta and the legacy gnubok_ tool prefix.
All 34 touched MCP tests pass. Merged cleanly on top of #759/#760 (server.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): make accounting_method description state the full settlement posting
Review (swedish-accounting-compliance): the agent-briefing schema described
accrual as "credit 1510 on payment", which reads as a one-sided entry. Spell
out both sides (payment debits 19xx AND credits 1510) so an agent can't infer a
single-leg posting that violates BFL 5 kap double-entry. Mirrors the precision
already in the bank-reconciliation skill body. Payload-size guard still passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
/**
|
|
* Atom-registry → MCP skill adapter.
|
|
*
|
|
* The in-app composer (lib/agent/composer/) writes to `agent_atom_registry`;
|
|
* each row points to a SKILL.md body on disk (under `.claude/skills/`). This
|
|
* loader hydrates those rows into `Skill` objects so the existing
|
|
* `gnubok_list_skills` / `gnubok_load_skill` tools can surface them to
|
|
* Claude.ai users with no further work (plan §13 MCP parity).
|
|
*
|
|
* Atom slugs match their registry id verbatim: "horizontal/swedish-vat",
|
|
* "vertical/konsult-it", "modifier/holding-ab". Workflow skills keep their
|
|
* flat slugs ("month-end-close") and never collide with atom slugs.
|
|
*
|
|
* Cached per-process. Reset between tests via `__resetAtomCache`.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { Skill, SkillTier } from './types'
|
|
|
|
interface AtomRegistryRow {
|
|
id: string
|
|
tier: 'horizontal' | 'vertical' | 'modifier'
|
|
title: string | null
|
|
description: string
|
|
sni_prefixes: string[] | null
|
|
body: string | null
|
|
body_path: string
|
|
}
|
|
|
|
let cache: Skill[] | null = null
|
|
|
|
/**
|
|
* SKILL.md frontmatter `description` fields are long, keyword-stuffed trigger
|
|
* lists authored for CLI skill-matching — not display copy (the project-accounting
|
|
* atom is ~1,100 chars). `gnubok_list_skills` and `gnubok_get_agent_briefing`
|
|
* surface them as one-line summaries, where the raw string gets truncated
|
|
* mid-sentence by the client. Trim to the first sentence, or a clean word
|
|
* boundary capped at `maxLen` with an ellipsis. The full text always stays
|
|
* available in the skill body (gnubok_load_skill). Idempotent for short input.
|
|
*/
|
|
export function toSummary(description: string, maxLen = 200): string {
|
|
const text = (description ?? '').trim().replace(/\s+/g, ' ')
|
|
if (text.length <= maxLen) return text
|
|
// Prefer the first sentence when it ends within the cap.
|
|
const firstStop = text.search(/[.!?](\s|$)/)
|
|
if (firstStop !== -1 && firstStop + 1 <= maxLen) return text.slice(0, firstStop + 1)
|
|
// Otherwise cut at the last word boundary before the cap and ellipsize so the
|
|
// truncation is ours (clean) rather than the client's (mid-word).
|
|
const slice = text.slice(0, maxLen)
|
|
const lastSpace = slice.lastIndexOf(' ')
|
|
return `${(lastSpace > 0 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`
|
|
}
|
|
|
|
export async function loadAtomsAsSkills(supabase: SupabaseClient): Promise<Skill[]> {
|
|
if (cache) return cache
|
|
|
|
// `body` is read from the DB (seeded by scripts/generate-skill-bodies.ts) — not
|
|
// from disk — so skills load identically on Vercel, Docker, and self-hosted.
|
|
// `mcp_exposed` is the curation kill-switch: only atoms flagged for the MCP
|
|
// surface reach Claude (swarm-* audit skills never become atoms in the first
|
|
// place; this guards against any future row that shouldn't be end-user-loadable).
|
|
const { data, error } = await supabase
|
|
.from('agent_atom_registry')
|
|
.select('id, tier, title, description, sni_prefixes, body, body_path')
|
|
.eq('is_active', true)
|
|
.eq('mcp_exposed', true)
|
|
.is('parent_atom_id', null) // list top-level skills only; references resolve via loadReferenceById
|
|
.order('id')
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to load atom registry: ${error.message}`)
|
|
}
|
|
if (!data) {
|
|
cache = []
|
|
return cache
|
|
}
|
|
|
|
const rows = data as AtomRegistryRow[]
|
|
const out: Skill[] = []
|
|
|
|
for (const row of rows) {
|
|
let body = row.body ?? ''
|
|
if (!body) {
|
|
// Dev convenience: before `npm run skills:generate` has populated the DB,
|
|
// fall back to reading the SKILL.md from disk. In production the body is
|
|
// always seeded by the generated migration, so this path is never taken.
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
try {
|
|
body = await readFile(join(process.cwd(), row.body_path), 'utf8')
|
|
} catch {
|
|
// fall through to the skip below
|
|
}
|
|
}
|
|
if (!body) {
|
|
console.warn(`[mcp-skills] atom ${row.id}: no body in DB and no on-disk fallback — skipping`)
|
|
continue
|
|
}
|
|
}
|
|
|
|
const sniRoot = row.sni_prefixes?.[0]?.split('.')[0]
|
|
const tags = [row.tier, ...(sniRoot ? [`sni-${sniRoot}`] : [])]
|
|
|
|
out.push({
|
|
slug: row.id,
|
|
name: row.title ?? row.id,
|
|
summary: toSummary(row.description),
|
|
tags,
|
|
body,
|
|
tier: row.tier as SkillTier,
|
|
})
|
|
}
|
|
|
|
cache = out
|
|
return cache
|
|
}
|
|
|
|
/**
|
|
* Resolve a single reference child (parent_atom_id IS NOT NULL) by exact id —
|
|
* e.g. "horizontal/swedish-vat/vat-compliance-reference". References are
|
|
* deliberately excluded from `loadAtomsAsSkills` (the listed catalog), so this
|
|
* is the only path that surfaces them; gnubok_load_skill falls back here when a
|
|
* slug isn't a workflow or a top-level atom. Returns null for unknown ids,
|
|
* inactive rows, or rows the curation switch (mcp_exposed) has turned off.
|
|
*
|
|
* Not cached: references load rarely and on demand, so a per-id query is cheaper
|
|
* than holding ~90 reference bodies in the process between calls.
|
|
*/
|
|
export async function loadReferenceById(
|
|
supabase: SupabaseClient,
|
|
id: string,
|
|
): Promise<Skill | null> {
|
|
const { data, error } = await supabase
|
|
.from('agent_atom_registry')
|
|
.select('id, tier, title, description, sni_prefixes, body, body_path, is_active, mcp_exposed, parent_atom_id')
|
|
.eq('id', id)
|
|
.not('parent_atom_id', 'is', null)
|
|
.maybeSingle()
|
|
|
|
if (error) throw new Error(`Failed to load reference ${id}: ${error.message}`)
|
|
if (!data || data.is_active === false || data.mcp_exposed === false) return null
|
|
|
|
const row = data as AtomRegistryRow & { is_active: boolean; mcp_exposed: boolean }
|
|
let body = row.body ?? ''
|
|
if (!body) {
|
|
// Same dev fallback as loadAtomsAsSkills: before the seed migration runs,
|
|
// read the references/*.md straight off disk. Never taken in production.
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
try {
|
|
body = await readFile(join(process.cwd(), row.body_path), 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
if (!body) return null
|
|
}
|
|
|
|
return {
|
|
slug: row.id,
|
|
name: row.title ?? row.id,
|
|
summary: toSummary(row.description),
|
|
tags: [row.tier, 'reference'],
|
|
body,
|
|
tier: row.tier as SkillTier,
|
|
}
|
|
}
|
|
|
|
/** Test-only: clear the module-level cache so the next call re-queries. */
|
|
export function __resetAtomCache(): void {
|
|
cache = null
|
|
}
|