/** * Generates docs/tools.md from the tool module catalog in src/tools. * * The doc is fully derived from the ToolDefinition objects (names, descriptions, * input schemas) and the preset table, so it cannot drift from the registry. * Run `npm run docs:tools` after changing tool definitions; `--check` exits * non-zero when the checked-in file is stale (used by tests/tools/doc.test.ts). */ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { MODULES, PRESETS, PRESET_NAMES, DEFAULT_PRESET } from '../src/tools/index.js'; import type { JsonSchemaProperty, ToolDefinition } from '../src/tools/module.js'; export const DOC_PATH = fileURLToPath(new URL('../docs/tools.md', import.meta.url)); function inline(text: string): string { return text.replace(/\s+/g, ' ').trim(); } /** Renders a JSON Schema property as a short type label, e.g. `array of string`. */ function formatType(schema: JsonSchemaProperty): string { if (Array.isArray(schema.enum) && schema.enum.length > 0) { return schema.enum.map((value) => `\`${String(value)}\``).join(' | '); } const type = typeof schema.type === 'string' ? schema.type : schema.type.join(' | '); if (type === 'array' && schema.items) { return `array of ${formatType(schema.items)}`; } return type; } function renderTool(definition: ToolDefinition): string[] { const lines: string[] = [`### \`${definition.name}\``, '']; if (definition.annotations?.readOnlyHint === true) { lines.push('_Read-only._', ''); } lines.push(inline(definition.description), ''); const schema = definition.inputSchema; const properties = Object.entries(schema.properties ?? {}); if (properties.length === 0) { lines.push('No parameters.', ''); return lines; } const required = new Set(schema.required ?? []); lines.push('Parameters:', ''); for (const [name, property] of properties) { const flag = required.has(name) ? 'required' : 'optional'; const description = property.description ? ` - ${inline(property.description)}` : ''; lines.push(`- \`${name}\` (${formatType(property)}, ${flag})${description}`); } lines.push(''); return lines; } /** Renders a markdown table with padded cells, matching prettier's output. */ function renderTable(header: string[], rows: string[][]): string[] { const widths = header.map((cell, index) => Math.max(cell.length, ...rows.map((row) => row[index]?.length ?? 0)) ); const line = (cells: string[]) => `| ${cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' | ')} |`; return [ line(header), `| ${widths.map((width) => '-'.repeat(Math.max(width, 3))).join(' | ')} |`, ...rows.map(line), ]; } function renderPresetTable(): string[] { const rows = MODULES.map((module) => [ module.privileged ? `\`${module.name}\` (privileged)` : `\`${module.name}\``, String(module.tools.length), ...PRESET_NAMES.map((preset) => (PRESETS[preset]?.includes(module.name) ? 'yes' : '-')), ]); return renderTable(['Module', 'Tools', ...PRESET_NAMES], rows); } export function renderToolsDoc(): string { const toolCount = MODULES.reduce((total, module) => total + module.tools.length, 0); const lines: string[] = [ '', '', '# Tool reference', '', `The server exposes ${toolCount} tools grouped into ${MODULES.length} modules. Which modules are`, 'enabled depends on `--tool-preset` or `--tools`; see', '[Tool modules and presets](../README.md#tool-modules-and-presets) in the README.', '', `Presets are cumulative and \`${DEFAULT_PRESET}\` is the default. Privileged modules require the`, 'Mozilla-internal build and `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`; the public package drops them.', '', '## Modules and presets', '', ...renderPresetTable(), '', '## Contents', '', ...MODULES.map((module) => `- [${module.name}](#${module.name})`), '', ]; for (const module of MODULES) { lines.push(`## ${module.name}`, '', inline(module.description), ''); if (module.privileged) { lines.push('Privileged module: requires the Mozilla-internal build.', ''); } for (const { definition } of module.tools) { lines.push(...renderTool(definition)); } } return `${lines.join('\n').trimEnd()}\n`; } function main(): void { const content = renderToolsDoc(); if (process.argv.includes('--check')) { const current = readFileSync(DOC_PATH, 'utf8'); if (current !== content) { console.error('docs/tools.md is out of date. Run: npm run docs:tools'); process.exit(1); } console.log('docs/tools.md is up to date.'); return; } writeFileSync(DOC_PATH, content); console.log(`Wrote ${DOC_PATH}`); } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { main(); }