buildmymcpserver/packages/llm/src/index.ts

168 lines
5.8 KiB
TypeScript
Raw Normal View History

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;
/** 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;
}
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 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;
});
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';
}
export class SpecTimeoutError extends Error {
override readonly name = 'SpecTimeoutError';
}
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: {},
};
}