buildmymcpserver/scripts/seed-templates.mjs

276 lines
12 KiB
JavaScript
Raw Normal View History

#!/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);
});