diff --git a/apps/api/package.json b/apps/api/package.json index 0561085..5df4b28 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "dependencies": { "@bmm/auth": "workspace:*", "@bmm/db": "workspace:*", + "@bmm/llm": "workspace:*", "@bmm/types": "workspace:*", "@fastify/cookie": "11.0.1", "@fastify/cors": "10.0.1", diff --git a/apps/generator/package.json b/apps/generator/package.json index b4d8327..7e7060e 100644 --- a/apps/generator/package.json +++ b/apps/generator/package.json @@ -12,6 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.32.1", "@bmm/db": "workspace:*", + "@bmm/llm": "workspace:*", "@bmm/types": "workspace:*", "bullmq": "5.34.5", "drizzle-orm": "0.36.4", diff --git a/apps/generator/src/lib/claude.ts b/apps/generator/src/lib/claude.ts index dd6b498..5299421 100644 --- a/apps/generator/src/lib/claude.ts +++ b/apps/generator/src/lib/claude.ts @@ -1,127 +1,12 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { GeneratorSpec, type GeneratorSpec as GeneratorSpecT } from '@bmm/types'; +import { generateSpec as sharedGenerate, type GenerationResult } from '@bmm/llm'; import { config } from '../config.js'; -const SYSTEM_PROMPT = `You generate production-grade MCP server specifications as STRICT JSON. - -Output ONE JSON object (no markdown, no prose, no code fences) with this exact shape: - -{ - "name": "human-readable server name (max 128 chars)", - "description": "1-2 sentence purpose", - "tools": [ - { - "name": "snake_case_tool_name", - "description": "what the AI client sees — single sentence, clear", - "inputSchema": { - "param_name": { "type": "string|number|boolean|array|object", "description": "...", "required": true } - }, - "implementation": "ASYNC TypeScript body. Receives {args} pre-validated. Must return MCP content blocks: { content: [{ type: 'text', text: '...' }] }. Use process.env.SECRET_NAME for secrets. NEVER use eval/Function/child_process. Use globalThis.fetch for HTTP. Wrap external calls in try/catch and return { content: [{ type: 'text', text: 'Error: ...' }], isError: true } on failure." - } - ], - "resources": [], - "prompts": [], - "requiredSecrets": ["UPPER_SNAKE_CASE"], - "scopes": ["mcp:read"], - "dependencies": {} -} - -Rules: -- Tools are idempotent unless the description explicitly says destructive. -- Validate all string inputs before use. -- For databases: parameterized queries only (use the 'pg' library with $1 placeholders). -- For HTTP APIs: globalThis.fetch with explicit timeout via AbortSignal.timeout(10000). -- Never hardcode credentials; declare them under requiredSecrets and read via process.env. -- Keep tool implementations under 5000 characters. -- Do not include "import" statements in implementations — the runtime injects fetch, pg, etc. - -Return JSON only. No explanation.`; - -export interface GenerationResult { - spec: GeneratorSpecT; - source: 'claude' | 'mock'; -} +export type { GenerationResult }; export async function generateSpec(prompt: string): Promise { - if (!config.ANTHROPIC_API_KEY) { - return { spec: mockSpec(prompt), source: 'mock' }; - } - const client = new Anthropic({ apiKey: config.ANTHROPIC_API_KEY }); - const response = await client.messages.create({ + return sharedGenerate(prompt, { + apiKey: config.ANTHROPIC_API_KEY, model: config.MODEL_GENERATE, - max_tokens: 8192, - system: SYSTEM_PROMPT, - messages: [{ role: 'user', content: prompt }], + maxTokens: 8192, }); - const text = response.content - .filter((b): b is { type: 'text'; text: string } => b.type === 'text') - .map((b) => b.text) - .join(''); - const json = extractJson(text); - const parsed = GeneratorSpec.safeParse(json); - if (!parsed.success) { - throw new Error(`spec_validation_failed: ${parsed.error.message}`); - } - scanForInjection(parsed.data); - return { spec: parsed.data, source: 'claude' }; -} - -function extractJson(text: string): unknown { - const trimmed = text.trim(); - // strip ```json fences if present - const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); - const body = fenced ? fenced[1] : trimmed; - if (!body) throw new Error('empty_generation_output'); - try { - return JSON.parse(body); - } catch (e) { - throw new Error(`generation_not_json: ${(e as Error).message}`); - } -} - -const BANNED_PATTERNS = [ - /\beval\s*\(/, - /\bnew\s+Function\s*\(/, - /\brequire\s*\(\s*['"]child_process['"]/, - /\bchild_process\b/, - /ignore\s+previous\s+instructions/i, - /disregard\s+(the\s+)?(above|previous)/i, -]; - -function scanForInjection(spec: GeneratorSpecT): void { - for (const tool of spec.tools) { - for (const pattern of BANNED_PATTERNS) { - if (pattern.test(tool.implementation) || pattern.test(tool.description)) { - throw new Error(`banned_pattern_detected: ${pattern.source}`); - } - } - } -} - -function mockSpec(prompt: string): GeneratorSpecT { - return { - name: 'Echo MCP', - description: `Mock server (no ANTHROPIC_API_KEY). Prompt was: ${prompt.slice(0, 200)}`, - tools: [ - { - name: 'echo', - description: 'Echoes the input string back to the caller.', - inputSchema: { - message: { type: 'string', description: 'Message to echo back', required: true }, - }, - implementation: `const msg = String(args.message ?? '');\nreturn { content: [{ type: 'text', text: \`echo: \${msg}\` }] };`, - }, - { - name: 'now', - description: 'Returns the current server UTC timestamp.', - inputSchema: {}, - implementation: `return { content: [{ type: 'text', text: new Date().toISOString() }] };`, - }, - ], - resources: [], - prompts: [], - requiredSecrets: [], - scopes: ['mcp:read'], - dependencies: {}, - }; } diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 0000000..1ebd4a0 --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,23 @@ +{ + "name": "@bmm/llm", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.32.1", + "@bmm/types": "workspace:*", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/node": "22.10.2", + "typescript": "5.7.2" + } +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 0000000..51de67d --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,139 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { GeneratorSpec, type GeneratorSpec as GeneratorSpecT } from '@bmm/types'; + +export const SYSTEM_PROMPT = `You generate production-grade MCP server specifications as STRICT JSON. + +Output ONE JSON object (no markdown, no prose, no code fences) with this exact shape: + +{ + "name": "human-readable server name (max 128 chars)", + "description": "1-2 sentence purpose", + "tools": [ + { + "name": "snake_case_tool_name", + "description": "what the AI client sees — single sentence, clear", + "inputSchema": { + "param_name": { "type": "string|number|boolean|array|object", "description": "...", "required": true } + }, + "implementation": "ASYNC TypeScript body. Receives {args} pre-validated. Must return MCP content blocks: { content: [{ type: 'text', text: '...' }] }. Use process.env.SECRET_NAME for secrets. NEVER use eval/Function/child_process. Use globalThis.fetch for HTTP. Wrap external calls in try/catch and return { content: [{ type: 'text', text: 'Error: ...' }], isError: true } on failure." + } + ], + "resources": [], + "prompts": [], + "requiredSecrets": ["UPPER_SNAKE_CASE"], + "scopes": ["mcp:read"], + "dependencies": {} +} + +Rules: +- Tools are idempotent unless the description explicitly says destructive. +- Validate all string inputs before use. +- For databases: parameterized queries only (use the 'pg' library with $1 placeholders). +- For HTTP APIs: globalThis.fetch with explicit timeout via AbortSignal.timeout(10000). +- Never hardcode credentials; declare them under requiredSecrets and read via process.env. +- Keep tool implementations under 5000 characters. +- Do not include "import" statements in implementations — the runtime injects fetch, pg, etc. + +Return JSON only. No explanation.`; + +const BANNED_PATTERNS = [ + /\beval\s*\(/, + /\bnew\s+Function\s*\(/, + /\brequire\s*\(\s*['"]child_process['"]/, + /\bchild_process\b/, + /ignore\s+previous\s+instructions/i, + /disregard\s+(the\s+)?(above|previous)/i, +]; + +export interface GenerationResult { + spec: GeneratorSpecT; + source: 'claude' | 'mock'; +} + +export interface GenerateOptions { + apiKey?: string; + model?: string; + maxTokens?: number; +} + +export async function generateSpec(prompt: string, opts: GenerateOptions = {}): Promise { + if (!opts.apiKey) { + return { spec: mockSpec(prompt), source: 'mock' }; + } + const client = new Anthropic({ apiKey: opts.apiKey }); + const response = await client.messages.create({ + model: opts.model ?? 'claude-opus-4-7', + max_tokens: opts.maxTokens ?? 8192, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: prompt }], + }); + const text = response.content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map((b) => b.text) + .join(''); + const json = extractJson(text); + const parsed = GeneratorSpec.safeParse(json); + if (!parsed.success) { + throw new SpecValidationError(parsed.error.message); + } + scanForInjection(parsed.data); + return { spec: parsed.data, source: 'claude' }; +} + +export class SpecValidationError extends Error { + override readonly name = 'SpecValidationError'; +} + +export class BannedPatternError extends Error { + override readonly name = 'BannedPatternError'; +} + +function extractJson(text: string): unknown { + const trimmed = text.trim(); + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); + const body = fenced ? fenced[1] : trimmed; + if (!body) throw new SpecValidationError('empty_generation_output'); + try { + return JSON.parse(body); + } catch (e) { + throw new SpecValidationError(`generation_not_json: ${(e as Error).message}`); + } +} + +function scanForInjection(spec: GeneratorSpecT): void { + for (const tool of spec.tools) { + for (const pattern of BANNED_PATTERNS) { + if (pattern.test(tool.implementation) || pattern.test(tool.description)) { + throw new BannedPatternError(`banned_pattern_detected: ${pattern.source}`); + } + } + } +} + +export function mockSpec(prompt: string): GeneratorSpecT { + return { + name: 'Echo MCP', + description: `Mock server (no ANTHROPIC_API_KEY). Prompt was: ${prompt.slice(0, 200)}`, + tools: [ + { + name: 'echo', + description: 'Echoes the input string back to the caller.', + inputSchema: { + message: { type: 'string', description: 'Message to echo back', required: true }, + }, + implementation: `const msg = String(args.message ?? '');\nreturn { content: [{ type: 'text', text: \`echo: \${msg}\` }] };`, + }, + { + name: 'now', + description: 'Returns the current server UTC timestamp.', + inputSchema: {}, + implementation: `return { content: [{ type: 'text', text: new Date().toISOString() }] };`, + }, + ], + resources: [], + prompts: [], + requiredSecrets: [], + scopes: ['mcp:read'], + dependencies: {}, + }; +} diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 0000000..4b1980d --- /dev/null +++ b/packages/llm/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 382131f..6ffc37c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@bmm/db': specifier: workspace:* version: link:../../packages/db + '@bmm/llm': + specifier: workspace:* + version: link:../../packages/llm '@bmm/types': specifier: workspace:* version: link:../../packages/types @@ -75,6 +78,9 @@ importers: '@bmm/db': specifier: workspace:* version: link:../../packages/db + '@bmm/llm': + specifier: workspace:* + version: link:../../packages/llm '@bmm/types': specifier: workspace:* version: link:../../packages/types @@ -213,6 +219,25 @@ importers: specifier: 5.7.2 version: 5.7.2 + packages/llm: + dependencies: + '@anthropic-ai/sdk': + specifier: 0.32.1 + version: 0.32.1 + '@bmm/types': + specifier: workspace:* + version: link:../types + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + typescript: + specifier: 5.7.2 + version: 5.7.2 + packages/types: dependencies: zod: