pai-zero 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/pai.js CHANGED
@@ -511,16 +511,26 @@ async function interactiveInterview(analysis, _cwd, projectName) {
511
511
  }]);
512
512
  if (mcpDev) {
513
513
  console.log("");
514
+ const hasSupabase = extraTools.includes("supabase");
515
+ const typeChoices = [
516
+ { name: `Tools \uC11C\uBC84 (\uAC1C\uC778\uC6A9) ${colors.dim("\u2500 AI\uAC00 \uD638\uCD9C\uD560 \uAE30\uB2A5 \uC81C\uACF5")}`, value: "tools" }
517
+ ];
518
+ if (hasSupabase) {
519
+ typeChoices.push({
520
+ name: `Tools \uC11C\uBC84 (\uACF5\uAC1C \uBC30\uD3EC) ${colors.dim("\u2500 API \uD0A4 + \uAD00\uB9AC\uD654\uBA74 + \uC0AC\uC6A9\uB7C9 \uCD94\uC801")}`,
521
+ value: "tools-public"
522
+ });
523
+ }
524
+ typeChoices.push(
525
+ { name: `Resources \uC11C\uBC84 ${colors.dim("\u2500 AI\uAC00 \uC77D\uC744 \uCEE8\uD14D\uC2A4\uD2B8 \uC81C\uACF5")}`, value: "resources" },
526
+ { name: `Prompts \uC11C\uBC84 ${colors.dim("\u2500 \uC7AC\uC0AC\uC6A9 \uD504\uB86C\uD504\uD2B8 \uD15C\uD50C\uB9BF")}`, value: "prompts" },
527
+ { name: `All-in-one ${colors.dim("\u2500 \uBAA8\uB450 \uD3EC\uD568")}`, value: "all" }
528
+ );
514
529
  const { mcpType } = await inquirer.prompt([{
515
530
  type: "list",
516
531
  name: "mcpType",
517
532
  message: "MCP \uC11C\uBC84 \uC720\uD615:",
518
- choices: [
519
- { name: `Tools \uC11C\uBC84 ${colors.dim("\u2500 AI\uAC00 \uD638\uCD9C\uD560 \uAE30\uB2A5 \uC81C\uACF5")}`, value: "tools" },
520
- { name: `Resources \uC11C\uBC84 ${colors.dim("\u2500 AI\uAC00 \uC77D\uC744 \uCEE8\uD14D\uC2A4\uD2B8 \uC81C\uACF5")}`, value: "resources" },
521
- { name: `Prompts \uC11C\uBC84 ${colors.dim("\u2500 \uC7AC\uC0AC\uC6A9 \uD504\uB86C\uD504\uD2B8 \uD15C\uD50C\uB9BF")}`, value: "prompts" },
522
- { name: `All-in-one ${colors.dim("\u2500 \uBAA8\uB450 \uD3EC\uD568")}`, value: "all" }
523
- ]
533
+ choices: typeChoices
524
534
  }]);
525
535
  const { mcpName } = await inquirer.prompt([{
526
536
  type: "input",
@@ -529,7 +539,11 @@ async function interactiveInterview(analysis, _cwd, projectName) {
529
539
  default: `${projectName}-mcp`,
530
540
  validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v.trim()) ? true : "\uC601\uBB38 \uC18C\uBB38\uC790, \uC22B\uC790, \uD558\uC774\uD508(-)\uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4."
531
541
  }]);
532
- mcp = { enabled: true, type: mcpType, name: mcpName.trim() };
542
+ mcp = {
543
+ enabled: true,
544
+ type: mcpType,
545
+ name: mcpName.trim()
546
+ };
533
547
  extraTools.push("mcp");
534
548
  }
535
549
  step(3, 3, "\uC124\uCE58 \uD655\uC778");
@@ -964,6 +978,7 @@ async function provisionMcp(ctx) {
964
978
  await fs4.ensureDir(mcpDir);
965
979
  await fs4.ensureDir(join3(mcpDir, "src"));
966
980
  await fs4.ensureDir(join3(mcpDir, "tests"));
981
+ const isPublicDeps = mcp.type === "tools-public";
967
982
  const pkgJson = {
968
983
  name: mcp.name,
969
984
  version: "0.1.0",
@@ -978,7 +993,8 @@ async function provisionMcp(ctx) {
978
993
  start: "node dist/index.js"
979
994
  },
980
995
  dependencies: {
981
- "@modelcontextprotocol/sdk": "^1.0.0"
996
+ "@modelcontextprotocol/sdk": "^1.0.0",
997
+ ...isPublicDeps ? { "@supabase/supabase-js": "^2.48.0" } : {}
982
998
  },
983
999
  devDependencies: {
984
1000
  typescript: "^5.7.0",
@@ -1014,10 +1030,53 @@ async function provisionMcp(ctx) {
1014
1030
  if (!await fs4.pathExists(indexPath)) {
1015
1031
  await fs4.writeFile(indexPath, buildIndexTs(mcp), "utf8");
1016
1032
  }
1017
- if (mcp.type === "tools" || mcp.type === "all") {
1033
+ const isTools = mcp.type === "tools" || mcp.type === "tools-public" || mcp.type === "all";
1034
+ const isPublic = mcp.type === "tools-public";
1035
+ if (isTools) {
1018
1036
  const toolsPath = join3(mcpDir, "src", "tools.ts");
1019
1037
  if (!await fs4.pathExists(toolsPath)) {
1020
- await fs4.writeFile(toolsPath, TOOLS_TEMPLATE, "utf8");
1038
+ await fs4.writeFile(toolsPath, isPublic ? TOOLS_PUBLIC_TEMPLATE : TOOLS_TEMPLATE, "utf8");
1039
+ }
1040
+ }
1041
+ if (isPublic) {
1042
+ const authPath = join3(mcpDir, "src", "auth.ts");
1043
+ if (!await fs4.pathExists(authPath)) {
1044
+ await fs4.writeFile(authPath, AUTH_MIDDLEWARE_TEMPLATE, "utf8");
1045
+ }
1046
+ const rateLimitPath = join3(mcpDir, "src", "rate-limit.ts");
1047
+ if (!await fs4.pathExists(rateLimitPath)) {
1048
+ await fs4.writeFile(rateLimitPath, RATE_LIMIT_TEMPLATE, "utf8");
1049
+ }
1050
+ const migDir = join3(ctx.cwd, "supabase", "migrations");
1051
+ await fs4.ensureDir(migDir);
1052
+ const migPath = join3(migDir, "20260101_mcp_keys_and_usage.sql");
1053
+ if (!await fs4.pathExists(migPath)) {
1054
+ await fs4.writeFile(migPath, SUPABASE_MIGRATION, "utf8");
1055
+ }
1056
+ const dashDir = join3(ctx.cwd, "src", "app", "dashboard");
1057
+ await fs4.ensureDir(join3(dashDir, "keys"));
1058
+ await fs4.ensureDir(join3(dashDir, "usage"));
1059
+ const dashLayoutPath = join3(dashDir, "layout.tsx");
1060
+ if (!await fs4.pathExists(dashLayoutPath)) {
1061
+ await fs4.writeFile(dashLayoutPath, DASHBOARD_LAYOUT, "utf8");
1062
+ }
1063
+ const dashPagePath = join3(dashDir, "page.tsx");
1064
+ if (!await fs4.pathExists(dashPagePath)) {
1065
+ await fs4.writeFile(dashPagePath, DASHBOARD_PAGE, "utf8");
1066
+ }
1067
+ const keysPagePath = join3(dashDir, "keys", "page.tsx");
1068
+ if (!await fs4.pathExists(keysPagePath)) {
1069
+ await fs4.writeFile(keysPagePath, KEYS_PAGE, "utf8");
1070
+ }
1071
+ const usagePagePath = join3(dashDir, "usage", "page.tsx");
1072
+ if (!await fs4.pathExists(usagePagePath)) {
1073
+ await fs4.writeFile(usagePagePath, USAGE_PAGE, "utf8");
1074
+ }
1075
+ const apiKeysDir = join3(ctx.cwd, "src", "app", "api", "keys");
1076
+ await fs4.ensureDir(apiKeysDir);
1077
+ const keysRoutePath = join3(apiKeysDir, "route.ts");
1078
+ if (!await fs4.pathExists(keysRoutePath)) {
1079
+ await fs4.writeFile(keysRoutePath, KEYS_API_ROUTE, "utf8");
1021
1080
  }
1022
1081
  }
1023
1082
  if (mcp.type === "resources" || mcp.type === "all") {
@@ -1040,6 +1099,17 @@ async function provisionMcp(ctx) {
1040
1099
  if (!await fs4.pathExists(readmePath)) {
1041
1100
  await fs4.writeFile(readmePath, buildReadme(mcp), "utf8");
1042
1101
  }
1102
+ const openspecPath = join3(ctx.cwd, "docs", "openspec.md");
1103
+ if (await fs4.pathExists(openspecPath)) {
1104
+ const existing = await fs4.readFile(openspecPath, "utf8");
1105
+ if (!existing.includes("## MCP \uC11C\uBC84")) {
1106
+ const section2 = buildOpenSpecMcpSection(mcp);
1107
+ await fs4.appendFile(openspecPath, `
1108
+
1109
+ ${section2}
1110
+ `);
1111
+ }
1112
+ }
1043
1113
  const mcpJsonPath = join3(ctx.cwd, ".mcp.json");
1044
1114
  const mcpJson = await fs4.pathExists(mcpJsonPath) ? await fs4.readJson(mcpJsonPath) : { mcpServers: {} };
1045
1115
  mcpJson.mcpServers[mcp.name] = {
@@ -1104,7 +1174,72 @@ main().catch((err) => {
1104
1174
  });
1105
1175
  `;
1106
1176
  }
1177
+ function buildOpenSpecMcpSection(mcp) {
1178
+ const isPublic = mcp.type === "tools-public";
1179
+ const hasTools = mcp.type === "tools" || mcp.type === "tools-public" || mcp.type === "all";
1180
+ const hasResources = mcp.type === "resources" || mcp.type === "all";
1181
+ const hasPrompts = mcp.type === "prompts" || mcp.type === "all";
1182
+ const lines = [
1183
+ "## MCP \uC11C\uBC84 (\uC790\uB3D9 \uC0DD\uC131\uB428)",
1184
+ "",
1185
+ "\uC774 \uD504\uB85C\uC81D\uD2B8\uB294 MCP(Model Context Protocol) \uC11C\uBC84\uB97C \uD3EC\uD568\uD569\uB2C8\uB2E4.",
1186
+ "`/pai design` \uC2E4\uD589 \uC2DC \uC544\uB798 \uD45C\uB97C \uC2EC\uCE35 \uC778\uD130\uBDF0\uB85C \uCC44\uC6B0\uC138\uC694.",
1187
+ "",
1188
+ `- **\uC774\uB984**: ${mcp.name}`,
1189
+ `- **\uD0C0\uC785**: ${mcp.type}`
1190
+ ];
1191
+ if (isPublic) {
1192
+ lines.push(
1193
+ "- **\uC778\uD504\uB77C**: Supabase (api_keys, usage_logs)",
1194
+ "- **\uB808\uC774\uD2B8 \uB9AC\uBC0B**: 100\uD68C/\uC2DC\uAC04 (\uAE30\uBCF8\uAC12)",
1195
+ "- **\uAD00\uB9AC \uD654\uBA74**: `/dashboard/keys`, `/dashboard/usage`"
1196
+ );
1197
+ }
1198
+ lines.push("", "### MCP \uB3C4\uAD6C / \uB9AC\uC18C\uC2A4 / \uD504\uB86C\uD504\uD2B8 \uC124\uACC4");
1199
+ if (hasTools) {
1200
+ lines.push(
1201
+ "",
1202
+ "#### Tools (AI\uAC00 \uD638\uCD9C\uD560 \uAE30\uB2A5)",
1203
+ "",
1204
+ "| \uB3C4\uAD6C \uC774\uB984 | \uC124\uBA85 | \uC785\uB825 \uD30C\uB77C\uBBF8\uD130 | \uCD9C\uB825 \uD615\uC2DD | \uB0B4\uBD80 \uB85C\uC9C1 |",
1205
+ "|----------|------|-------------|----------|----------|",
1206
+ "| TODO | TODO | TODO | TODO | TODO |"
1207
+ );
1208
+ }
1209
+ if (hasResources) {
1210
+ lines.push(
1211
+ "",
1212
+ "#### Resources (AI\uAC00 \uC77D\uC744 \uCEE8\uD14D\uC2A4\uD2B8)",
1213
+ "",
1214
+ "| URI | \uC774\uB984 | \uC124\uBA85 | MIME \uD0C0\uC785 |",
1215
+ "|-----|------|------|----------|",
1216
+ "| TODO | TODO | TODO | TODO |"
1217
+ );
1218
+ }
1219
+ if (hasPrompts) {
1220
+ lines.push(
1221
+ "",
1222
+ "#### Prompts (\uC7AC\uC0AC\uC6A9 \uD504\uB86C\uD504\uD2B8 \uD15C\uD50C\uB9BF)",
1223
+ "",
1224
+ "| \uC774\uB984 | \uC124\uBA85 | \uC778\uC790 |",
1225
+ "|------|------|------|",
1226
+ "| TODO | TODO | TODO |"
1227
+ );
1228
+ }
1229
+ if (isPublic) {
1230
+ lines.push(
1231
+ "",
1232
+ "### \uACF5\uAC1C \uBC30\uD3EC \uC124\uC815",
1233
+ "",
1234
+ "- **\uB808\uC774\uD2B8 \uB9AC\uBC0B**: TODO (\uAE30\uBCF8 100\uD68C/\uC2DC\uAC04)",
1235
+ "- **\uC694\uAE08\uC81C**: \uBB34\uB8CC (Stripe \uC81C\uC678)",
1236
+ "- **API \uD0A4 \uC815\uCC45**: \uC0AC\uC6A9\uC790\uB2F9 \uBCF5\uC218 \uD0A4 \uD5C8\uC6A9"
1237
+ );
1238
+ }
1239
+ return lines.join("\n");
1240
+ }
1107
1241
  function buildReadme(mcp) {
1242
+ const isPublic = mcp.type === "tools-public";
1108
1243
  return `# ${mcp.name} \u2014 MCP Server
1109
1244
 
1110
1245
  PAI\uAC00 \uC0DD\uC131\uD55C MCP(Model Context Protocol) \uC11C\uBC84\uC785\uB2C8\uB2E4.
@@ -1112,9 +1247,35 @@ PAI\uAC00 \uC0DD\uC131\uD55C MCP(Model Context Protocol) \uC11C\uBC84\uC785\uB2C
1112
1247
  ## \uD0C0\uC785: ${mcp.type}
1113
1248
 
1114
1249
  ${mcp.type === "tools" || mcp.type === "all" ? "- **Tools**: AI\uAC00 \uD638\uCD9C\uD560 \uAE30\uB2A5 \uC81C\uACF5 (src/tools.ts)" : ""}
1250
+ ${isPublic ? "- **Tools (\uACF5\uAC1C \uBC30\uD3EC)**: API \uD0A4 \uAC80\uC99D + \uB808\uC774\uD2B8 \uB9AC\uBC0B + \uC0AC\uC6A9\uB7C9 \uCD94\uC801 (src/tools.ts, auth.ts, rate-limit.ts)" : ""}
1115
1251
  ${mcp.type === "resources" || mcp.type === "all" ? "- **Resources**: AI\uAC00 \uC77D\uC744 \uCEE8\uD14D\uC2A4\uD2B8 \uC81C\uACF5 (src/resources.ts)" : ""}
1116
1252
  ${mcp.type === "prompts" || mcp.type === "all" ? "- **Prompts**: \uC7AC\uC0AC\uC6A9 \uD504\uB86C\uD504\uD2B8 \uD15C\uD50C\uB9BF (src/prompts.ts)" : ""}
1117
1253
 
1254
+ ${isPublic ? `
1255
+ ## \uACF5\uAC1C \uBC30\uD3EC \uC124\uC815
1256
+
1257
+ 1. **Supabase \uB9C8\uC774\uADF8\uB808\uC774\uC158 \uC2E4\uD589**
1258
+ \\\`\\\`\\\`bash
1259
+ supabase db push
1260
+ \\\`\\\`\\\`
1261
+ (\\\`supabase/migrations/\\\` \uCC38\uACE0)
1262
+
1263
+ 2. **\uD658\uACBD \uBCC0\uC218 \uC124\uC815 (.env.local)**
1264
+ \\\`\\\`\\\`
1265
+ NEXT_PUBLIC_SUPABASE_URL=...
1266
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=...
1267
+ SUPABASE_SERVICE_ROLE_KEY=...
1268
+ \\\`\\\`\\\`
1269
+
1270
+ 3. **\uB300\uC2DC\uBCF4\uB4DC \uC811\uC18D**
1271
+ \\\`\\\`\\\`bash
1272
+ npm run dev
1273
+ # \u2192 http://localhost:3000/dashboard
1274
+ \\\`\\\`\\\`
1275
+ - API \uD0A4 \uBC1C\uAE09 \uBC0F \uAD00\uB9AC
1276
+ - \uC0AC\uC6A9\uB7C9 \uD655\uC778
1277
+ ` : ""}
1278
+
1118
1279
  ## \uAC1C\uBC1C
1119
1280
 
1120
1281
  \`\`\`bash
@@ -1147,10 +1308,368 @@ claude mcp add ${mcp.name} -- node ./mcp-server/dist/index.js
1147
1308
  - [SDK \uBB38\uC11C](https://github.com/modelcontextprotocol/sdk)
1148
1309
  `;
1149
1310
  }
1150
- var TOOLS_TEMPLATE, RESOURCES_TEMPLATE, PROMPTS_TEMPLATE, TEST_TEMPLATE;
1311
+ var TOOLS_PUBLIC_TEMPLATE, AUTH_MIDDLEWARE_TEMPLATE, RATE_LIMIT_TEMPLATE, SUPABASE_MIGRATION, DASHBOARD_LAYOUT, DASHBOARD_PAGE, KEYS_PAGE, USAGE_PAGE, KEYS_API_ROUTE, TOOLS_TEMPLATE, RESOURCES_TEMPLATE, PROMPTS_TEMPLATE, TEST_TEMPLATE;
1151
1312
  var init_mcp = __esm({
1152
1313
  "src/stages/environment/provisioners/mcp.ts"() {
1153
1314
  "use strict";
1315
+ TOOLS_PUBLIC_TEMPLATE = `import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
1316
+ import {
1317
+ CallToolRequestSchema,
1318
+ ListToolsRequestSchema,
1319
+ } from '@modelcontextprotocol/sdk/types.js';
1320
+ import { verifyApiKey } from './auth.js';
1321
+ import { checkRateLimit, logUsage } from './rate-limit.js';
1322
+
1323
+ export function registerTools(server: Server): void {
1324
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1325
+ tools: [
1326
+ {
1327
+ name: 'hello',
1328
+ description: '\uC778\uC99D\uB41C \uB3C4\uAD6C \uD638\uCD9C \u2014 API \uD0A4 \uD544\uC694',
1329
+ inputSchema: {
1330
+ type: 'object',
1331
+ properties: {
1332
+ apiKey: { type: 'string', description: 'API \uD0A4' },
1333
+ name: { type: 'string', description: '\uC778\uC0AC\uD560 \uB300\uC0C1' },
1334
+ },
1335
+ required: ['apiKey', 'name'],
1336
+ },
1337
+ },
1338
+ ],
1339
+ }));
1340
+
1341
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1342
+ const { name, arguments: args } = request.params;
1343
+ const { apiKey, ...params } = (args as { apiKey?: string; [k: string]: unknown }) ?? {};
1344
+
1345
+ if (!apiKey) throw new Error('API key required');
1346
+ const user = await verifyApiKey(apiKey);
1347
+ if (!user) throw new Error('Invalid API key');
1348
+
1349
+ const allowed = await checkRateLimit(user.id);
1350
+ if (!allowed) throw new Error('Rate limit exceeded');
1351
+
1352
+ if (name === 'hello') {
1353
+ const target = (params as { name?: string })?.name ?? 'world';
1354
+ const result = { content: [{ type: 'text' as const, text: \`Hello, \${target}!\` }] };
1355
+ await logUsage(user.id, name);
1356
+ return result;
1357
+ }
1358
+
1359
+ throw new Error(\`Unknown tool: \${name}\`);
1360
+ });
1361
+ }
1362
+ `;
1363
+ AUTH_MIDDLEWARE_TEMPLATE = `import { createClient } from '@supabase/supabase-js';
1364
+
1365
+ const supabase = createClient(
1366
+ process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
1367
+ process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1368
+ );
1369
+
1370
+ export interface AuthUser {
1371
+ id: string;
1372
+ email: string;
1373
+ }
1374
+
1375
+ /**
1376
+ * API \uD0A4 \uAC80\uC99D \u2014 Supabase api_keys \uD14C\uC774\uBE14\uC5D0\uC11C \uC870\uD68C
1377
+ */
1378
+ export async function verifyApiKey(apiKey: string): Promise<AuthUser | null> {
1379
+ const { data, error } = await supabase
1380
+ .from('api_keys')
1381
+ .select('user_id, revoked_at, users(id, email)')
1382
+ .eq('key_hash', await hashKey(apiKey))
1383
+ .single();
1384
+
1385
+ if (error || !data || data.revoked_at) return null;
1386
+
1387
+ // \uB9C8\uC9C0\uB9C9 \uC0AC\uC6A9 \uC2DC\uAC04 \uC5C5\uB370\uC774\uD2B8
1388
+ await supabase
1389
+ .from('api_keys')
1390
+ .update({ last_used_at: new Date().toISOString() })
1391
+ .eq('key_hash', await hashKey(apiKey));
1392
+
1393
+ const users = (data as unknown as { users: { id: string; email: string } }).users;
1394
+ return { id: users.id, email: users.email };
1395
+ }
1396
+
1397
+ async function hashKey(key: string): Promise<string> {
1398
+ const crypto = await import('node:crypto');
1399
+ return crypto.createHash('sha256').update(key).digest('hex');
1400
+ }
1401
+
1402
+ export async function generateApiKey(): Promise<{ key: string; hash: string }> {
1403
+ const crypto = await import('node:crypto');
1404
+ const key = \`mcp_\${crypto.randomBytes(32).toString('hex')}\`;
1405
+ const hash = crypto.createHash('sha256').update(key).digest('hex');
1406
+ return { key, hash };
1407
+ }
1408
+ `;
1409
+ RATE_LIMIT_TEMPLATE = `import { createClient } from '@supabase/supabase-js';
1410
+
1411
+ const supabase = createClient(
1412
+ process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
1413
+ process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1414
+ );
1415
+
1416
+ const RATE_LIMIT_PER_HOUR = 100;
1417
+
1418
+ /**
1419
+ * Rate limit \uCCB4\uD06C \u2014 \uCD5C\uADFC 1\uC2DC\uAC04 \uB0B4 \uD638\uCD9C \uC218 \uD655\uC778
1420
+ */
1421
+ export async function checkRateLimit(userId: string): Promise<boolean> {
1422
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
1423
+ const { count } = await supabase
1424
+ .from('usage_logs')
1425
+ .select('*', { count: 'exact', head: true })
1426
+ .eq('user_id', userId)
1427
+ .gte('created_at', oneHourAgo);
1428
+
1429
+ return (count ?? 0) < RATE_LIMIT_PER_HOUR;
1430
+ }
1431
+
1432
+ /**
1433
+ * \uC0AC\uC6A9\uB7C9 \uAE30\uB85D \u2014 usage_logs \uD14C\uC774\uBE14\uC5D0 \uC800\uC7A5
1434
+ */
1435
+ export async function logUsage(userId: string, toolName: string): Promise<void> {
1436
+ await supabase.from('usage_logs').insert({
1437
+ user_id: userId,
1438
+ tool_name: toolName,
1439
+ created_at: new Date().toISOString(),
1440
+ });
1441
+ }
1442
+ `;
1443
+ SUPABASE_MIGRATION = `-- MCP Tools \uACF5\uAC1C \uBC30\uD3EC \u2014 API \uD0A4 \uAD00\uB9AC + \uC0AC\uC6A9\uB7C9 \uCD94\uC801
1444
+ -- Generated by PAI
1445
+
1446
+ -- \uC0AC\uC6A9\uC790 \uD14C\uC774\uBE14 (Supabase Auth\uC640 \uC5F0\uB3D9)
1447
+ create table if not exists public.users (
1448
+ id uuid primary key references auth.users(id) on delete cascade,
1449
+ email text not null unique,
1450
+ created_at timestamptz default now()
1451
+ );
1452
+
1453
+ -- API \uD0A4 \uD14C\uC774\uBE14
1454
+ create table if not exists public.api_keys (
1455
+ id uuid primary key default gen_random_uuid(),
1456
+ user_id uuid not null references public.users(id) on delete cascade,
1457
+ key_hash text not null unique,
1458
+ name text not null,
1459
+ created_at timestamptz default now(),
1460
+ last_used_at timestamptz,
1461
+ revoked_at timestamptz
1462
+ );
1463
+
1464
+ create index if not exists api_keys_user_id_idx on public.api_keys(user_id);
1465
+ create index if not exists api_keys_key_hash_idx on public.api_keys(key_hash);
1466
+
1467
+ -- \uC0AC\uC6A9\uB7C9 \uB85C\uADF8 \uD14C\uC774\uBE14
1468
+ create table if not exists public.usage_logs (
1469
+ id bigserial primary key,
1470
+ user_id uuid not null references public.users(id) on delete cascade,
1471
+ tool_name text not null,
1472
+ created_at timestamptz default now()
1473
+ );
1474
+
1475
+ create index if not exists usage_logs_user_id_idx on public.usage_logs(user_id);
1476
+ create index if not exists usage_logs_created_at_idx on public.usage_logs(created_at);
1477
+
1478
+ -- RLS (Row Level Security)
1479
+ alter table public.users enable row level security;
1480
+ alter table public.api_keys enable row level security;
1481
+ alter table public.usage_logs enable row level security;
1482
+
1483
+ create policy "Users can view own data" on public.users
1484
+ for select using (auth.uid() = id);
1485
+
1486
+ create policy "Users can manage own api keys" on public.api_keys
1487
+ for all using (auth.uid() = user_id);
1488
+
1489
+ create policy "Users can view own usage" on public.usage_logs
1490
+ for select using (auth.uid() = user_id);
1491
+ `;
1492
+ DASHBOARD_LAYOUT = `import Link from 'next/link';
1493
+ import type { ReactNode } from 'react';
1494
+
1495
+ export default function DashboardLayout({ children }: { children: ReactNode }) {
1496
+ return (
1497
+ <div style={{ display: 'flex', minHeight: '100vh' }}>
1498
+ <aside style={{ width: 240, borderRight: '1px solid #eee', padding: 24 }}>
1499
+ <h2 style={{ fontSize: 18, marginBottom: 16 }}>MCP \uB300\uC2DC\uBCF4\uB4DC</h2>
1500
+ <nav>
1501
+ <Link href="/dashboard" style={{ display: 'block', padding: '8px 0' }}>\uAC1C\uC694</Link>
1502
+ <Link href="/dashboard/keys" style={{ display: 'block', padding: '8px 0' }}>API \uD0A4</Link>
1503
+ <Link href="/dashboard/usage" style={{ display: 'block', padding: '8px 0' }}>\uC0AC\uC6A9\uB7C9</Link>
1504
+ </nav>
1505
+ </aside>
1506
+ <main style={{ flex: 1, padding: 24 }}>{children}</main>
1507
+ </div>
1508
+ );
1509
+ }
1510
+ `;
1511
+ DASHBOARD_PAGE = `export default function DashboardPage() {
1512
+ return (
1513
+ <div>
1514
+ <h1>MCP \uB300\uC2DC\uBCF4\uB4DC</h1>
1515
+ <p>API \uD0A4\uB97C \uBC1C\uAE09\uD558\uACE0 \uC0AC\uC6A9\uB7C9\uC744 \uAD00\uB9AC\uD558\uC138\uC694.</p>
1516
+ <ul>
1517
+ <li><a href="/dashboard/keys">API \uD0A4 \uBC1C\uAE09</a></li>
1518
+ <li><a href="/dashboard/usage">\uC0AC\uC6A9\uB7C9 \uD655\uC778</a></li>
1519
+ </ul>
1520
+ </div>
1521
+ );
1522
+ }
1523
+ `;
1524
+ KEYS_PAGE = `'use client';
1525
+ import { useEffect, useState } from 'react';
1526
+
1527
+ interface ApiKey {
1528
+ id: string;
1529
+ name: string;
1530
+ created_at: string;
1531
+ last_used_at?: string;
1532
+ }
1533
+
1534
+ export default function KeysPage() {
1535
+ const [keys, setKeys] = useState<ApiKey[]>([]);
1536
+ const [newKeyName, setNewKeyName] = useState('');
1537
+ const [newKey, setNewKey] = useState<string | null>(null);
1538
+
1539
+ useEffect(() => {
1540
+ fetch('/api/keys').then(r => r.json()).then(setKeys);
1541
+ }, []);
1542
+
1543
+ async function create() {
1544
+ const res = await fetch('/api/keys', {
1545
+ method: 'POST',
1546
+ headers: { 'Content-Type': 'application/json' },
1547
+ body: JSON.stringify({ name: newKeyName }),
1548
+ });
1549
+ const data = await res.json();
1550
+ setNewKey(data.key);
1551
+ setKeys([...keys, data.apiKey]);
1552
+ setNewKeyName('');
1553
+ }
1554
+
1555
+ async function revoke(id: string) {
1556
+ await fetch(\`/api/keys/\${id}\`, { method: 'DELETE' });
1557
+ setKeys(keys.filter(k => k.id !== id));
1558
+ }
1559
+
1560
+ return (
1561
+ <div>
1562
+ <h1>API \uD0A4 \uAD00\uB9AC</h1>
1563
+ <div style={{ marginBottom: 24 }}>
1564
+ <input
1565
+ placeholder="\uD0A4 \uC774\uB984"
1566
+ value={newKeyName}
1567
+ onChange={e => setNewKeyName(e.target.value)}
1568
+ />
1569
+ <button onClick={create}>\uC0C8 \uD0A4 \uBC1C\uAE09</button>
1570
+ </div>
1571
+ {newKey && (
1572
+ <div style={{ padding: 16, background: '#fffbe6', marginBottom: 24 }}>
1573
+ <strong>\uC0C8 API \uD0A4 (\uD55C \uBC88\uB9CC \uD45C\uC2DC):</strong>
1574
+ <pre>{newKey}</pre>
1575
+ </div>
1576
+ )}
1577
+ <table style={{ width: '100%' }}>
1578
+ <thead>
1579
+ <tr><th>\uC774\uB984</th><th>\uC0DD\uC131\uC77C</th><th>\uB9C8\uC9C0\uB9C9 \uC0AC\uC6A9</th><th></th></tr>
1580
+ </thead>
1581
+ <tbody>
1582
+ {keys.map(k => (
1583
+ <tr key={k.id}>
1584
+ <td>{k.name}</td>
1585
+ <td>{new Date(k.created_at).toLocaleDateString()}</td>
1586
+ <td>{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : '\u2014'}</td>
1587
+ <td><button onClick={() => revoke(k.id)}>\uC0AD\uC81C</button></td>
1588
+ </tr>
1589
+ ))}
1590
+ </tbody>
1591
+ </table>
1592
+ </div>
1593
+ );
1594
+ }
1595
+ `;
1596
+ USAGE_PAGE = `'use client';
1597
+ import { useEffect, useState } from 'react';
1598
+
1599
+ interface UsageLog {
1600
+ tool_name: string;
1601
+ count: number;
1602
+ }
1603
+
1604
+ export default function UsagePage() {
1605
+ const [usage, setUsage] = useState<UsageLog[]>([]);
1606
+ const [total, setTotal] = useState(0);
1607
+
1608
+ useEffect(() => {
1609
+ fetch('/api/usage').then(r => r.json()).then(data => {
1610
+ setUsage(data.byTool ?? []);
1611
+ setTotal(data.total ?? 0);
1612
+ });
1613
+ }, []);
1614
+
1615
+ return (
1616
+ <div>
1617
+ <h1>\uC0AC\uC6A9\uB7C9</h1>
1618
+ <p>\uCD5C\uADFC 30\uC77C \uCD1D \uD638\uCD9C: <strong>{total}</strong></p>
1619
+ <table style={{ width: '100%' }}>
1620
+ <thead>
1621
+ <tr><th>\uB3C4\uAD6C</th><th>\uD638\uCD9C \uC218</th></tr>
1622
+ </thead>
1623
+ <tbody>
1624
+ {usage.map(u => (
1625
+ <tr key={u.tool_name}>
1626
+ <td>{u.tool_name}</td>
1627
+ <td>{u.count}</td>
1628
+ </tr>
1629
+ ))}
1630
+ </tbody>
1631
+ </table>
1632
+ </div>
1633
+ );
1634
+ }
1635
+ `;
1636
+ KEYS_API_ROUTE = `import { NextResponse } from 'next/server';
1637
+ import { createClient } from '@supabase/supabase-js';
1638
+ import crypto from 'node:crypto';
1639
+
1640
+ const supabase = createClient(
1641
+ process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
1642
+ process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1643
+ );
1644
+
1645
+ export async function GET() {
1646
+ // TODO: \uD604\uC7AC \uB85C\uADF8\uC778 \uC0AC\uC6A9\uC790\uC758 \uD0A4\uB9CC \uC870\uD68C (auth \uBBF8\uB4E4\uC6E8\uC5B4 \uC5F0\uB3D9 \uD544\uC694)
1647
+ const { data, error } = await supabase
1648
+ .from('api_keys')
1649
+ .select('id, name, created_at, last_used_at')
1650
+ .is('revoked_at', null);
1651
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
1652
+ return NextResponse.json(data);
1653
+ }
1654
+
1655
+ export async function POST(request: Request) {
1656
+ const { name } = await request.json();
1657
+ const key = \`mcp_\${crypto.randomBytes(32).toString('hex')}\`;
1658
+ const key_hash = crypto.createHash('sha256').update(key).digest('hex');
1659
+
1660
+ // TODO: \uD604\uC7AC \uB85C\uADF8\uC778 \uC0AC\uC6A9\uC790 ID \uC5F0\uB3D9
1661
+ const user_id = 'TODO-REPLACE-WITH-AUTH-USER-ID';
1662
+
1663
+ const { data, error } = await supabase
1664
+ .from('api_keys')
1665
+ .insert({ user_id, name, key_hash })
1666
+ .select('id, name, created_at')
1667
+ .single();
1668
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
1669
+
1670
+ return NextResponse.json({ apiKey: data, key });
1671
+ }
1672
+ `;
1154
1673
  TOOLS_TEMPLATE = `import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
1155
1674
  import {
1156
1675
  CallToolRequestSchema,
@@ -1917,6 +2436,37 @@ AskUserQuestion \uB3C4\uAD6C\uB85C \uC2EC\uCE35 \uC778\uD130\uBDF0\uB97C \uC9C4\
1917
2436
  - \uAE30\uC220 \uC2A4\uD0DD (Stack)
1918
2437
  - API \uC5D4\uB4DC\uD3EC\uC778\uD2B8
1919
2438
 
2439
+ ## Phase 2.5: MCP \uC11C\uBC84 \uC124\uACC4 (MCP \uD504\uB85C\uC81D\uD2B8\uB9CC)
2440
+
2441
+ \\\`.pai/config.json\\\`\uC758 \\\`mcp.enabled\\\`\uAC00 true\uC774\uBA74 \uC774 Phase\uB97C \uC218\uD589\uD558\uC138\uC694:
2442
+
2443
+ 1. \\\`docs/openspec.md\\\`\uC758 "## MCP \uC11C\uBC84" \uC139\uC158\uC744 \uCC3E\uC544 \uD604\uC7AC \uC815\uC758\uB41C \uB3C4\uAD6C/\uB9AC\uC18C\uC2A4/\uD504\uB86C\uD504\uD2B8 \uBAA9\uB85D \uD655\uC778
2444
+ 2. TODO\uB85C \uCC44\uC6CC\uC9C4 \uD56D\uBAA9\uC774 \uC788\uC73C\uBA74 AskUserQuestion\uC73C\uB85C \uC2EC\uCE35 \uC778\uD130\uBDF0:
2445
+
2446
+ **Tools \uC11C\uBC84 (tools / tools-public / all)**:
2447
+ - "\uC774 MCP \uC11C\uBC84\uC5D0\uC11C \uC81C\uACF5\uD560 \uB3C4\uAD6C \uC774\uB984\uACFC \uAE30\uB2A5\uC740?"
2448
+ - "\uAC01 \uB3C4\uAD6C\uC758 \uC785\uB825 \uD30C\uB77C\uBBF8\uD130(JSON Schema)\uB294?"
2449
+ - "\uCD9C\uB825 \uD615\uC2DD(text/json/image)\uC740?"
2450
+ - "\uB0B4\uBD80 \uB85C\uC9C1\uC740 \uC5B4\uB514\uC5D0 \uAD6C\uD604? (src/lib/ \uB610\uB294 \uC678\uBD80 API \uD638\uCD9C?)"
2451
+
2452
+ **Resources \uC11C\uBC84 (resources / all)**:
2453
+ - "AI\uC5D0\uAC8C \uC81C\uACF5\uD560 \uCEE8\uD14D\uC2A4\uD2B8\uB294 \uBB34\uC5C7\uC778\uAC00\uC694?"
2454
+ - "\uAC01 \uB9AC\uC18C\uC2A4\uC758 URI \uC2A4\uD0A4\uB9C8(\uC608: \\\`wiki://pages/\\\`)\uB294?"
2455
+ - "MIME \uD0C0\uC785(text/plain, application/json)\uC740?"
2456
+
2457
+ **Prompts \uC11C\uBC84 (prompts / all)**:
2458
+ - "\uC7AC\uC0AC\uC6A9 \uD504\uB86C\uD504\uD2B8 \uC774\uB984\uACFC \uC6A9\uB3C4\uB294?"
2459
+ - "\uAC01 \uD504\uB86C\uD504\uD2B8\uC5D0 \uD544\uC694\uD55C \uC778\uC790\uB294?"
2460
+
2461
+ 3. \uB2F5\uBCC0\uC744 \uC885\uD569\uD558\uC5EC \\\`openspec.md\\\`\uC758 MCP \uC139\uC158 \uD14C\uC774\uBE14\uC744 \uCC44\uC6C1\uB2C8\uB2E4.
2462
+
2463
+ 4. **\uACF5\uAC1C \uBC30\uD3EC(tools-public)**\uC778 \uACBD\uC6B0 \uCD94\uAC00 \uC9C8\uBB38:
2464
+ - "\uC2DC\uAC04\uB2F9 \uB808\uC774\uD2B8 \uB9AC\uBC0B\uC744 \uBCC0\uACBD\uD560\uAE4C\uC694? (\uAE30\uBCF8 100\uD68C/\uC2DC\uAC04)"
2465
+ - "\uAC01 \uB3C4\uAD6C\uBCC4 \uBE44\uC6A9\uC774 \uB2E4\uB974\uAC8C \uC124\uACC4\uB418\uB098\uC694?"
2466
+ - "\uB300\uC2DC\uBCF4\uB4DC\uC5D0\uC11C \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uD45C\uC2DC\uD560 \uBB34\uB8CC \uD55C\uB3C4\uB294?"
2467
+
2468
+ 5. MCP \uC11C\uBC84\uC758 \\\`src/tools.ts\\\`, \\\`src/resources.ts\\\` \uB4F1\uC5D0 \uC124\uACC4\uB41C \uB3C4\uAD6C\uB97C \uAD6C\uD604\uD558\uB3C4\uB85D \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uC548\uB0B4\uD558\uC138\uC694.
2469
+
1920
2470
  ## Phase 3: \uAE30\uC220 \uC81C\uC57D + \uD558\uB124\uC2A4 \uC5D4\uC9C0\uB2C8\uC5B4\uB9C1
1921
2471
 
1922
2472
  **\uBAA9\uD45C**: AI \uC5D0\uC774\uC804\uD2B8\uAC00 \uC548\uC804\uD558\uAC8C \uC791\uC5C5\uD560 \uC218 \uC788\uB294 \uD658\uACBD \uD655\uC778
@@ -2510,6 +3060,7 @@ var init_environment = __esm({
2510
3060
  await withSpinner("\uC124\uC815 \uC800\uC7A5 \uC911...", async () => {
2511
3061
  const config = createDefaultConfig(interview.projectName, interview.mode);
2512
3062
  config.plugins = pluginKeys;
3063
+ if (interview.mcp) config.mcp = interview.mcp;
2513
3064
  await saveConfig(input.cwd, config);
2514
3065
  await sleep(300);
2515
3066
  });