feat(llm): extract Claude SYSTEM_PROMPT + generateSpec into shared @bmm/llm package

This commit is contained in:
Marco Sadjadi 2026-05-19 18:05:31 +02:00
parent ab67203921
commit bb0d9c2cda
7 changed files with 203 additions and 120 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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<GenerationResult> {
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: {},
};
}

23
packages/llm/package.json Normal file
View File

@ -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"
}
}

139
packages/llm/src/index.ts Normal file
View File

@ -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<GenerationResult> {
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: {},
};
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}

25
pnpm-lock.yaml generated
View File

@ -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: