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 +455 -11
- package/dist/bin/pai.js.map +1 -1
- package/dist/cli/index.js +455 -11
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
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 = {
|
|
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
|
-
|
|
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,
|