buildmymcpserver/scripts/seed-templates.mjs
Marco Sadjadi 089074d104 feat(conversion): Google-first login, truthful dashboard, SSR marketplace, template seeding
- login: OAuth/email on top, phone collapsed behind 'Sign in with phone
  instead' (prod providers: google on, email off, sms on — a developer
  should never see a phone field as the front door)
- dashboard: plan card wired to GET /v1/billing/status; calls card shows
  '—' + pointer to per-server metrics (no user-facing usage endpoint
  exists; previous card showed invented '0 of 100,000 / Hobby')
- templates: server-rendered grid (revalidate 300) via fetchPublicTemplates,
  client browser hydrates with initial data; inviting empty state with
  labeled starter ideas instead of 'No templates yet'
- servers/new: removed upgrade nag from first analyze wait
- scripts/seed-templates.mjs: idempotent dry-run-by-default seeder driving
  the real preview->create->live->publish flow for 6 first-party templates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:00:25 +02:00

276 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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=<bmm_session cookie value> 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_<KEY>). 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<string, string>, 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 yesterdays 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);
});