pai-zero 0.10.0 → 0.10.1

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") {
@@ -1105,6 +1164,7 @@ main().catch((err) => {
1105
1164
  `;
1106
1165
  }
1107
1166
  function buildReadme(mcp) {
1167
+ const isPublic = mcp.type === "tools-public";
1108
1168
  return `# ${mcp.name} \u2014 MCP Server
1109
1169
 
1110
1170
  PAI\uAC00 \uC0DD\uC131\uD55C MCP(Model Context Protocol) \uC11C\uBC84\uC785\uB2C8\uB2E4.
@@ -1112,9 +1172,35 @@ PAI\uAC00 \uC0DD\uC131\uD55C MCP(Model Context Protocol) \uC11C\uBC84\uC785\uB2C
1112
1172
  ## \uD0C0\uC785: ${mcp.type}
1113
1173
 
1114
1174
  ${mcp.type === "tools" || mcp.type === "all" ? "- **Tools**: AI\uAC00 \uD638\uCD9C\uD560 \uAE30\uB2A5 \uC81C\uACF5 (src/tools.ts)" : ""}
1175
+ ${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
1176
  ${mcp.type === "resources" || mcp.type === "all" ? "- **Resources**: AI\uAC00 \uC77D\uC744 \uCEE8\uD14D\uC2A4\uD2B8 \uC81C\uACF5 (src/resources.ts)" : ""}
1116
1177
  ${mcp.type === "prompts" || mcp.type === "all" ? "- **Prompts**: \uC7AC\uC0AC\uC6A9 \uD504\uB86C\uD504\uD2B8 \uD15C\uD50C\uB9BF (src/prompts.ts)" : ""}
1117
1178
 
1179
+ ${isPublic ? `
1180
+ ## \uACF5\uAC1C \uBC30\uD3EC \uC124\uC815
1181
+
1182
+ 1. **Supabase \uB9C8\uC774\uADF8\uB808\uC774\uC158 \uC2E4\uD589**
1183
+ \\\`\\\`\\\`bash
1184
+ supabase db push
1185
+ \\\`\\\`\\\`
1186
+ (\\\`supabase/migrations/\\\` \uCC38\uACE0)
1187
+
1188
+ 2. **\uD658\uACBD \uBCC0\uC218 \uC124\uC815 (.env.local)**
1189
+ \\\`\\\`\\\`
1190
+ NEXT_PUBLIC_SUPABASE_URL=...
1191
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=...
1192
+ SUPABASE_SERVICE_ROLE_KEY=...
1193
+ \\\`\\\`\\\`
1194
+
1195
+ 3. **\uB300\uC2DC\uBCF4\uB4DC \uC811\uC18D**
1196
+ \\\`\\\`\\\`bash
1197
+ npm run dev
1198
+ # \u2192 http://localhost:3000/dashboard
1199
+ \\\`\\\`\\\`
1200
+ - API \uD0A4 \uBC1C\uAE09 \uBC0F \uAD00\uB9AC
1201
+ - \uC0AC\uC6A9\uB7C9 \uD655\uC778
1202
+ ` : ""}
1203
+
1118
1204
  ## \uAC1C\uBC1C
1119
1205
 
1120
1206
  \`\`\`bash
@@ -1147,10 +1233,368 @@ claude mcp add ${mcp.name} -- node ./mcp-server/dist/index.js
1147
1233
  - [SDK \uBB38\uC11C](https://github.com/modelcontextprotocol/sdk)
1148
1234
  `;
1149
1235
  }
1150
- var TOOLS_TEMPLATE, RESOURCES_TEMPLATE, PROMPTS_TEMPLATE, TEST_TEMPLATE;
1236
+ 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
1237
  var init_mcp = __esm({
1152
1238
  "src/stages/environment/provisioners/mcp.ts"() {
1153
1239
  "use strict";
1240
+ TOOLS_PUBLIC_TEMPLATE = `import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
1241
+ import {
1242
+ CallToolRequestSchema,
1243
+ ListToolsRequestSchema,
1244
+ } from '@modelcontextprotocol/sdk/types.js';
1245
+ import { verifyApiKey } from './auth.js';
1246
+ import { checkRateLimit, logUsage } from './rate-limit.js';
1247
+
1248
+ export function registerTools(server: Server): void {
1249
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1250
+ tools: [
1251
+ {
1252
+ name: 'hello',
1253
+ description: '\uC778\uC99D\uB41C \uB3C4\uAD6C \uD638\uCD9C \u2014 API \uD0A4 \uD544\uC694',
1254
+ inputSchema: {
1255
+ type: 'object',
1256
+ properties: {
1257
+ apiKey: { type: 'string', description: 'API \uD0A4' },
1258
+ name: { type: 'string', description: '\uC778\uC0AC\uD560 \uB300\uC0C1' },
1259
+ },
1260
+ required: ['apiKey', 'name'],
1261
+ },
1262
+ },
1263
+ ],
1264
+ }));
1265
+
1266
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1267
+ const { name, arguments: args } = request.params;
1268
+ const { apiKey, ...params } = (args as { apiKey?: string; [k: string]: unknown }) ?? {};
1269
+
1270
+ if (!apiKey) throw new Error('API key required');
1271
+ const user = await verifyApiKey(apiKey);
1272
+ if (!user) throw new Error('Invalid API key');
1273
+
1274
+ const allowed = await checkRateLimit(user.id);
1275
+ if (!allowed) throw new Error('Rate limit exceeded');
1276
+
1277
+ if (name === 'hello') {
1278
+ const target = (params as { name?: string })?.name ?? 'world';
1279
+ const result = { content: [{ type: 'text' as const, text: \`Hello, \${target}!\` }] };
1280
+ await logUsage(user.id, name);
1281
+ return result;
1282
+ }
1283
+
1284
+ throw new Error(\`Unknown tool: \${name}\`);
1285
+ });
1286
+ }
1287
+ `;
1288
+ AUTH_MIDDLEWARE_TEMPLATE = `import { createClient } from '@supabase/supabase-js';
1289
+
1290
+ const supabase = createClient(
1291
+ process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
1292
+ process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1293
+ );
1294
+
1295
+ export interface AuthUser {
1296
+ id: string;
1297
+ email: string;
1298
+ }
1299
+
1300
+ /**
1301
+ * API \uD0A4 \uAC80\uC99D \u2014 Supabase api_keys \uD14C\uC774\uBE14\uC5D0\uC11C \uC870\uD68C
1302
+ */
1303
+ export async function verifyApiKey(apiKey: string): Promise<AuthUser | null> {
1304
+ const { data, error } = await supabase
1305
+ .from('api_keys')
1306
+ .select('user_id, revoked_at, users(id, email)')
1307
+ .eq('key_hash', await hashKey(apiKey))
1308
+ .single();
1309
+
1310
+ if (error || !data || data.revoked_at) return null;
1311
+
1312
+ // \uB9C8\uC9C0\uB9C9 \uC0AC\uC6A9 \uC2DC\uAC04 \uC5C5\uB370\uC774\uD2B8
1313
+ await supabase
1314
+ .from('api_keys')
1315
+ .update({ last_used_at: new Date().toISOString() })
1316
+ .eq('key_hash', await hashKey(apiKey));
1317
+
1318
+ const users = (data as unknown as { users: { id: string; email: string } }).users;
1319
+ return { id: users.id, email: users.email };
1320
+ }
1321
+
1322
+ async function hashKey(key: string): Promise<string> {
1323
+ const crypto = await import('node:crypto');
1324
+ return crypto.createHash('sha256').update(key).digest('hex');
1325
+ }
1326
+
1327
+ export async function generateApiKey(): Promise<{ key: string; hash: string }> {
1328
+ const crypto = await import('node:crypto');
1329
+ const key = \`mcp_\${crypto.randomBytes(32).toString('hex')}\`;
1330
+ const hash = crypto.createHash('sha256').update(key).digest('hex');
1331
+ return { key, hash };
1332
+ }
1333
+ `;
1334
+ RATE_LIMIT_TEMPLATE = `import { createClient } from '@supabase/supabase-js';
1335
+
1336
+ const supabase = createClient(
1337
+ process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
1338
+ process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1339
+ );
1340
+
1341
+ const RATE_LIMIT_PER_HOUR = 100;
1342
+
1343
+ /**
1344
+ * Rate limit \uCCB4\uD06C \u2014 \uCD5C\uADFC 1\uC2DC\uAC04 \uB0B4 \uD638\uCD9C \uC218 \uD655\uC778
1345
+ */
1346
+ export async function checkRateLimit(userId: string): Promise<boolean> {
1347
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
1348
+ const { count } = await supabase
1349
+ .from('usage_logs')
1350
+ .select('*', { count: 'exact', head: true })
1351
+ .eq('user_id', userId)
1352
+ .gte('created_at', oneHourAgo);
1353
+
1354
+ return (count ?? 0) < RATE_LIMIT_PER_HOUR;
1355
+ }
1356
+
1357
+ /**
1358
+ * \uC0AC\uC6A9\uB7C9 \uAE30\uB85D \u2014 usage_logs \uD14C\uC774\uBE14\uC5D0 \uC800\uC7A5
1359
+ */
1360
+ export async function logUsage(userId: string, toolName: string): Promise<void> {
1361
+ await supabase.from('usage_logs').insert({
1362
+ user_id: userId,
1363
+ tool_name: toolName,
1364
+ created_at: new Date().toISOString(),
1365
+ });
1366
+ }
1367
+ `;
1368
+ SUPABASE_MIGRATION = `-- MCP Tools \uACF5\uAC1C \uBC30\uD3EC \u2014 API \uD0A4 \uAD00\uB9AC + \uC0AC\uC6A9\uB7C9 \uCD94\uC801
1369
+ -- Generated by PAI
1370
+
1371
+ -- \uC0AC\uC6A9\uC790 \uD14C\uC774\uBE14 (Supabase Auth\uC640 \uC5F0\uB3D9)
1372
+ create table if not exists public.users (
1373
+ id uuid primary key references auth.users(id) on delete cascade,
1374
+ email text not null unique,
1375
+ created_at timestamptz default now()
1376
+ );
1377
+
1378
+ -- API \uD0A4 \uD14C\uC774\uBE14
1379
+ create table if not exists public.api_keys (
1380
+ id uuid primary key default gen_random_uuid(),
1381
+ user_id uuid not null references public.users(id) on delete cascade,
1382
+ key_hash text not null unique,
1383
+ name text not null,
1384
+ created_at timestamptz default now(),
1385
+ last_used_at timestamptz,
1386
+ revoked_at timestamptz
1387
+ );
1388
+
1389
+ create index if not exists api_keys_user_id_idx on public.api_keys(user_id);
1390
+ create index if not exists api_keys_key_hash_idx on public.api_keys(key_hash);
1391
+
1392
+ -- \uC0AC\uC6A9\uB7C9 \uB85C\uADF8 \uD14C\uC774\uBE14
1393
+ create table if not exists public.usage_logs (
1394
+ id bigserial primary key,
1395
+ user_id uuid not null references public.users(id) on delete cascade,
1396
+ tool_name text not null,
1397
+ created_at timestamptz default now()
1398
+ );
1399
+
1400
+ create index if not exists usage_logs_user_id_idx on public.usage_logs(user_id);
1401
+ create index if not exists usage_logs_created_at_idx on public.usage_logs(created_at);
1402
+
1403
+ -- RLS (Row Level Security)
1404
+ alter table public.users enable row level security;
1405
+ alter table public.api_keys enable row level security;
1406
+ alter table public.usage_logs enable row level security;
1407
+
1408
+ create policy "Users can view own data" on public.users
1409
+ for select using (auth.uid() = id);
1410
+
1411
+ create policy "Users can manage own api keys" on public.api_keys
1412
+ for all using (auth.uid() = user_id);
1413
+
1414
+ create policy "Users can view own usage" on public.usage_logs
1415
+ for select using (auth.uid() = user_id);
1416
+ `;
1417
+ DASHBOARD_LAYOUT = `import Link from 'next/link';
1418
+ import type { ReactNode } from 'react';
1419
+
1420
+ export default function DashboardLayout({ children }: { children: ReactNode }) {
1421
+ return (
1422
+ <div style={{ display: 'flex', minHeight: '100vh' }}>
1423
+ <aside style={{ width: 240, borderRight: '1px solid #eee', padding: 24 }}>
1424
+ <h2 style={{ fontSize: 18, marginBottom: 16 }}>MCP \uB300\uC2DC\uBCF4\uB4DC</h2>
1425
+ <nav>
1426
+ <Link href="/dashboard" style={{ display: 'block', padding: '8px 0' }}>\uAC1C\uC694</Link>
1427
+ <Link href="/dashboard/keys" style={{ display: 'block', padding: '8px 0' }}>API \uD0A4</Link>
1428
+ <Link href="/dashboard/usage" style={{ display: 'block', padding: '8px 0' }}>\uC0AC\uC6A9\uB7C9</Link>
1429
+ </nav>
1430
+ </aside>
1431
+ <main style={{ flex: 1, padding: 24 }}>{children}</main>
1432
+ </div>
1433
+ );
1434
+ }
1435
+ `;
1436
+ DASHBOARD_PAGE = `export default function DashboardPage() {
1437
+ return (
1438
+ <div>
1439
+ <h1>MCP \uB300\uC2DC\uBCF4\uB4DC</h1>
1440
+ <p>API \uD0A4\uB97C \uBC1C\uAE09\uD558\uACE0 \uC0AC\uC6A9\uB7C9\uC744 \uAD00\uB9AC\uD558\uC138\uC694.</p>
1441
+ <ul>
1442
+ <li><a href="/dashboard/keys">API \uD0A4 \uBC1C\uAE09</a></li>
1443
+ <li><a href="/dashboard/usage">\uC0AC\uC6A9\uB7C9 \uD655\uC778</a></li>
1444
+ </ul>
1445
+ </div>
1446
+ );
1447
+ }
1448
+ `;
1449
+ KEYS_PAGE = `'use client';
1450
+ import { useEffect, useState } from 'react';
1451
+
1452
+ interface ApiKey {
1453
+ id: string;
1454
+ name: string;
1455
+ created_at: string;
1456
+ last_used_at?: string;
1457
+ }
1458
+
1459
+ export default function KeysPage() {
1460
+ const [keys, setKeys] = useState<ApiKey[]>([]);
1461
+ const [newKeyName, setNewKeyName] = useState('');
1462
+ const [newKey, setNewKey] = useState<string | null>(null);
1463
+
1464
+ useEffect(() => {
1465
+ fetch('/api/keys').then(r => r.json()).then(setKeys);
1466
+ }, []);
1467
+
1468
+ async function create() {
1469
+ const res = await fetch('/api/keys', {
1470
+ method: 'POST',
1471
+ headers: { 'Content-Type': 'application/json' },
1472
+ body: JSON.stringify({ name: newKeyName }),
1473
+ });
1474
+ const data = await res.json();
1475
+ setNewKey(data.key);
1476
+ setKeys([...keys, data.apiKey]);
1477
+ setNewKeyName('');
1478
+ }
1479
+
1480
+ async function revoke(id: string) {
1481
+ await fetch(\`/api/keys/\${id}\`, { method: 'DELETE' });
1482
+ setKeys(keys.filter(k => k.id !== id));
1483
+ }
1484
+
1485
+ return (
1486
+ <div>
1487
+ <h1>API \uD0A4 \uAD00\uB9AC</h1>
1488
+ <div style={{ marginBottom: 24 }}>
1489
+ <input
1490
+ placeholder="\uD0A4 \uC774\uB984"
1491
+ value={newKeyName}
1492
+ onChange={e => setNewKeyName(e.target.value)}
1493
+ />
1494
+ <button onClick={create}>\uC0C8 \uD0A4 \uBC1C\uAE09</button>
1495
+ </div>
1496
+ {newKey && (
1497
+ <div style={{ padding: 16, background: '#fffbe6', marginBottom: 24 }}>
1498
+ <strong>\uC0C8 API \uD0A4 (\uD55C \uBC88\uB9CC \uD45C\uC2DC):</strong>
1499
+ <pre>{newKey}</pre>
1500
+ </div>
1501
+ )}
1502
+ <table style={{ width: '100%' }}>
1503
+ <thead>
1504
+ <tr><th>\uC774\uB984</th><th>\uC0DD\uC131\uC77C</th><th>\uB9C8\uC9C0\uB9C9 \uC0AC\uC6A9</th><th></th></tr>
1505
+ </thead>
1506
+ <tbody>
1507
+ {keys.map(k => (
1508
+ <tr key={k.id}>
1509
+ <td>{k.name}</td>
1510
+ <td>{new Date(k.created_at).toLocaleDateString()}</td>
1511
+ <td>{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : '\u2014'}</td>
1512
+ <td><button onClick={() => revoke(k.id)}>\uC0AD\uC81C</button></td>
1513
+ </tr>
1514
+ ))}
1515
+ </tbody>
1516
+ </table>
1517
+ </div>
1518
+ );
1519
+ }
1520
+ `;
1521
+ USAGE_PAGE = `'use client';
1522
+ import { useEffect, useState } from 'react';
1523
+
1524
+ interface UsageLog {
1525
+ tool_name: string;
1526
+ count: number;
1527
+ }
1528
+
1529
+ export default function UsagePage() {
1530
+ const [usage, setUsage] = useState<UsageLog[]>([]);
1531
+ const [total, setTotal] = useState(0);
1532
+
1533
+ useEffect(() => {
1534
+ fetch('/api/usage').then(r => r.json()).then(data => {
1535
+ setUsage(data.byTool ?? []);
1536
+ setTotal(data.total ?? 0);
1537
+ });
1538
+ }, []);
1539
+
1540
+ return (
1541
+ <div>
1542
+ <h1>\uC0AC\uC6A9\uB7C9</h1>
1543
+ <p>\uCD5C\uADFC 30\uC77C \uCD1D \uD638\uCD9C: <strong>{total}</strong></p>
1544
+ <table style={{ width: '100%' }}>
1545
+ <thead>
1546
+ <tr><th>\uB3C4\uAD6C</th><th>\uD638\uCD9C \uC218</th></tr>
1547
+ </thead>
1548
+ <tbody>
1549
+ {usage.map(u => (
1550
+ <tr key={u.tool_name}>
1551
+ <td>{u.tool_name}</td>
1552
+ <td>{u.count}</td>
1553
+ </tr>
1554
+ ))}
1555
+ </tbody>
1556
+ </table>
1557
+ </div>
1558
+ );
1559
+ }
1560
+ `;
1561
+ KEYS_API_ROUTE = `import { NextResponse } from 'next/server';
1562
+ import { createClient } from '@supabase/supabase-js';
1563
+ import crypto from 'node:crypto';
1564
+
1565
+ const supabase = createClient(
1566
+ process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
1567
+ process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1568
+ );
1569
+
1570
+ export async function GET() {
1571
+ // TODO: \uD604\uC7AC \uB85C\uADF8\uC778 \uC0AC\uC6A9\uC790\uC758 \uD0A4\uB9CC \uC870\uD68C (auth \uBBF8\uB4E4\uC6E8\uC5B4 \uC5F0\uB3D9 \uD544\uC694)
1572
+ const { data, error } = await supabase
1573
+ .from('api_keys')
1574
+ .select('id, name, created_at, last_used_at')
1575
+ .is('revoked_at', null);
1576
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
1577
+ return NextResponse.json(data);
1578
+ }
1579
+
1580
+ export async function POST(request: Request) {
1581
+ const { name } = await request.json();
1582
+ const key = \`mcp_\${crypto.randomBytes(32).toString('hex')}\`;
1583
+ const key_hash = crypto.createHash('sha256').update(key).digest('hex');
1584
+
1585
+ // TODO: \uD604\uC7AC \uB85C\uADF8\uC778 \uC0AC\uC6A9\uC790 ID \uC5F0\uB3D9
1586
+ const user_id = 'TODO-REPLACE-WITH-AUTH-USER-ID';
1587
+
1588
+ const { data, error } = await supabase
1589
+ .from('api_keys')
1590
+ .insert({ user_id, name, key_hash })
1591
+ .select('id, name, created_at')
1592
+ .single();
1593
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
1594
+
1595
+ return NextResponse.json({ apiKey: data, key });
1596
+ }
1597
+ `;
1154
1598
  TOOLS_TEMPLATE = `import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
1155
1599
  import {
1156
1600
  CallToolRequestSchema,