diff --git a/.gitignore b/.gitignore index deed335..2e876db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules/ dist/ + +.projects/cache +.projects/vault .env diff --git a/README.md b/README.md index efbe320..3c34390 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,20 @@ Example: bitrefill --json search-products --query "Amazon" --per_page 1 | jq '.products[0].name' ``` +### LLM context (`llm-context`) + +Generates Markdown from the MCP `tools/list` response: tool names, descriptions, parameter tables, JSON Schema, example `bitrefill …` invocations, and example MCP `tools/call` payloads. Intended for **CLAUDE.md**, **Cursor** rules, or **`.github/copilot-instructions.md`**. + +- **stdout** by default, or **`-o` / `--output `** to write a file. +- Uses the same auth as other commands (`--api-key`, `BITREFILL_API_KEY`, or OAuth). +- The generated **Connection** line shows a redacted MCP URL (`…/mcp/`), not your real key. + +```bash +export BITREFILL_API_KEY=YOUR_API_KEY +bitrefill llm-context -o BITREFILL-MCP.md +# or: bitrefill llm-context > BITREFILL-MCP.md +``` + ### Examples ```bash @@ -83,6 +97,9 @@ bitrefill --help # Clear stored credentials bitrefill logout + +# Export tool docs for coding agents (see "LLM context" above) +bitrefill llm-context -o BITREFILL-MCP.md ``` ## Development diff --git a/src/index.ts b/src/index.ts index 82c1bcd..fd64f17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,10 @@ import { type OutputFormatter, } from './output.js'; import { buildOptionsForTool, parseToolArgs } from './tools.js'; +import { generateLlmContextMarkdown } from './llm-context.js'; + +/** Subcommands defined by the CLI; MCP tools with the same name are skipped. */ +const RESERVED_TOOL_NAMES = new Set(['logout', 'llm-context']); const BASE_MCP_URL = 'https://api.bitrefill.com/mcp'; const CALLBACK_PORT = 8098; @@ -330,8 +334,30 @@ async function main(): Promise { } }); + program + .command('llm-context') + .description( + 'Emit MCP tools reference as Markdown (for CLAUDE.md, Cursor rules, Copilot instructions)' + ) + .option( + '-o, --output ', + 'Write Markdown to a file instead of stdout' + ) + .action((opts: { output?: string }) => { + const md = generateLlmContextMarkdown(tools, { + mcpUrl, + programName: program.name(), + }); + if (opts.output) { + fs.writeFileSync(opts.output, md, 'utf-8'); + } else { + process.stdout.write(md); + } + }); + // Register each MCP tool as a subcommand for (const tool of tools) { + if (RESERVED_TOOL_NAMES.has(tool.name)) continue; const sub = program .command(tool.name) .description(tool.description ?? ''); diff --git a/src/llm-context.test.ts b/src/llm-context.test.ts new file mode 100644 index 0000000..21a7912 --- /dev/null +++ b/src/llm-context.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { generateLlmContextMarkdown } from './llm-context.js'; + +describe('generateLlmContextMarkdown', () => { + it('includes tool name, description, schema, CLI and MCP examples', () => { + const tools: Tool[] = [ + { + name: 'search_products', + description: 'Search catalog.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query', + }, + limit: { + type: 'integer', + description: 'Max results', + }, + }, + required: ['query'], + }, + }, + ]; + + const md = generateLlmContextMarkdown(tools, { + mcpUrl: 'https://api.bitrefill.com/mcp/test', + programName: 'bitrefill', + }); + + expect(md).toContain('# Bitrefill MCP — LLM context'); + expect(md).toContain('## Connection'); + expect(md).toContain('https://api.bitrefill.com/mcp/'); + expect(md).toContain('### `search_products`'); + expect(md).toContain('Search catalog.'); + expect(md).toContain('| `query` |'); + expect(md).toContain('#### Input schema (JSON Schema)'); + expect(md).toContain('"type": "object"'); + expect(md).toContain('#### Example: CLI'); + expect(md).toContain('bitrefill search_products'); + expect(md).toContain('--query'); + expect(md).toContain('#### Example: MCP `tools/call`'); + expect(md).toContain('"method": "tools/call"'); + expect(md).toContain('"name": "search_products"'); + }); + + it('sorts tools by name', () => { + const tools: Tool[] = [ + { + name: 'zebra', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'alpha', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + + const md = generateLlmContextMarkdown(tools); + expect(md.indexOf('`alpha`')).toBeLessThan(md.indexOf('`zebra`')); + }); + + it('includes output schema when present', () => { + const tools: Tool[] = [ + { + name: 't', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + }, + ]; + + const md = generateLlmContextMarkdown(tools); + expect(md).toContain('#### Output schema (JSON Schema)'); + expect(md).toContain('"ok"'); + }); +}); diff --git a/src/llm-context.ts b/src/llm-context.ts new file mode 100644 index 0000000..a9dcc89 --- /dev/null +++ b/src/llm-context.ts @@ -0,0 +1,235 @@ +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { JsonSchemaProperty } from './tools.js'; + +/** Public MCP base; paths under it may include an API key segment — never echo secrets in docs. */ +const BITREFILL_MCP_PUBLIC_BASE = 'https://api.bitrefill.com/mcp'; + +export interface GenerateLlmContextOptions { + /** MCP server URL used for this session (shown in the header; API key segment is redacted). */ + mcpUrl?: string; + /** CLI program name (default `bitrefill`). */ + programName?: string; +} + +function sanitizeMcpUrlForDocs(url: string): string { + if (url === BITREFILL_MCP_PUBLIC_BASE) return url; + if (url.startsWith(`${BITREFILL_MCP_PUBLIC_BASE}/`)) { + return `${BITREFILL_MCP_PUBLIC_BASE}/`; + } + return url; +} + +function sortedPropertyEntries(tool: Tool): [string, JsonSchemaProperty][] { + const schema = tool.inputSchema as { + properties?: Record; + }; + if (!schema.properties) return []; + return Object.entries(schema.properties).sort(([a], [b]) => + a.localeCompare(b) + ); +} + +/** JSON.stringify for use as a shell argument (bash/zsh). */ +function shellArgFromString(value: string): string { + return JSON.stringify(value); +} + +function exampleArgumentValue(prop: JsonSchemaProperty): string { + if (Array.isArray(prop.enum) && prop.enum.length > 0) { + const first = prop.enum[0]; + return typeof first === 'string' + ? shellArgFromString(first) + : String(first); + } + const t = prop.type; + switch (t) { + case 'number': + case 'integer': + return '1'; + case 'boolean': + return 'true'; + case 'array': + return `'[]'`; + case 'object': + return `'{}'`; + case 'string': + default: + return shellArgFromString('example'); + } +} + +function buildCliExample( + tool: Tool, + programName: string, + entries: [string, JsonSchemaProperty][] +): string { + if (entries.length === 0) return `${programName} ${tool.name}`; + + const schema = tool.inputSchema as { required?: string[] }; + const required = new Set(schema.required ?? []); + + const parts: string[] = [`${programName} ${tool.name}`]; + for (const [name, prop] of entries) { + if (!required.has(name)) continue; + parts.push(`--${name} ${exampleArgumentValue(prop)}`); + } + for (const [name, prop] of entries) { + if (required.has(name)) continue; + parts.push(`--${name} ${exampleArgumentValue(prop)}`); + break; + } + return parts.join(' '); +} + +function buildMcpToolsCallExample( + tool: Tool, + entries: [string, JsonSchemaProperty][] +): string { + const args: Record = {}; + const schema = tool.inputSchema as { required?: string[] }; + const required = new Set(schema.required ?? []); + + for (const [name, prop] of entries) { + if (!required.has(name)) continue; + args[name] = exampleJsonValue(prop); + } + if (Object.keys(args).length === 0 && entries.length > 0) { + const [name, prop] = entries[0]; + args[name] = exampleJsonValue(prop); + } + + return JSON.stringify( + { + method: 'tools/call', + params: { + name: tool.name, + arguments: args, + }, + }, + null, + 2 + ); +} + +function exampleJsonValue(prop: JsonSchemaProperty): unknown { + if (Array.isArray(prop.enum) && prop.enum.length > 0) { + return prop.enum[0]; + } + const t = prop.type; + switch (t) { + case 'number': + case 'integer': + return 1; + case 'boolean': + return true; + case 'array': + return []; + case 'object': + return {}; + case 'string': + default: + return 'example'; + } +} + +function formatParameterTable( + tool: Tool, + entries: [string, JsonSchemaProperty][] +): string { + if (entries.length === 0) { + return '_No parameters._\n'; + } + + const schema = tool.inputSchema as { required?: string[] }; + const required = new Set(schema.required ?? []); + + const rows = entries.map(([name, prop]) => { + const req = required.has(name) ? 'yes' : 'no'; + const ty = prop.type ?? '—'; + const desc = (prop.description ?? '—').replace(/\|/g, '\\|'); + return `| \`${name}\` | ${ty} | ${req} | ${desc} |`; + }); + + return [ + '| Name | Type | Required | Description |', + '| --- | --- | --- | --- |', + ...rows, + '', + ].join('\n'); +} + +/** + * Markdown describing MCP tools (from `tools/list`): names, descriptions, + * parameter tables, JSON Schema, CLI examples, and `tools/call` JSON. + */ +export function generateLlmContextMarkdown( + tools: Tool[], + options?: GenerateLlmContextOptions +): string { + const programName = options?.programName ?? 'bitrefill'; + const sorted = [...tools].sort((a, b) => a.name.localeCompare(b.name)); + + const lines: string[] = [ + '# Bitrefill MCP — LLM context', + '', + 'Generated by `' + + programName + + ' llm-context`. Add this to **CLAUDE.md**, **Cursor rules**, or **`.github/copilot-instructions.md`** so agents know how to use the Bitrefill API via MCP or this CLI.', + '', + ]; + + if (options?.mcpUrl) { + lines.push('## Connection'); + lines.push(''); + lines.push( + `- MCP URL used for this run: \`${sanitizeMcpUrlForDocs(options.mcpUrl)}\` (override with \`MCP_URL\` or \`--api-key\` / \`BITREFILL_API_KEY\`).` + ); + lines.push(''); + } + + lines.push('## Tools'); + lines.push(''); + + for (const tool of sorted) { + const entries = sortedPropertyEntries(tool); + lines.push(`### \`${tool.name}\``); + lines.push(''); + lines.push(tool.description?.trim() || '_No description._'); + lines.push(''); + lines.push('#### Parameters'); + lines.push(''); + lines.push(formatParameterTable(tool, entries)); + lines.push('#### Input schema (JSON Schema)'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(tool.inputSchema ?? {}, null, 2)); + lines.push('```'); + lines.push(''); + + if (tool.outputSchema) { + lines.push('#### Output schema (JSON Schema)'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(tool.outputSchema, null, 2)); + lines.push('```'); + lines.push(''); + } + + lines.push('#### Example: CLI'); + lines.push(''); + lines.push('```bash'); + lines.push(buildCliExample(tool, programName, entries)); + lines.push('```'); + lines.push(''); + lines.push('#### Example: MCP `tools/call`'); + lines.push(''); + lines.push('```json'); + lines.push(buildMcpToolsCallExample(tool, entries)); + lines.push('```'); + lines.push(''); + lines.push('---'); + lines.push(''); + } + + return lines.join('\n'); +}