From 33aaacef8e256812ea21755df841d85381790eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+paola3stefania@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:05:39 -0800 Subject: [PATCH] feat(mcp): add setup_auth tool (#7307) --- packages/cli/src/commands/mcp.ts | 287 ++++++++---- packages/mcp/package.json | 53 +++ packages/mcp/src/index.ts | 20 + packages/mcp/src/lib/generator.ts | 467 +++++++++++++++++++ packages/mcp/src/lib/parser.ts | 111 +++++ packages/mcp/src/lib/schemas.ts | 61 +++ packages/mcp/src/lib/templates/database.ts | 140 ++++++ packages/mcp/src/lib/templates/features.ts | 224 +++++++++ packages/mcp/src/lib/templates/frameworks.ts | 274 +++++++++++ packages/mcp/src/lib/types.ts | 167 +++++++ packages/mcp/src/tools/index.ts | 1 + packages/mcp/src/tools/setup-auth.ts | 174 +++++++ packages/mcp/tsconfig.json | 6 + packages/mcp/tsdown.config.ts | 7 + pnpm-lock.yaml | 56 ++- 15 files changed, 1965 insertions(+), 83 deletions(-) create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/lib/generator.ts create mode 100644 packages/mcp/src/lib/parser.ts create mode 100644 packages/mcp/src/lib/schemas.ts create mode 100644 packages/mcp/src/lib/templates/database.ts create mode 100644 packages/mcp/src/lib/templates/features.ts create mode 100644 packages/mcp/src/lib/templates/frameworks.ts create mode 100644 packages/mcp/src/lib/types.ts create mode 100644 packages/mcp/src/tools/index.ts create mode 100644 packages/mcp/src/tools/setup-auth.ts create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/tsdown.config.ts diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 4399f7472c..66faea0b8a 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -11,101 +11,178 @@ interface MCPOptions { claudeCode?: boolean; openCode?: boolean; manual?: boolean; + localOnly?: boolean; + remoteOnly?: boolean; } +const REMOTE_MCP_URL = + "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp"; +const LOCAL_MCP_COMMAND = "npx @better-auth/mcp"; + async function mcpAction(options: MCPOptions) { - const mcpUrl = "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp"; - const mcpName = "better-auth"; + const installLocal = !options.remoteOnly; + const installRemote = !options.localOnly; if (options.cursor) { - await handleCursorAction(mcpUrl, mcpName); + await handleCursorAction(installLocal, installRemote); } else if (options.claudeCode) { - handleClaudeCodeAction(mcpUrl); + handleClaudeCodeAction(installLocal, installRemote); } else if (options.openCode) { - handleOpenCodeAction(mcpUrl); + handleOpenCodeAction(installLocal, installRemote); } else if (options.manual) { - handleManualAction(mcpUrl, mcpName); + handleManualAction(installLocal, installRemote); } else { - showAllOptions(mcpUrl, mcpName); + showAllOptions(); } } -async function handleCursorAction(mcpUrl: string, mcpName: string) { - const mcpConfig = { - url: mcpUrl, - }; - - const encodedConfig = base64.encode( - new TextEncoder().encode(JSON.stringify(mcpConfig)), - ); - const deeplinkUrl = `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(mcpName)}&config=${encodedConfig}`; +async function installMcpServers( + client: "cursor" | "claude-code" | "open-code" | "manual" | string, + installLocal: boolean = true, + installRemote: boolean = true, +) { + switch (client) { + case "cursor": + await handleCursorAction(installLocal, installRemote); + break; + case "claude-code": + handleClaudeCodeAction(installLocal, installRemote); + break; + case "open-code": + handleOpenCodeAction(installLocal, installRemote); + break; + case "manual": + handleManualAction(installLocal, installRemote); + break; + } +} +async function handleCursorAction( + installLocal: boolean, + installRemote: boolean, +) { console.log(chalk.bold.blue("šŸš€ Adding Better Auth MCP to Cursor...")); - try { - const platform = os.platform(); - let command: string; + const platform = os.platform(); + let openCommand: string; - switch (platform) { - case "darwin": - command = `open "${deeplinkUrl}"`; - break; - case "win32": - command = `start "" "${deeplinkUrl}"`; - break; - case "linux": - command = `xdg-open "${deeplinkUrl}"`; - break; - default: - throw new Error(`Unsupported platform: ${platform}`); + switch (platform) { + case "darwin": + openCommand = "open"; + break; + case "win32": + openCommand = "start"; + break; + case "linux": + openCommand = "xdg-open"; + break; + default: + throw new Error(`Unsupported platform: ${platform}`); + } + + const installed: string[] = []; + + if (installRemote) { + const remoteConfig = { url: REMOTE_MCP_URL }; + const encodedRemote = base64.encode( + new TextEncoder().encode(JSON.stringify(remoteConfig)), + ); + const remoteDeeplink = `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent("better-auth-docs")}&config=${encodedRemote}`; + + try { + const cmd = + platform === "win32" + ? `start "" "${remoteDeeplink}"` + : `${openCommand} "${remoteDeeplink}"`; + execSync(cmd, { stdio: "inherit" }); + installed.push("better-auth-docs (remote - documentation & search)"); + } catch { + console.log( + chalk.yellow("\n⚠ Could not automatically open Cursor for remote MCP."), + ); } + } - execSync(command, { stdio: "inherit" }); - console.log(chalk.green("\nāœ“ Cursor MCP installed successfully!")); - } catch { - console.log( - chalk.yellow( - "\n⚠ Could not automatically open Cursor. Please copy the deeplink URL above and open it manually.", - ), + if (installLocal) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const localConfig = { command: LOCAL_MCP_COMMAND }; + const encodedLocal = base64.encode( + new TextEncoder().encode(JSON.stringify(localConfig)), ); - console.log( - chalk.gray( - "\nYou can also manually add this configuration to your Cursor MCP settings:", - ), - ); - console.log(chalk.gray(JSON.stringify(mcpConfig, null, 2))); + const localDeeplink = `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent("better-auth")}&config=${encodedLocal}`; + + try { + const cmd = + platform === "win32" + ? `start "" "${localDeeplink}"` + : `${openCommand} "${localDeeplink}"`; + execSync(cmd, { stdio: "inherit" }); + installed.push("better-auth (local - setup & diagnostics)"); + } catch { + console.log( + chalk.yellow("\n⚠ Could not automatically open Cursor for local MCP."), + ); + } + } + + if (installed.length > 0) { + console.log(chalk.green("\nāœ“ Cursor MCP servers installed:")); + for (const name of installed) { + console.log(chalk.green(` • ${name}`)); + } } console.log(chalk.bold.white("\n✨ Next Steps:")); console.log( - chalk.gray("• The MCP server will be added to your Cursor configuration"), + chalk.gray("• The MCP servers will be added to your Cursor configuration"), ); console.log( chalk.gray("• You can now use Better Auth features directly in Cursor"), ); + console.log( + chalk.gray( + '• Try: "Set up Better Auth with Google login" or "Help me debug my auth"', + ), + ); } -function handleClaudeCodeAction(mcpUrl: string) { +function handleClaudeCodeAction(installLocal: boolean, installRemote: boolean) { console.log(chalk.bold.blue("šŸ¤– Adding Better Auth MCP to Claude Code...")); - const command = `claude mcp add --transport http better-auth ${mcpUrl}`; + const commands: string[] = []; - try { - execSync(command, { stdio: "inherit" }); - console.log(chalk.green("\nāœ“ Claude Code MCP installed successfully!")); - } catch { - console.log( - chalk.yellow( - "\n⚠ Could not automatically add to Claude Code. Please run this command manually:", - ), + if (installRemote) { + commands.push( + `claude mcp add --transport http better-auth-docs ${REMOTE_MCP_URL}`, ); - console.log(chalk.cyan(command)); + } + if (installLocal) { + commands.push(`claude mcp add better-auth -- ${LOCAL_MCP_COMMAND}`); } + let anySucceeded = false; + for (const command of commands) { + try { + execSync(command, { stdio: "inherit" }); + anySucceeded = true; + } catch { + console.log( + chalk.yellow( + "\n⚠ Could not automatically add to Claude Code. Please run this command manually:", + ), + ); + console.log(chalk.cyan(command)); + } + } + + if (anySucceeded) { + console.log(chalk.green("\nāœ“ Claude Code MCP configured!")); + } console.log(chalk.bold.white("\n✨ Next Steps:")); console.log( chalk.gray( - "• The MCP server will be added to your Claude Code configuration", + "• The MCP servers will be added to your Claude Code configuration", ), ); console.log( @@ -115,18 +192,30 @@ function handleClaudeCodeAction(mcpUrl: string) { ); } -function handleOpenCodeAction(mcpUrl: string) { +function handleOpenCodeAction(installLocal: boolean, installRemote: boolean) { console.log(chalk.bold.blue("šŸ”§ Adding Better Auth MCP to Open Code...")); + const mcpConfig: Record = {}; + + if (installRemote) { + mcpConfig["better-auth-docs"] = { + type: "remote", + url: REMOTE_MCP_URL, + enabled: true, + }; + } + + if (installLocal) { + mcpConfig["better-auth"] = { + type: "stdio", + command: LOCAL_MCP_COMMAND, + enabled: true, + }; + } + const openCodeConfig = { $schema: "https://opencode.ai/config.json", - mcp: { - "better-auth": { - type: "remote", - url: mcpUrl, - enabled: true, - }, - }, + mcp: mcpConfig, }; const configPath = path.join(process.cwd(), "opencode.json"); @@ -154,7 +243,7 @@ function handleOpenCodeAction(mcpUrl: string) { console.log( chalk.green(`\nāœ“ Open Code configuration written to ${configPath}`), ); - console.log(chalk.green("āœ“ Better Auth MCP added successfully!")); + console.log(chalk.green("āœ“ Better Auth MCP servers added successfully!")); } catch { console.log( chalk.yellow( @@ -165,20 +254,28 @@ function handleOpenCodeAction(mcpUrl: string) { } console.log(chalk.bold.white("\n✨ Next Steps:")); - console.log(chalk.gray("• Restart Open Code to load the new MCP server")); + console.log(chalk.gray("• Restart Open Code to load the new MCP servers")); console.log( chalk.gray("• You can now use Better Auth features directly in Open Code"), ); } -function handleManualAction(mcpUrl: string, mcpName: string) { - console.log(chalk.bold.blue("šŸ“ Adding Better Auth MCP Configuration...")); +function handleManualAction(installLocal: boolean, installRemote: boolean) { + console.log(chalk.bold.blue("šŸ“ Better Auth MCP Configuration...")); - const manualConfig = { - [mcpName]: { - url: mcpUrl, - }, - }; + const manualConfig: Record = {}; + + if (installRemote) { + manualConfig["better-auth-docs"] = { + url: REMOTE_MCP_URL, + }; + } + + if (installLocal) { + manualConfig["better-auth"] = { + command: LOCAL_MCP_COMMAND, + }; + } const configPath = path.join(process.cwd(), "mcp.json"); @@ -196,7 +293,7 @@ function handleManualAction(mcpUrl: string, mcpName: string) { fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2)); console.log(chalk.green(`\nāœ“ MCP configuration written to ${configPath}`)); - console.log(chalk.green("āœ“ Better Auth MCP added successfully!")); + console.log(chalk.green("āœ“ Better Auth MCP servers added successfully!")); } catch { console.log( chalk.yellow( @@ -207,7 +304,7 @@ function handleManualAction(mcpUrl: string, mcpName: string) { } console.log(chalk.bold.white("\n✨ Next Steps:")); - console.log(chalk.gray("• Restart your MCP client to load the new server")); + console.log(chalk.gray("• Restart your MCP client to load the new servers")); console.log( chalk.gray( "• You can now use Better Auth features directly in your MCP client", @@ -215,12 +312,12 @@ function handleManualAction(mcpUrl: string, mcpName: string) { ); } -function showAllOptions(mcpUrl: string, mcpName: string) { - console.log(chalk.bold.blue("šŸ”Œ Better Auth MCP Server")); +function showAllOptions() { + console.log(chalk.bold.blue("šŸ”Œ Better Auth MCP Servers")); console.log(chalk.gray("Choose your MCP client to get started:")); console.log(); - console.log(chalk.bold.white("Available Commands:")); + console.log(chalk.bold.white("MCP Clients:")); console.log(chalk.cyan(" --cursor ") + chalk.gray("Add to Cursor")); console.log( chalk.cyan(" --claude-code ") + chalk.gray("Add to Claude Code"), @@ -230,12 +327,42 @@ function showAllOptions(mcpUrl: string, mcpName: string) { chalk.cyan(" --manual ") + chalk.gray("Manual configuration"), ); console.log(); + + console.log(chalk.bold.white("Server Selection:")); + console.log( + chalk.cyan(" --local-only ") + + chalk.gray("Install only local MCP (setup & diagnostics)"), + ); + console.log( + chalk.cyan(" --remote-only ") + + chalk.gray("Install only remote MCP (documentation & search)"), + ); + console.log(chalk.gray(" (default: install both servers)")); + console.log(); + + console.log(chalk.bold.white("Servers:")); + console.log( + chalk.gray(" • ") + + chalk.white("better-auth") + + chalk.gray(" (local) - Setup auth, diagnose issues, validate config"), + ); + console.log( + chalk.gray(" • ") + + chalk.white("better-auth-docs") + + chalk.gray(" (remote) - Search documentation, code examples"), + ); + console.log(); } export const mcp = new Command("mcp") - .description("Add Better Auth MCP server to MCP Clients") + .description("Add Better Auth MCP servers to MCP Clients") .option("--cursor", "Automatically open Cursor with the MCP configuration") .option("--claude-code", "Show Claude Code MCP configuration command") .option("--open-code", "Show Open Code MCP configuration") .option("--manual", "Show manual MCP configuration for mcp.json") + .option("--local-only", "Install only local MCP server (setup & diagnostics)") + .option( + "--remote-only", + "Install only remote MCP server (documentation & search)", + ) .action(mcpAction); diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000000..9914f05cd5 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,53 @@ +{ + "name": "@better-auth/mcp", + "version": "1.5.0-beta.6", + "type": "module", + "description": "Better Auth MCP server for AI-powered auth setup and diagnostics", + "module": "dist/index.mjs", + "repository": { + "type": "git", + "url": "git+https://github.com/better-auth/better-auth.git", + "directory": "packages/mcp" + }, + "homepage": "https://www.better-auth.com/docs/concepts/cli#mcp", + "main": "./dist/index.mjs", + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "typecheck": "tsc --project tsconfig.json" + }, + "publishConfig": { + "access": "public", + "executableFiles": [ + "./dist/index.mjs" + ] + }, + "license": "MIT", + "keywords": [ + "auth", + "mcp", + "ai", + "model-context-protocol", + "better-auth" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "bin": { + "better-auth-mcp": "./dist/index.mjs" + }, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.8.0", + "zod": "^3.24.2" + }, + "files": [ + "dist" + ] +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000000..e6a31f104c --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerSetupAuth } from "./tools/index.js"; + +const server = new McpServer({ + name: "better-auth", + description: + "Better Auth MCP server for AI-powered auth setup and diagnostics", + version: "0.0.1", +}); + +registerSetupAuth(server); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch(console.error); diff --git a/packages/mcp/src/lib/generator.ts b/packages/mcp/src/lib/generator.ts new file mode 100644 index 0000000000..33b805e849 --- /dev/null +++ b/packages/mcp/src/lib/generator.ts @@ -0,0 +1,467 @@ +import { computeFeatureDiff, parseExistingSetup } from "./parser.js"; +import { + generateDatabaseConfig, + getDatabaseCommands, + getDatabaseEnvVar, +} from "./templates/database.js"; +import { + categorizeFeatures, + generatePluginImports, + generatePluginSetup, + generateSocialProviderConfig, + getPluginEnvVars, + getSocialProviderEnvVars, +} from "./templates/features.js"; +import { + FRAMEWORK_CONFIGS, + getDefaultApiPath, + getDefaultAuthPath, +} from "./templates/frameworks.js"; +import type { + Command, + Database, + DocLink, + EnvVar, + Feature, + Framework, + ORM, + OutputFile, + SetupAuthError, + SetupAuthInput, + SetupAuthOutput, +} from "./types.js"; + +export function generateSetup( + input: SetupAuthInput, +): SetupAuthOutput | SetupAuthError { + const framework = input.framework; + const database = input.database; + const orm = input.orm || "none"; + const features = input.features || ["email-password"]; + const typescript = input.typescript ?? true; + const srcDir = input.srcDir ?? FRAMEWORK_CONFIGS[framework].defaultSrcDir; + + const authPath = input.authPath || getDefaultAuthPath(framework, srcDir); + const apiPath = input.apiPath || getDefaultApiPath(framework, srcDir); + + let mode: "create" | "update" = "create"; + let featuresToGenerate = features; + let detected: ReturnType | undefined; + + if (input.existingSetup?.authConfig) { + mode = "update"; + detected = parseExistingSetup(input.existingSetup); + const diff = computeFeatureDiff(detected.features as Feature[], features); + featuresToGenerate = diff.toAdd; + + if (featuresToGenerate.length === 0) { + return { + mode: "update", + files: [], + envVars: [], + commands: [], + detected, + nextSteps: ["All requested features are already configured"], + docs: [], + }; + } + } + + const { socialProviders, plugins, hasEmailPassword } = + categorizeFeatures(featuresToGenerate); + + const files: OutputFile[] = []; + const envVars: EnvVar[] = []; + const commands: Command[] = []; + const warnings: string[] = []; + + envVars.push({ + name: "BETTER_AUTH_SECRET", + description: "Secret key for signing tokens", + required: true, + howToGet: "Run: npx @better-auth/cli secret", + }); + + envVars.push(getDatabaseEnvVar(database)); + + for (const provider of socialProviders) { + envVars.push(...getSocialProviderEnvVars(provider)); + } + + envVars.push(...getPluginEnvVars(plugins)); + + if (mode === "create") { + const authFile = generateAuthFile({ + framework, + database, + orm, + socialProviders, + plugins, + hasEmailPassword, + typescript, + }); + + files.push({ + path: `${authPath}.${typescript ? "ts" : "js"}`, + description: "Better Auth server configuration", + action: "create", + content: authFile, + }); + + const clientFile = generateClientFile({ + framework, + plugins, + typescript, + }); + + files.push({ + path: `${authPath}-client.${typescript ? "ts" : "js"}`, + description: "Better Auth client configuration", + action: "create", + content: clientFile, + }); + + if (apiPath) { + const apiFile = generateApiRouteFile(framework, authPath); + if (apiFile) { + files.push({ + path: `${apiPath}/route.${typescript ? "ts" : "js"}`, + description: "API route handler for auth endpoints", + action: "create", + content: apiFile, + }); + } + } + + const frameworkConfig = FRAMEWORK_CONFIGS[framework]; + if (frameworkConfig.hooksTemplate) { + files.push({ + path: srcDir ? "src/hooks.server.ts" : "hooks.server.ts", + description: "SvelteKit hooks for auth handling", + action: "create", + content: frameworkConfig.hooksTemplate(authPath), + }); + } + } else { + if (socialProviders.length > 0 || plugins.length > 0 || hasEmailPassword) { + const changes = generateUpdateChanges({ + socialProviders, + plugins, + hasEmailPassword, + }); + + files.push({ + path: `${authPath}.${typescript ? "ts" : "js"}`, + description: "Better Auth server configuration", + action: "update", + changes: changes.serverChanges, + }); + + if (plugins.length > 0) { + files.push({ + path: `${authPath}-client.${typescript ? "ts" : "js"}`, + description: "Better Auth client configuration", + action: "update", + changes: changes.clientChanges, + }); + } + } + } + + commands.push({ + command: "pnpm add better-auth", + description: "Install Better Auth", + when: "If not already installed", + }); + + commands.push(...getDatabaseCommands(orm)); + + const nextSteps = generateNextSteps(mode, socialProviders, plugins); + const docs = generateDocLinks(features); + + return { + mode, + files, + envVars, + commands, + detected, + nextSteps, + warnings: warnings.length > 0 ? warnings : undefined, + docs, + }; +} + +function generateAuthFile(options: { + framework: Framework; + database: Database; + orm: ORM; + socialProviders: string[]; + plugins: string[]; + hasEmailPassword: boolean; + typescript: boolean; +}): string { + const { database, orm, socialProviders, plugins, hasEmailPassword } = options; + + const imports: string[] = ['import { betterAuth } from "better-auth";']; + + const { + imports: dbImports, + config: dbConfig, + prismaInstance, + } = generateDatabaseConfig(database, orm); + + if (dbImports) { + imports.push(dbImports); + } + + const { serverImports } = generatePluginImports(plugins); + imports.push(...serverImports); + + const socialProvidersConfig = socialProviders + .map((p) => ` ${generateSocialProviderConfig(p)}`) + .join(",\n"); + + const { serverPlugins } = generatePluginSetup(plugins); + + let configBody = ` database: ${dbConfig},`; + + if (hasEmailPassword) { + configBody += ` + emailAndPassword: { + enabled: true, + },`; + } + + if (socialProviders.length > 0) { + configBody += ` + socialProviders: { +${socialProvidersConfig} + },`; + } + + if (serverPlugins.length > 0) { + configBody += ` + plugins: [ + ${serverPlugins.join(",\n ")} + ],`; + } + + return `${imports.join("\n")} +${prismaInstance || ""} +export const auth = betterAuth({ +${configBody} +}); +`; +} + +function generateClientFile(options: { + framework: Framework; + plugins: string[]; + typescript: boolean; +}): string { + const { framework, plugins } = options; + + const frameworkConfig = FRAMEWORK_CONFIGS[framework]; + const imports: string[] = [frameworkConfig.clientImport]; + + const { clientImports } = generatePluginImports(plugins); + imports.push(...clientImports); + + const { clientPlugins } = generatePluginSetup(plugins); + + let configBody = ""; + + if (clientPlugins.length > 0) { + configBody = `{ + plugins: [ + ${clientPlugins.join(",\n ")} + ], +}`; + } else { + configBody = "{}"; + } + + return `${imports.join("\n")} + +export const authClient = createAuthClient(${configBody}); +`; +} + +function generateApiRouteFile( + framework: Framework, + authPath: string, +): string | null { + const frameworkConfig = FRAMEWORK_CONFIGS[framework]; + + if (!frameworkConfig.apiRouteTemplate) { + return null; + } + + const template = frameworkConfig.apiRouteTemplate(authPath); + return template || null; +} + +function generateUpdateChanges(options: { + socialProviders: string[]; + plugins: string[]; + hasEmailPassword: boolean; +}): { + serverChanges: { type: string; content: string; description: string }[]; + clientChanges: { type: string; content: string; description: string }[]; +} { + const { socialProviders, plugins, hasEmailPassword } = options; + + const serverChanges: { + type: string; + content: string; + description: string; + }[] = []; + const clientChanges: { + type: string; + content: string; + description: string; + }[] = []; + + if (hasEmailPassword) { + serverChanges.push({ + type: "add_to_config", + content: `emailAndPassword: { + enabled: true, + }`, + description: "Enable email and password authentication", + }); + } + + const { serverImports, clientImports } = generatePluginImports(plugins); + + for (const imp of serverImports) { + serverChanges.push({ + type: "add_import", + content: imp, + description: "Add plugin import", + }); + } + + for (const provider of socialProviders) { + serverChanges.push({ + type: "add_to_config", + content: generateSocialProviderConfig(provider), + description: `Add ${provider} social provider`, + }); + } + + const { serverPlugins, clientPlugins } = generatePluginSetup(plugins); + + for (const plugin of serverPlugins) { + serverChanges.push({ + type: "add_plugin", + content: plugin, + description: "Add server plugin", + }); + } + + for (const imp of clientImports) { + clientChanges.push({ + type: "add_import", + content: imp, + description: "Add client plugin import", + }); + } + + for (const plugin of clientPlugins) { + clientChanges.push({ + type: "add_plugin", + content: plugin, + description: "Add client plugin", + }); + } + + return { serverChanges, clientChanges }; +} + +function generateNextSteps( + mode: "create" | "update", + socialProviders: string[], + plugins: string[], +): string[] { + const steps: string[] = []; + + if (mode === "create") { + steps.push("Set environment variables in .env file"); + steps.push("Run database migrations"); + } + + if (socialProviders.length > 0) { + steps.push(`Configure OAuth apps for: ${socialProviders.join(", ")}`); + steps.push("Add callback URLs to OAuth provider dashboards"); + } + + if (plugins.includes("magic-link")) { + steps.push("Configure email provider for magic links"); + } + + if (plugins.includes("phone-number")) { + steps.push("Configure SMS provider for OTP"); + } + + if (plugins.includes("captcha")) { + steps.push( + "Set up captcha provider (Cloudflare Turnstile, reCAPTCHA, or hCaptcha)", + ); + } + + steps.push("Start your development server and test auth flow"); + + return steps; +} + +function generateDocLinks(features: Feature[]): DocLink[] { + const docs: DocLink[] = [ + { + title: "Better Auth Documentation", + url: "https://www.better-auth.com/docs", + }, + { + title: "Getting Started", + url: "https://www.better-auth.com/docs/getting-started", + }, + ]; + + if ( + features.some((f) => ["google", "github", "apple", "discord"].includes(f)) + ) { + docs.push({ + title: "Social Sign-On", + url: "https://www.better-auth.com/docs/authentication/social-sign-on", + }); + } + + if (features.includes("2fa")) { + docs.push({ + title: "Two-Factor Authentication", + url: "https://www.better-auth.com/docs/plugins/two-factor", + }); + } + + if (features.includes("organization")) { + docs.push({ + title: "Organizations", + url: "https://www.better-auth.com/docs/plugins/organization", + }); + } + + if (features.includes("passkey")) { + docs.push({ + title: "Passkeys", + url: "https://www.better-auth.com/docs/plugins/passkey", + }); + } + + return docs; +} + +export function isSetupError( + result: SetupAuthOutput | SetupAuthError, +): result is SetupAuthError { + return "error" in result; +} diff --git a/packages/mcp/src/lib/parser.ts b/packages/mcp/src/lib/parser.ts new file mode 100644 index 0000000000..7f28cb3bee --- /dev/null +++ b/packages/mcp/src/lib/parser.ts @@ -0,0 +1,111 @@ +import type { DetectedConfig, ExistingSetup, Feature } from "./types.js"; + +export function parseExistingSetup( + existingSetup: ExistingSetup, +): DetectedConfig { + const detected: DetectedConfig = { + features: [], + }; + + if (existingSetup.authConfig) { + const config = existingSetup.authConfig; + detected.database = detectDatabase(config); + detected.orm = detectORM(config); + detected.features = detectFeatures(config, existingSetup.authClientConfig); + } + + return detected; +} + +function detectDatabase(config: string): string | undefined { + if ( + /provider:\s*["']postgresql["']/.test(config) || + /provider:\s*["']pg["']/.test(config) + ) { + return "postgres"; + } + if (/provider:\s*["']mysql["']/.test(config)) { + return "mysql"; + } + if (/provider:\s*["']sqlite["']/.test(config)) { + return "sqlite"; + } + if (/provider:\s*["']mongodb["']/.test(config)) { + return "mongodb"; + } + return undefined; +} + +function detectORM(config: string): string | undefined { + if (/prismaAdapter\(/.test(config)) { + return "prisma"; + } + if (/drizzleAdapter\(/.test(config)) { + return "drizzle"; + } + if (/database:\s*\{[\s\S]*provider:/.test(config)) { + return "none"; + } + return undefined; +} + +function detectFeatures( + serverConfig: string, + clientConfig?: string, +): Feature[] { + const features: Feature[] = []; + + if (/emailAndPassword:\s*\{[\s\S]*enabled:\s*true/.test(serverConfig)) { + features.push("email-password"); + } + + const socialProviders: Feature[] = [ + "google", + "github", + "apple", + "discord", + "twitter", + "facebook", + "microsoft", + "linkedin", + ]; + + for (const provider of socialProviders) { + if (new RegExp(`${provider}:\\s*\\{`).test(serverConfig)) { + features.push(provider); + } + } + + const pluginPatterns: [RegExp, Feature][] = [ + [/twoFactor\(/, "2fa"], + [/organization\(/, "organization"], + [/admin\(/, "admin"], + [/username\(/, "username"], + [/multiSession\(/, "multi-session"], + [/apiKey\(/, "api-key"], + [/bearer\(/, "bearer"], + [/jwt\(/, "jwt"], + [/magicLink\(/, "magic-link"], + [/phoneNumber\(/, "phone-number"], + [/passkey\(/, "passkey"], + [/anonymous\(/, "anonymous"], + [/captcha\(/, "captcha"], + ]; + + for (const [pattern, feature] of pluginPatterns) { + if (pattern.test(serverConfig)) { + features.push(feature); + } + } + + return features; +} + +export function computeFeatureDiff( + existing: Feature[], + requested: Feature[], +): { toAdd: Feature[]; existing: Feature[] } { + const toAdd = requested.filter((f) => !existing.includes(f)); + const existingFeatures = requested.filter((f) => existing.includes(f)); + return { toAdd, existing: existingFeatures }; +} diff --git a/packages/mcp/src/lib/schemas.ts b/packages/mcp/src/lib/schemas.ts new file mode 100644 index 0000000000..7a246b4cfe --- /dev/null +++ b/packages/mcp/src/lib/schemas.ts @@ -0,0 +1,61 @@ +import * as z from "zod"; + +export const FrameworkEnum = z.enum([ + "next-app-router", + "next-pages-router", + "sveltekit", + "astro", + "remix", + "nuxt", + "solid-start", + "hono", + "express", + "fastify", + "elysia", + "tanstack-start", + "expo", +]); + +export const DatabaseEnum = z.enum(["postgres", "mysql", "sqlite", "mongodb"]); + +export const ORMEnum = z.enum(["prisma", "drizzle", "none"]); + +export const FeatureEnum = z.enum([ + "email-password", + "magic-link", + "phone-number", + "passkey", + "anonymous", + "google", + "github", + "apple", + "discord", + "twitter", + "facebook", + "microsoft", + "linkedin", + "2fa", + "captcha", + "organization", + "admin", + "username", + "multi-session", + "api-key", + "bearer", + "jwt", +]); + +export const ExistingSetupSchema = z.object({ + authConfig: z + .string() + .optional() + .describe("Contents of existing auth.ts file"), + authClientConfig: z + .string() + .optional() + .describe("Contents of existing auth-client.ts file"), + envVars: z + .array(z.string()) + .optional() + .describe("List of existing environment variable names"), +}); diff --git a/packages/mcp/src/lib/templates/database.ts b/packages/mcp/src/lib/templates/database.ts new file mode 100644 index 0000000000..cb5f7d3db4 --- /dev/null +++ b/packages/mcp/src/lib/templates/database.ts @@ -0,0 +1,140 @@ +import type { + Database, + DatabaseConfig, + EnvVar, + ORM, + ORMConfig, +} from "../types.js"; + +// cspell:ignore mydb +const DATABASE_CONFIGS: Record = { + postgres: { + provider: "postgresql", + envVarName: "DATABASE_URL", + connectionStringExample: "postgresql://user:password@localhost:5432/mydb", + }, + mysql: { + provider: "mysql", + envVarName: "DATABASE_URL", + connectionStringExample: "mysql://user:password@localhost:3306/mydb", + }, + sqlite: { + provider: "sqlite", + envVarName: "DATABASE_URL", + connectionStringExample: "file:./dev.db", + }, + mongodb: { + provider: "mongodb", + envVarName: "DATABASE_URL", + connectionStringExample: "mongodb://localhost:27017/mydb", + }, +}; + +const ORM_CONFIGS: Record = { + prisma: { + adapterImport: `import { prismaAdapter } from "better-auth/adapters/prisma"; +import { PrismaClient } from "@prisma/client";`, + adapterSetup: (dbProvider: string) => `prismaAdapter(prisma, { + provider: "${dbProvider}", + })`, + schemaCommand: + "npx @better-auth/cli generate --output prisma/schema.prisma", + pushCommand: "npx prisma db push", + }, + drizzle: { + adapterImport: `import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { db } from "./db";`, + adapterSetup: (dbProvider: string) => `drizzleAdapter(db, { + provider: "${getShortProvider(dbProvider)}", + })`, + schemaCommand: "npx @better-auth/cli generate --output src/lib/schema.ts", + pushCommand: "npx drizzle-kit push", + }, + none: { + adapterImport: "", + adapterSetup: (dbProvider: string) => `{ + provider: "${getShortProvider(dbProvider)}", + url: process.env.DATABASE_URL, + }`, + pushCommand: "npx @better-auth/cli migrate", + }, +}; + +function getShortProvider(provider: string): string { + switch (provider) { + case "postgresql": + return "pg"; + case "mysql": + return "mysql"; + case "sqlite": + return "sqlite"; + case "mongodb": + return "mongodb"; + default: + return provider; + } +} + +export function generateDatabaseConfig( + database: Database, + orm: ORM, +): { imports: string; config: string; prismaInstance?: string } { + if (orm === "drizzle" && database === "mongodb") { + throw new Error( + "Drizzle ORM does not support MongoDB. Please select Prisma or use the built-in MongoDB adapter instead.", + ); + } + + const dbConfig = DATABASE_CONFIGS[database]; + const ormConfig = ORM_CONFIGS[orm]; + + let imports = ""; + let prismaInstance = ""; + + if (orm === "prisma") { + imports = ormConfig.adapterImport; + prismaInstance = "\nconst prisma = new PrismaClient();\n"; + } else if (orm === "drizzle") { + imports = ormConfig.adapterImport; + } + + const config = ormConfig.adapterSetup(dbConfig.provider); + + return { imports, config, prismaInstance }; +} + +export function getDatabaseEnvVar(database: Database): EnvVar { + const config = DATABASE_CONFIGS[database]; + return { + name: config.envVarName, + description: `${database.charAt(0).toUpperCase() + database.slice(1)} connection string`, + required: true, + example: config.connectionStringExample, + }; +} + +export function getDatabaseCommands( + orm: ORM, +): { command: string; description: string; when?: string }[] { + const ormConfig = ORM_CONFIGS[orm]; + const commands: { command: string; description: string; when?: string }[] = + []; + + if (ormConfig.schemaCommand) { + commands.push({ + command: ormConfig.schemaCommand, + description: `Generate ${orm === "prisma" ? "Prisma" : "Drizzle"} schema for auth tables`, + }); + } + + commands.push({ + command: ormConfig.pushCommand, + description: + orm === "none" + ? "Run database migrations for auth tables" + : `Push ${orm === "prisma" ? "Prisma" : "Drizzle"} schema to database`, + when: "After setting DATABASE_URL", + }); + + return commands; +} diff --git a/packages/mcp/src/lib/templates/features.ts b/packages/mcp/src/lib/templates/features.ts new file mode 100644 index 0000000000..5fa892ba3c --- /dev/null +++ b/packages/mcp/src/lib/templates/features.ts @@ -0,0 +1,224 @@ +import type { EnvVar, Feature, PluginConfig } from "../types.js"; + +export function generateSocialProviderConfig(provider: string): string { + const upperName = provider.toUpperCase(); + return `${provider}: { + clientId: process.env.${upperName}_CLIENT_ID!, + clientSecret: process.env.${upperName}_CLIENT_SECRET!, + }`; +} + +export function getSocialProviderEnvVars(provider: string): EnvVar[] { + const upperName = provider.toUpperCase(); + const providerName = provider.charAt(0).toUpperCase() + provider.slice(1); + + return [ + { + name: `${upperName}_CLIENT_ID`, + description: `${providerName} OAuth client ID`, + required: true, + howToGet: `Create OAuth app at ${providerName} developer console`, + }, + { + name: `${upperName}_CLIENT_SECRET`, + description: `${providerName} OAuth client secret`, + required: true, + howToGet: `Create OAuth app at ${providerName} developer console`, + }, + ]; +} + +const KNOWN_SOCIAL_PROVIDERS = [ + "google", + "github", + "apple", + "discord", + "twitter", + "facebook", + "microsoft", + "linkedin", + "spotify", + "twitch", + "slack", + "gitlab", + "reddit", + "dropbox", + "tiktok", +] as const; + +const PLUGIN_CONFIGS: Record = { + "2fa": { + serverImport: 'import { twoFactor } from "better-auth/plugins";', + clientImport: + 'import { twoFactorClient } from "better-auth/client/plugins";', + serverPlugin: () => `twoFactor()`, + clientPlugin: () => `twoFactorClient()`, + }, + organization: { + serverImport: 'import { organization } from "better-auth/plugins";', + clientImport: + 'import { organizationClient } from "better-auth/client/plugins";', + serverPlugin: () => `organization()`, + clientPlugin: () => `organizationClient()`, + }, + admin: { + serverImport: 'import { admin } from "better-auth/plugins";', + clientImport: 'import { adminClient } from "better-auth/client/plugins";', + serverPlugin: () => `admin()`, + clientPlugin: () => `adminClient()`, + }, + username: { + serverImport: 'import { username } from "better-auth/plugins";', + clientImport: + 'import { usernameClient } from "better-auth/client/plugins";', + serverPlugin: () => `username()`, + clientPlugin: () => `usernameClient()`, + }, + "multi-session": { + serverImport: 'import { multiSession } from "better-auth/plugins";', + clientImport: + 'import { multiSessionClient } from "better-auth/client/plugins";', + serverPlugin: () => `multiSession()`, + clientPlugin: () => `multiSessionClient()`, + }, + "api-key": { + serverImport: 'import { apiKey } from "better-auth/plugins";', + clientImport: 'import { apiKeyClient } from "better-auth/client/plugins";', + serverPlugin: () => `apiKey()`, + clientPlugin: () => `apiKeyClient()`, + }, + bearer: { + serverImport: 'import { bearer } from "better-auth/plugins";', + serverPlugin: () => `bearer()`, + }, + jwt: { + serverImport: 'import { jwt } from "better-auth/plugins";', + serverPlugin: () => `jwt()`, + }, + "magic-link": { + serverImport: 'import { magicLink } from "better-auth/plugins";', + clientImport: + 'import { magicLinkClient } from "better-auth/client/plugins";', + serverPlugin: () => `magicLink({ + sendMagicLink: async ({ email, url }) => { + // TODO: Send magic link email + console.log("Send magic link to", email, url); + }, + })`, + clientPlugin: () => `magicLinkClient()`, + }, + "phone-number": { + serverImport: 'import { phoneNumber } from "better-auth/plugins";', + clientImport: + 'import { phoneNumberClient } from "better-auth/client/plugins";', + serverPlugin: () => `phoneNumber({ + sendOTP: async ({ phoneNumber, code }) => { + // TODO: Send OTP via SMS + console.log("Send OTP", code, "to", phoneNumber); + }, + })`, + clientPlugin: () => `phoneNumberClient()`, + }, + passkey: { + serverImport: 'import { passkey } from "better-auth/plugins";', + clientImport: 'import { passkeyClient } from "better-auth/client/plugins";', + serverPlugin: () => `passkey()`, + clientPlugin: () => `passkeyClient()`, + }, + anonymous: { + serverImport: 'import { anonymous } from "better-auth/plugins";', + clientImport: + 'import { anonymousClient } from "better-auth/client/plugins";', + serverPlugin: () => `anonymous()`, + clientPlugin: () => `anonymousClient()`, + }, + captcha: { + serverImport: 'import { captcha } from "better-auth/plugins";', + serverPlugin: () => `captcha({ + provider: "cloudflare-turnstile", // or "recaptcha" or "hcaptcha" + secretKey: process.env.CAPTCHA_SECRET_KEY!, + })`, + envVars: [ + { + name: "CAPTCHA_SECRET_KEY", + description: "Captcha provider secret key", + required: true, + }, + ], + }, +}; + +export function generatePluginImports(plugins: string[]): { + serverImports: string[]; + clientImports: string[]; +} { + const serverImports: string[] = []; + const clientImports: string[] = []; + + for (const plugin of plugins) { + const config = PLUGIN_CONFIGS[plugin]; + if (config) { + serverImports.push(config.serverImport); + if (config.clientImport) { + clientImports.push(config.clientImport); + } + } + } + + return { serverImports, clientImports }; +} + +export function generatePluginSetup(plugins: string[]): { + serverPlugins: string[]; + clientPlugins: string[]; +} { + const serverPlugins: string[] = []; + const clientPlugins: string[] = []; + + for (const plugin of plugins) { + const config = PLUGIN_CONFIGS[plugin]; + if (config) { + serverPlugins.push(config.serverPlugin()); + if (config.clientPlugin) { + clientPlugins.push(config.clientPlugin()); + } + } + } + + return { serverPlugins, clientPlugins }; +} + +export function getPluginEnvVars(plugins: string[]): EnvVar[] { + const envVars: EnvVar[] = []; + + for (const plugin of plugins) { + const config = PLUGIN_CONFIGS[plugin]; + if (config?.envVars) { + envVars.push(...config.envVars); + } + } + + return envVars; +} + +export function categorizeFeatures(features: Feature[]): { + socialProviders: string[]; + plugins: string[]; + hasEmailPassword: boolean; +} { + const socialProviders: string[] = []; + const plugins: string[] = []; + let hasEmailPassword = false; + + for (const feature of features) { + if (feature === "email-password") { + hasEmailPassword = true; + } else if (feature in PLUGIN_CONFIGS) { + plugins.push(feature); + } else { + socialProviders.push(feature); + } + } + + return { socialProviders, plugins, hasEmailPassword }; +} diff --git a/packages/mcp/src/lib/templates/frameworks.ts b/packages/mcp/src/lib/templates/frameworks.ts new file mode 100644 index 0000000000..4d937b5828 --- /dev/null +++ b/packages/mcp/src/lib/templates/frameworks.ts @@ -0,0 +1,274 @@ +import type { Framework, FrameworkConfig } from "../types.js"; + +export const FRAMEWORK_CONFIGS: Record = { + "next-app-router": { + name: "Next.js (App Router)", + defaultSrcDir: false, + defaultAuthPath: "lib/auth", + defaultApiPath: "app/api/auth/[...all]", + clientImport: 'import { createAuthClient } from "better-auth/react";', + handlerImport: 'import { toNextJsHandler } from "better-auth/next-js";', + handlerFunction: "toNextJsHandler", + apiRouteTemplate: ( + authPath: string, + ) => `import { auth } from "@/${authPath}"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth);`, + defaultPort: 3000, + }, + "next-pages-router": { + name: "Next.js (Pages Router)", + defaultSrcDir: false, + defaultAuthPath: "lib/auth", + defaultApiPath: "pages/api/auth/[...all]", + clientImport: 'import { createAuthClient } from "better-auth/react";', + handlerImport: 'import { toNodeHandler } from "better-auth/node";', + handlerFunction: "toNodeHandler", + apiRouteTemplate: ( + authPath: string, + ) => `import { toNodeHandler } from "better-auth/node"; +import { auth } from "@/${authPath}"; + +export const config = { api: { bodyParser: false } }; +export default toNodeHandler(auth);`, + defaultPort: 3000, + }, + sveltekit: { + name: "SvelteKit", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "routes/api/auth/[...all]", + clientImport: 'import { createAuthClient } from "better-auth/svelte";', + handlerImport: 'import { svelteKitHandler } from "better-auth/svelte-kit";', + handlerFunction: "svelteKitHandler", + apiRouteTemplate: () => "", + hooksTemplate: (authPath: string) => `import { auth } from "$${authPath}"; +import { svelteKitHandler } from "better-auth/svelte-kit"; +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + return svelteKitHandler({ event, resolve, auth }); +};`, + defaultPort: 5173, + }, + astro: { + name: "Astro", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "pages/api/auth/[...all]", + clientImport: 'import { createAuthClient } from "better-auth/client";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: ( + authPath: string, + ) => `import type { APIRoute } from "astro"; +import { auth } from "@/${authPath}"; + +export const GET: APIRoute = async (ctx) => { + return auth.handler(ctx.request); +}; + +export const POST: APIRoute = async (ctx) => { + return auth.handler(ctx.request); +};`, + defaultPort: 4321, + }, + remix: { + name: "Remix", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "routes/api.auth.$", + clientImport: 'import { createAuthClient } from "better-auth/react";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: ( + authPath: string, + ) => `import { auth } from "~/${authPath}"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; + +export async function loader({ request }: LoaderFunctionArgs) { + return auth.handler(request); +} + +export async function action({ request }: ActionFunctionArgs) { + return auth.handler(request); +}`, + defaultPort: 3000, + }, + nuxt: { + name: "Nuxt 3", + defaultSrcDir: false, + defaultAuthPath: "lib/auth", + defaultApiPath: "server/api/auth/[...all]", + clientImport: 'import { createAuthClient } from "better-auth/vue";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: ( + authPath: string, + ) => `import { auth } from "~/${authPath}"; + +export default defineEventHandler((event) => { + return auth.handler(toWebRequest(event)); +});`, + defaultPort: 3000, + }, + "solid-start": { + name: "SolidStart", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "routes/api/auth/[...all]", + clientImport: 'import { createAuthClient } from "better-auth/solid";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: ( + authPath: string, + ) => `import { auth } from "~/${authPath}"; +import type { APIEvent } from "@solidjs/start/server"; + +export async function GET(event: APIEvent) { + return auth.handler(event.request); +} + +export async function POST(event: APIEvent) { + return auth.handler(event.request); +}`, + defaultPort: 3000, + }, + hono: { + name: "Hono", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "", + clientImport: 'import { createAuthClient } from "better-auth/client";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: (authPath: string) => `import { Hono } from "hono"; +import { auth } from "./${authPath}"; + +const app = new Hono(); + +app.on(["POST", "GET"], "/api/auth/**", (c) => { + return auth.handler(c.req.raw); +}); + +export default app;`, + defaultPort: 3000, + }, + express: { + name: "Express.js", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "", + clientImport: 'import { createAuthClient } from "better-auth/client";', + handlerImport: 'import { toNodeHandler } from "better-auth/node";', + handlerFunction: "toNodeHandler", + apiRouteTemplate: (authPath: string) => `import express from "express"; +import { toNodeHandler } from "better-auth/node"; +import { auth } from "./${authPath}"; + +const app = express(); + +app.all("/api/auth/*", toNodeHandler(auth)); + +app.listen(3000, () => { + console.log("Server running on port 3000"); +});`, + defaultPort: 3000, + }, + fastify: { + name: "Fastify", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "", + clientImport: 'import { createAuthClient } from "better-auth/client";', + handlerImport: 'import { toNodeHandler } from "better-auth/node";', + handlerFunction: "toNodeHandler", + apiRouteTemplate: (authPath: string) => `import Fastify from "fastify"; +import { toNodeHandler } from "better-auth/node"; +import { auth } from "./${authPath}"; + +const fastify = Fastify(); + +fastify.all("/api/auth/*", async (request, reply) => { + const handler = toNodeHandler(auth); + return handler(request.raw, reply.raw); +}); + +fastify.listen({ port: 3000 }, (err) => { + if (err) throw err; + console.log("Server running on port 3000"); +});`, + defaultPort: 3000, + }, + elysia: { + name: "Elysia (Bun)", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "", + clientImport: 'import { createAuthClient } from "better-auth/client";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: (authPath: string) => `import { Elysia } from "elysia"; +import { auth } from "./${authPath}"; + +const app = new Elysia() + .all("/api/auth/*", ({ request }) => auth.handler(request)) + .listen(3000); + +console.log("Server running on port 3000");`, + defaultPort: 3000, + }, + "tanstack-start": { + name: "TanStack Start", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "routes/api/auth.$", + clientImport: 'import { createAuthClient } from "better-auth/react";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: ( + authPath: string, + ) => `import { auth } from "~/${authPath}"; +import { createAPIFileRoute } from "@tanstack/start/api"; + +export const Route = createAPIFileRoute("/api/auth/$")({ + GET: ({ request }) => auth.handler(request), + POST: ({ request }) => auth.handler(request), +});`, + defaultPort: 3000, + }, + expo: { + name: "Expo (React Native)", + defaultSrcDir: true, + defaultAuthPath: "lib/auth", + defaultApiPath: "", + clientImport: 'import { createAuthClient } from "@better-auth/expo";', + handlerImport: "", + handlerFunction: "", + apiRouteTemplate: () => "", + defaultPort: 8081, + }, +}; + +export function getDefaultAuthPath( + framework: Framework, + srcDir: boolean, +): string { + const config = FRAMEWORK_CONFIGS[framework]; + const basePath = config.defaultAuthPath; + + return srcDir ? `src/${basePath}` : basePath; +} + +export function getDefaultApiPath( + framework: Framework, + srcDir: boolean, +): string { + const config = FRAMEWORK_CONFIGS[framework]; + const basePath = config.defaultApiPath; + + if (!basePath) return ""; + + return srcDir ? `src/${basePath}` : basePath; +} diff --git a/packages/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts new file mode 100644 index 0000000000..c2e80d9ac7 --- /dev/null +++ b/packages/mcp/src/lib/types.ts @@ -0,0 +1,167 @@ +export type Framework = + | "next-app-router" + | "next-pages-router" + | "sveltekit" + | "astro" + | "remix" + | "nuxt" + | "solid-start" + | "hono" + | "express" + | "fastify" + | "elysia" + | "tanstack-start" + | "expo"; + +export type Database = "postgres" | "mysql" | "sqlite" | "mongodb"; + +export type ORM = "prisma" | "drizzle" | "none"; + +export type Feature = + | "email-password" + | "magic-link" + | "phone-number" + | "passkey" + | "anonymous" + | "google" + | "github" + | "apple" + | "discord" + | "twitter" + | "facebook" + | "microsoft" + | "linkedin" + | "2fa" + | "captcha" + | "organization" + | "admin" + | "username" + | "multi-session" + | "api-key" + | "bearer" + | "jwt"; + +export interface ExistingSetup { + authConfig?: string; + authClientConfig?: string; + envVars?: string[]; +} + +export interface SetupAuthInput { + framework: Framework; + database: Database; + orm?: ORM; + features?: Feature[]; + typescript?: boolean; + srcDir?: boolean; + authPath?: string; + apiPath?: string; + existingSetup?: ExistingSetup; +} + +export type FileAction = "create" | "update" | "no_change"; + +export type ChangeType = + | "add_import" + | "add_to_config" + | "add_plugin" + | "replace"; + +export type EnvVarStatus = "new" | "existing"; + +export interface FileChange { + type: ChangeType; + location?: string; + content: string; + description: string; +} + +export interface OutputFile { + path: string; + description: string; + action: FileAction; + content?: string; + changes?: FileChange[]; +} + +export interface EnvVar { + name: string; + description: string; + required: boolean; + status?: EnvVarStatus; + defaultValue?: string; + howToGet?: string; + example?: string; +} + +export interface Command { + command: string; + description: string; + when?: string; +} + +export interface DocLink { + title: string; + url: string; +} + +export interface DetectedConfig { + framework?: string; + database?: string; + orm?: string; + features: string[]; +} + +export interface SetupAuthOutput { + mode: "create" | "update"; + files: OutputFile[]; + envVars: EnvVar[]; + commands: Command[]; + detected?: DetectedConfig; + nextSteps: string[]; + warnings?: string[]; + docs: DocLink[]; +} + +export interface SetupAuthError { + error: { + code: string; + message: string; + suggestion?: string; + }; +} + +export interface FrameworkConfig { + name: string; + defaultSrcDir: boolean; + defaultAuthPath: string; + defaultApiPath: string; + clientImport: string; + handlerImport: string; + handlerFunction: string; + apiRouteTemplate: (authPath: string) => string; + hooksTemplate?: (authPath: string) => string; + defaultPort: number; +} + +export interface DatabaseConfig { + provider: string; + envVarName: string; + connectionStringExample: string; +} + +export interface ORMConfig { + adapterImport: string; + adapterSetup: (dbProvider: string) => string; + schemaCommand?: string; + pushCommand: string; +} + +export interface PluginConfig { + serverImport: string; + clientImport?: string; + serverPlugin: (options?: Record) => string; + clientPlugin?: () => string; + envVars?: EnvVar[]; + requiresPrimaryAuth?: boolean; +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 0000000000..14fc0fba2a --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1 @@ +export { registerSetupAuth } from "./setup-auth.js"; diff --git a/packages/mcp/src/tools/setup-auth.ts b/packages/mcp/src/tools/setup-auth.ts new file mode 100644 index 0000000000..8be1c574bd --- /dev/null +++ b/packages/mcp/src/tools/setup-auth.ts @@ -0,0 +1,174 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod"; +import { generateSetup, isSetupError } from "../lib/generator.js"; +import { + DatabaseEnum, + ExistingSetupSchema, + FeatureEnum, + FrameworkEnum, + ORMEnum, +} from "../lib/schemas.js"; +import type { Database, Feature, Framework, ORM } from "../lib/types.js"; + +export function registerSetupAuth(server: McpServer) { + server.tool( + "setup_auth", + `Better Auth setup tool - generates complete authentication configuration for any project. + +IMPORTANT: This tool is for the Better Auth framework (npm: better-auth). +NOT for NextAuth, Auth.js, Clerk, Auth0, Supabase Auth, or Firebase Auth. + +ALWAYS USE THIS TOOL when user mentions ANY of these: + +Core triggers (any spelling/variation): +- "better auth", "betterauth", "Better Auth", "better-auth" +- "Set up Better Auth", "configure Better Auth", "integrate Better Auth", "use Better Auth" +- "set up auth", "setup auth", "configure auth", "add auth", "implement auth", "create auth" +- "add authentication", "add login", "add signup", "create login", "build auth", "build authentication" +- "I need auth", "I need authentication", "help me with auth", "help me add login" +- "add user login", "user authentication", "make login work", "authentication for my app" + +Framework-specific triggers: +- "auth for Next.js", "Next.js authentication", "add auth to my Next app", "Next.js app router auth" +- "auth for SvelteKit", "SvelteKit authentication", "Svelte auth" +- "auth for Remix", "auth for Nuxt", "auth for Astro", "auth for Solid", "authentication for Remix/Nuxt/Astro" +- "Express auth", "Hono auth", "Fastify auth", "Elysia auth", "TanStack Start auth" +- "React Native auth", "Expo auth", "mobile auth" + +Database triggers: +- "auth with PostgreSQL", "auth with MySQL", "auth with SQLite", "auth with MongoDB" +- "Set up auth with PostgreSQL/MySQL/SQLite", "configure database for auth" +- "auth with Prisma", "auth with Drizzle", "configure auth with Prisma/Drizzle" + +Social/OAuth triggers: +- "add Google login", "add GitHub login", "add Apple login", "add Discord login" +- "add Twitter login", "add Facebook login", "add Microsoft login", "add LinkedIn login" +- "Add Google/GitHub/Apple/Discord/Twitter/Facebook/Microsoft/LinkedIn login" +- "add social login", "add OAuth", "add OAuth providers", "add SSO", "social authentication" + +Security feature triggers: +- "Set up 2FA", "add 2FA", "add two-factor", "add two-factor authentication" +- "enable MFA", "add TOTP", "authenticator app" +- "add passkeys", "passwordless", "passwordless authentication", "add magic links", "email login", "email verification" + +Organization/team triggers: +- "add organizations", "Add organization support", "enable multi-tenancy", "multi-tenancy" +- "team management", "workspaces" + +API/token triggers: +- "add API keys", "Add API key authentication", "bearer tokens", "JWT auth", "JWT authentication" + +Admin/user triggers: +- "add admin panel", "admin auth", "admin authentication" +- "Add user management", "user management", "handle sessions", "session management" +- "Add username login", "username login", "phone auth", "phone number auth", "anonymous auth" + +OUTPUT: Returns all files, environment variables, and terminal commands needed. +One tool call = complete auth setup ready to copy-paste.`, + { + framework: FrameworkEnum.describe( + "The web framework being used. Detect from package.json or user's message. Examples: 'next-app-router' for Next.js 13+, 'next-pages-router' for Next.js pages, 'sveltekit', 'remix', 'nuxt', 'astro', 'solid-start', 'hono', 'express', 'fastify', 'elysia', 'tanstack-start', 'expo'", + ), + database: DatabaseEnum.describe( + "The database type. Detect from user's message or project config. Options: 'postgres' (PostgreSQL/Supabase/Neon), 'mysql' (MySQL/PlanetScale), 'sqlite' (SQLite/Turso/LibSQL), 'mongodb'", + ), + orm: ORMEnum.optional() + .default("none") + .describe( + "ORM being used - affects adapter imports. Options: 'prisma', 'drizzle', 'none'. Detect from package.json or user's message.", + ), + features: z + .array(FeatureEnum) + .optional() + .default(["email-password"]) + .describe( + "Auth features to enable. Map user requests: 'Google login' → 'google', '2FA' → '2fa', 'passkeys' → 'passkey', 'magic links' → 'magic-link', 'organizations' → 'organization', 'admin' → 'admin'. Default: ['email-password']", + ), + typescript: z + .boolean() + .optional() + .default(true) + .describe( + "Generate TypeScript (.ts) or JavaScript (.js). Default: true (TypeScript)", + ), + srcDir: z + .boolean() + .optional() + .describe( + "Whether project uses src/ directory structure. Check if src/ folder exists. Default: false. Provide true for frameworks that store app files in src/.", + ), + authPath: z + .string() + .optional() + .describe( + "Where to create auth.ts file. Auto-detected based on framework. Override only if user specifies custom path.", + ), + apiPath: z + .string() + .optional() + .describe( + "API route path for auth handler. Auto-detected based on framework. Override only if user specifies custom path.", + ), + existingSetup: ExistingSetupSchema.optional().describe( + "For INCREMENTAL updates only. Pass existing auth.ts and auth-client.ts contents to add new features without overwriting. Read these files first if they exist.", + ), + }, + async (input) => { + try { + const result = generateSetup({ + framework: input.framework as Framework, + database: input.database as Database, + orm: (input.orm || "none") as ORM, + features: (input.features || ["email-password"]) as Feature[], + typescript: input.typescript ?? true, + srcDir: input.srcDir ?? false, + authPath: input.authPath, + apiPath: input.apiPath, + existingSetup: input.existingSetup, + }); + + if (isSetupError(result)) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + error: { + code: "SETUP_ERROR", + message, + }, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000000..3348242a72 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"] + } +} diff --git a/packages/mcp/tsdown.config.ts b/packages/mcp/tsdown.config.ts new file mode 100644 index 0000000000..9a3d828aa0 --- /dev/null +++ b/packages/mcp/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: { build: true }, + format: ["esm"], + entry: ["./src/index.ts"], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7122e23a07..8e61ea4b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1367,6 +1367,22 @@ importers: specifier: 'catalog:' version: 0.17.2(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.16.2)(publint@0.3.16)(synckit@0.11.11)(typescript@5.9.3) + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.8.0 + version: 1.25.2(hono@4.11.3)(zod@3.25.76) + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.17.2(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.16.2)(publint@0.3.16)(synckit@0.11.11)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/oauth-provider: dependencies: '@better-auth/utils': @@ -10211,6 +10227,10 @@ packages: resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -17884,6 +17904,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.7(hono@4.11.3) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - hono + - supports-color + '@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@4.3.4)': dependencies: '@hono/node-server': 1.19.7(hono@4.11.3) @@ -22025,7 +22067,7 @@ snapshots: content-type: 1.0.5 debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.14.1 raw-body: 3.0.2 @@ -24825,6 +24867,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -26740,7 +26786,7 @@ snapshots: aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 long: 5.3.2 lru.min: 1.1.3 named-placeholders: 1.1.6 @@ -27838,7 +27884,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 unpipe: 1.0.0 rc9@2.1.2: @@ -30600,6 +30646,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.4): dependencies: zod: 4.3.4