#!/usr/bin/env node // Seed first-party marketplace templates through the real product flow: // preview → create server → wait for live → publish as template. // // The API only publishes templates from LIVE servers with a successful build, // so seeding is the honest path — every seeded template carries real generated // code, exactly what a user's build would produce. // // Usage: // BMM_SESSION= node scripts/seed-templates.mjs # dry-run (default) // BMM_SESSION=... node scripts/seed-templates.mjs --apply # execute // BMM_API_URL=https://api.buildmymcpserver.com BMM_SESSION=... node ... --apply # against prod (deliberate!) // // Secrets: templates whose server needs credentials read them from env // (SEED_). Missing secret → that seed is skipped with a warning, never // created with a fake value. echo-demo needs none and always works. // // Idempotent: seeds whose derived slug already exists in GET /v1/templates // are skipped. Rate limits apply (previews/builds per day per plan) — run // under an account with headroom or spread over days. const API = process.env.BMM_API_URL ?? 'http://localhost:4000'; const SESSION = process.env.BMM_SESSION; const APPLY = process.argv.includes('--apply'); const BUILD_TIMEOUT_MS = 5 * 60 * 1000; const POLL_INTERVAL_MS = 5000; /** @type {Array<{slug: string, name: string, serverSlug: string, category: string, prompt: string, shortDescription: string, longDescription: string, secretEnv: Record, secretHints: Array<{key: string, description: string, howToGetUrl?: string}>}>} */ const SEEDS = [ { slug: 'echo-demo', name: 'Echo Demo', serverSlug: 'seed-echo-demo', category: 'demo', prompt: 'Create a minimal demo MCP server with two tools: echo (returns the input text unchanged) and now (returns the current UTC timestamp in ISO 8601). No external APIs, no secrets.', shortDescription: 'The smallest possible MCP server — echo and a UTC clock. Fork it to see the full flow in under a minute.', longDescription: 'A dependency-free demo server with two tools: `echo` returns whatever text you send it, `now` returns the current UTC timestamp. Useful as a first fork to watch the build pipeline, test your client connection and inspect the OAuth flow before wiring a real API.', secretEnv: {}, secretHints: [], }, { slug: 'notion-search', name: 'Notion Search', serverSlug: 'seed-notion-search', category: 'productivity', prompt: 'Create an MCP server that searches a Notion workspace. Tools: search_pages (query string, returns matching page titles and ids via the Notion search API) and get_page_content (page_id, returns the page blocks as plain text). Auth: NOTION_API_KEY used as a Bearer token against api.notion.com with Notion-Version header.', shortDescription: 'Search pages and read content from your Notion workspace. Bring your own integration token.', longDescription: 'Two tools against the official Notion API: `search_pages` for workspace-wide search and `get_page_content` to read a page as plain text. Fork it, paste your own internal-integration token, and your AI client can look things up in Notion. Read-only.', secretEnv: { NOTION_API_KEY: 'SEED_NOTION_API_KEY' }, secretHints: [ { key: 'NOTION_API_KEY', description: 'Internal integration secret from notion.so/my-integrations (read scope is enough).', howToGetUrl: 'https://www.notion.so/my-integrations', }, ], }, { slug: 'github-issues', name: 'GitHub Issues', serverSlug: 'seed-github-issues', category: 'developer-tools', prompt: 'Create an MCP server for GitHub issues scoped to a single repository. Tools: list_issues (state filter open/closed/all), get_issue (issue number, returns title/body/labels/comments count) and search_issues (query string). Auth: GITHUB_TOKEN as Bearer token, GITHUB_REPO in owner/repo format used in the API paths.', shortDescription: 'List, read and search issues in one GitHub repo. Scoped by a fine-grained token you control.', longDescription: 'Three read-only tools against the GitHub REST API, scoped to a single repository via GITHUB_REPO: `list_issues`, `get_issue` and `search_issues`. Use a fine-grained personal access token with Issues read permission and nothing else.', secretEnv: { GITHUB_TOKEN: 'SEED_GITHUB_TOKEN', GITHUB_REPO: 'SEED_GITHUB_REPO' }, secretHints: [ { key: 'GITHUB_TOKEN', description: 'Fine-grained PAT with read-only Issues permission on the target repo.', howToGetUrl: 'https://github.com/settings/personal-access-tokens', }, { key: 'GITHUB_REPO', description: 'Repository in owner/repo format, e.g. vercel/next.js.' }, ], }, { slug: 'stripe-readonly', name: 'Stripe Read-Only', serverSlug: 'seed-stripe-readonly', category: 'finance', prompt: 'Create a read-only MCP server for Stripe. Tools: list_charges (limit param, returns recent charges with amount/currency/status), get_customer (customer id), list_refunds (limit param). Auth: STRIPE_API_KEY as Bearer token against api.stripe.com. Read-only — never create or mutate anything.', shortDescription: 'Look up charges, customers and refunds from Stripe. Use a restricted read-only key.', longDescription: 'Read-only Stripe lookups: `list_charges`, `get_customer`, `list_refunds`. Designed for a restricted API key with read-only permissions — the server never writes. Ask your AI “what were yesterday’s failed charges?” instead of opening the dashboard.', secretEnv: { STRIPE_API_KEY: 'SEED_STRIPE_API_KEY' }, secretHints: [ { key: 'STRIPE_API_KEY', description: 'Restricted key with read-only scopes (Charges, Customers, Refunds).', howToGetUrl: 'https://dashboard.stripe.com/apikeys', }, ], }, { slug: 'postgres-readonly', name: 'PostgreSQL Read-Only', serverSlug: 'seed-postgres-readonly', category: 'data', prompt: 'Create a read-only PostgreSQL MCP server. Tools: list_tables (returns schema-qualified table names), describe_table (table name, returns columns and types from information_schema) and run_query (SELECT-only — reject any statement that is not a single SELECT). Auth: DATABASE_URL connection string.', shortDescription: 'Schema introspection and SELECT-only queries against your Postgres. Nothing gets written.', longDescription: '`list_tables`, `describe_table` and a guarded `run_query` that accepts single SELECT statements only. Point it at a read-only database role for defense in depth — the code additionally rejects non-SELECT statements before execution.', secretEnv: { DATABASE_URL: 'SEED_DATABASE_URL' }, secretHints: [ { key: 'DATABASE_URL', description: 'postgres:// connection string — use a read-only role.', }, ], }, { slug: 'rest-wrapper', name: 'REST API Wrapper', serverSlug: 'seed-rest-wrapper', category: 'developer-tools', prompt: 'Create an MCP server that wraps a generic REST API. Tools: get_resource (path param appended to a base URL, returns the JSON response) and search_resources (path plus query-string params object). Auth: API_BASE_URL for the base URL and API_TOKEN sent as a Bearer token. Only allow requests to the configured base URL.', shortDescription: 'Point it at any JSON REST API — base URL + token in, typed MCP tools out.', longDescription: 'The template for “I just want my existing API in Claude”: configure API_BASE_URL and API_TOKEN, get `get_resource` and `search_resources` tools that only ever call your configured host. Fork it and iterate the prompt to add endpoint-specific tools.', secretEnv: { API_BASE_URL: 'SEED_API_BASE_URL', API_TOKEN: 'SEED_API_TOKEN' }, secretHints: [ { key: 'API_BASE_URL', description: 'Base URL of your API, e.g. https://api.example.com/v1.', }, { key: 'API_TOKEN', description: 'Bearer token the wrapper sends with every request.' }, ], }, ]; if (!SESSION) { console.error( 'BMM_SESSION is required (value of the bmm_session cookie of the seeding account).', ); process.exit(1); } /** @param {string} path @param {RequestInit} [init] */ async function api(path, init = {}) { const res = await fetch(`${API}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Cookie: `bmm_session=${SESSION}`, ...(init.headers ?? {}), }, }); const body = await res.json().catch(() => ({})); if (!res.ok) { const detail = body?.detail ?? body?.error ?? res.status; throw new Error(`${init.method ?? 'GET'} ${path} → ${res.status}: ${detail}`); } return body; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Derive the slug the publish endpoint will generate from a title. */ function titleSlug(title) { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 48); } async function main() { console.log(`Target API: ${API} ${APPLY ? '(APPLY)' : '(dry-run — pass --apply to execute)'}`); const existing = await api('/v1/templates?sort=newest'); const existingSlugs = new Set((existing.templates ?? []).map((t) => t.slug)); for (const seed of SEEDS) { const expectedSlug = titleSlug(seed.name); if (existingSlugs.has(expectedSlug) || existingSlugs.has(seed.slug)) { console.log(`✓ ${seed.slug} — already published, skipping`); continue; } // Resolve secrets from env; skip seeds we can't provision honestly. const secrets = {}; let missing = null; for (const [key, envName] of Object.entries(seed.secretEnv)) { const val = process.env[envName]; if (!val) { missing = envName; break; } secrets[key] = val; } if (missing) { console.warn( `⚠ ${seed.slug} — skipped: env ${missing} not set (refusing to seed with fake credentials)`, ); continue; } if (!APPLY) { console.log( `→ ${seed.slug} — would run preview → create (${seed.serverSlug}) → wait live → publish`, ); continue; } console.log(`→ ${seed.slug} — previewing spec…`); const preview = await api('/v1/servers/preview', { method: 'POST', body: JSON.stringify({ prompt: seed.prompt }), }); console.log( ` spec ok (${preview.spec.tools.length} tools, source=${preview.source}) — creating server…`, ); const created = await api('/v1/servers', { method: 'POST', body: JSON.stringify({ name: seed.name, slug: seed.serverSlug, prompt: seed.prompt, secrets, previewId: preview.previewId, }), }); const serverId = created.server.id; process.stdout.write(' building'); const deadline = Date.now() + BUILD_TIMEOUT_MS; let status = created.server.status; while (status !== 'live') { if (Date.now() > deadline) throw new Error(`${seed.slug}: build timed out (status=${status})`); if (status === 'failed' || status === 'error') throw new Error(`${seed.slug}: build failed`); await sleep(POLL_INTERVAL_MS); const s = await api(`/v1/servers/${serverId}`); status = s.server.status; process.stdout.write('.'); } console.log(' live'); const published = await api('/v1/templates', { method: 'POST', body: JSON.stringify({ serverId, title: seed.name, shortDescription: seed.shortDescription, longDescription: seed.longDescription, category: seed.category, secretHints: seed.secretHints, }), }); console.log(`✓ ${seed.slug} — published as /templates/${published.template.slug}`); } console.log('Done.'); } main().catch((err) => { console.error(`\nSeed failed: ${err.message}`); process.exit(1); });