feat(llm): extract Claude SYSTEM_PROMPT + generateSpec into shared @bmm/llm package
This commit is contained in:
parent
ab67203921
commit
bb0d9c2cda
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
23
packages/llm/package.json
Normal 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
139
packages/llm/src/index.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
9
packages/llm/tsconfig.json
Normal file
9
packages/llm/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user