2026-05-19 18:05:31 +02:00
|
|
|
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;
|
2026-05-21 23:52:48 +02:00
|
|
|
/** Per-attempt request timeout in ms. Omit to use the SDK default. */
|
|
|
|
|
timeoutMs?: number;
|
|
|
|
|
/** SDK retry count. Omit to use the SDK default. */
|
|
|
|
|
maxRetries?: number;
|
2026-05-19 18:05:31 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:52:48 +02:00
|
|
|
export async function generateSpec(
|
|
|
|
|
prompt: string,
|
|
|
|
|
opts: GenerateOptions = {},
|
|
|
|
|
): Promise<GenerationResult> {
|
2026-05-19 18:05:31 +02:00
|
|
|
if (!opts.apiKey) {
|
|
|
|
|
return { spec: mockSpec(prompt), source: 'mock' };
|
|
|
|
|
}
|
|
|
|
|
const client = new Anthropic({ apiKey: opts.apiKey });
|
2026-05-21 23:52:48 +02:00
|
|
|
const requestOptions: { timeout?: number; maxRetries?: number } = {};
|
|
|
|
|
if (opts.timeoutMs !== undefined) requestOptions.timeout = opts.timeoutMs;
|
|
|
|
|
if (opts.maxRetries !== undefined) requestOptions.maxRetries = opts.maxRetries;
|
|
|
|
|
|
|
|
|
|
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 }],
|
|
|
|
|
},
|
|
|
|
|
requestOptions,
|
|
|
|
|
)
|
|
|
|
|
.catch((err: unknown) => {
|
|
|
|
|
// A per-attempt timeout surfaces as APIConnectionTimeoutError once the
|
|
|
|
|
// SDK exhausts retries. Map it to a typed error so the API layer returns
|
|
|
|
|
// a clean 504 instead of letting the edge proxy time out headerless.
|
|
|
|
|
if (err instanceof Anthropic.APIConnectionTimeoutError) {
|
|
|
|
|
throw new SpecTimeoutError('spec generation exceeded the time budget');
|
|
|
|
|
}
|
|
|
|
|
throw err;
|
|
|
|
|
});
|
2026-05-19 18:05:31 +02:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:52:48 +02:00
|
|
|
export class SpecTimeoutError extends Error {
|
|
|
|
|
override readonly name = 'SpecTimeoutError';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:05:31 +02:00
|
|
|
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: {},
|
|
|
|
|
};
|
|
|
|
|
}
|