import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { execFileSync, execSync } from "child_process"; import { readFileSync, writeFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, "../.."); const REPO = "bipprojectbali/desa-plus"; const STACK_ENV = "stg"; const BASE_URL = process.env.BASE_URL ?? ""; const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? ""; const GH = (args: string[]) => execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim(); const GIT = (args: string[]) => execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim(); // --- version helpers --- function bumpVersion(version: string, type: "patch" | "minor" | "major"): string { const [maj, min, pat] = version.split(".").map(Number); if (type === "major") return `${maj + 1}.0.0`; if (type === "minor") return `${maj}.${min + 1}.0`; return `${maj}.${min}.${pat + 1}`; } function readPkgVersion(): string { const pkg = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf-8")); return pkg.version as string; } function applyVersionBump(newVersion: string): void { const pkgPath = path.join(PROJECT_ROOT, "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); pkg.version = newVersion; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); } // --- deployed version check --- async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise { if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg."; const url = `${BASE_URL}/api/version-app`; const interval = 15_000; const maxAttempts = Math.ceil(timeoutMs / interval); let last = ""; for (let i = 1; i <= maxAttempts; i++) { await new Promise((r) => setTimeout(r, interval)); try { const res = await fetch(url); const data = (await res.json()) as { version?: string }; last = data.version ?? "?"; if (last === expected) { return `Versi terverifikasi di stg: ${last}`; } } catch { last = "error fetch"; } } return `Timeout: versi stg masih ${last}, expected ${expected}`; } // --- MCP server --- const server = new Server( { name: "deploy-stg", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "deploy", description: "Full deploy ke stg: bump version, commit, push ke build remote, publish Docker image, tunggu selesai, repull Portainer, verifikasi versi.", inputSchema: { type: "object", properties: { stack_name: { type: "string", description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.", }, bump: { type: "string", enum: ["patch", "minor", "major"], description: "Jenis bump versi (default: patch)", default: "patch", }, }, required: [], }, }, { name: "publish", description: "Trigger workflow publish.yml: build & push Docker image ke GHCR (selalu stg, tag dari package.json). Kembalikan URL run.", inputSchema: { type: "object", properties: {}, required: [] }, }, { name: "repull", description: "Trigger workflow re-pull.yml: redeploy stack di Portainer stg dengan pull image terbaru. Kembalikan URL run.", inputSchema: { type: "object", properties: { stack_name: { type: "string", description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.", }, }, required: [], }, }, { name: "run_status", description: "Cek status GitHub Actions run terbaru untuk workflow tertentu, atau semua workflow.", inputSchema: { type: "object", properties: { workflow: { type: "string", enum: ["publish.yml", "re-pull.yml", "all"], description: "Nama workflow file atau 'all' untuk semua (default: all)", default: "all", }, limit: { type: "number", description: "Jumlah run yang ditampilkan (default 5)", default: 5, }, }, required: [], }, }, { name: "check_version", description: "Bandingkan versi lokal (package.json) dengan versi yang berjalan di stg (/api/version-app).", inputSchema: { type: "object", properties: {}, required: [] }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { // ── deploy ───────────────────────────────────────────────────────────── if (name === "deploy") { const { stack_name: _sn, bump = "patch" } = (args ?? {}) as { stack_name?: string; bump?: "patch" | "minor" | "major"; }; const stack_name = _sn || DEFAULT_STACK_NAME; if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong."); // 0. Cek migrasi — buat otomatis jika schema ada perubahan let migrationCreated = false; try { execFileSync( "./node_modules/.bin/prisma", ["migrate", "diff", "--from-migrations", "prisma/migrations", "--to-schema-datamodel", "prisma/schema.prisma", "--exit-code"], { encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" } ); } catch { // Ada schema diff — buat migration otomatis execFileSync( "./node_modules/.bin/prisma", ["migrate", "dev", "--create-only", "--name", "auto"], { encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" } ); migrationCreated = true; } const oldVersion = readPkgVersion(); const newVersion = bumpVersion(oldVersion, bump); // 1. Bump version in package.json applyVersionBump(newVersion); // 2. Commit (version bump + migration jika ada) GIT(["add", "package.json", "prisma/migrations"]); GIT(["commit", "-m", migrationCreated ? `bump: version ${newVersion} + migration` : `bump: version ${newVersion}` ]); // 3. Push to build remote (GitHub) const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]); GIT(["push", "build", `${currentBranch}:main`, "--force"]); // 4. Trigger publish GH([ "workflow", "run", "publish.yml", "--repo", REPO, "--field", `stack_env=${STACK_ENV}`, "--field", `tag=${newVersion}`, ]); await new Promise((r) => setTimeout(r, 4000)); const publishRunId = GH([ "run", "list", "--repo", REPO, "--workflow", "publish.yml", "--limit", "1", "--json", "databaseId", "--jq", ".[0].databaseId", ]); const publishUrl = GH([ "run", "list", "--repo", REPO, "--workflow", "publish.yml", "--limit", "1", "--json", "url", "--jq", ".[0].url", ]); // 5. Wait for publish to finish execSync(`gh run watch ${publishRunId} --repo ${REPO} --exit-status`, { encoding: "utf-8", cwd: PROJECT_ROOT, timeout: 30 * 60 * 1000, stdio: "pipe", }); // 6. Trigger repull GH([ "workflow", "run", "re-pull.yml", "--repo", REPO, "--field", `stack_name=${stack_name}`, "--field", `stack_env=${STACK_ENV}`, ]); await new Promise((r) => setTimeout(r, 4000)); const repullUrl = GH([ "run", "list", "--repo", REPO, "--workflow", "re-pull.yml", "--limit", "1", "--json", "url", "--jq", ".[0].url", ]); // 7. Wait for repull, then verify version await new Promise((r) => setTimeout(r, 30_000)); const versionCheck = await waitForDeployedVersion(newVersion); const localVer = readPkgVersion(); return { content: [ { type: "text", text: [ `Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`, `Publish run : ${publishUrl}`, `Repull run : ${repullUrl}`, ``, `Versi lokal : ${localVer}`, versionCheck, ].join("\n"), }, ], }; } // ── publish ──────────────────────────────────────────────────────────── if (name === "publish") { const tag = readPkgVersion(); GH([ "workflow", "run", "publish.yml", "--repo", REPO, "--field", `stack_env=${STACK_ENV}`, "--field", `tag=${tag}`, ]); await new Promise((r) => setTimeout(r, 3000)); const runUrl = GH([ "run", "list", "--repo", REPO, "--workflow", "publish.yml", "--limit", "1", "--json", "url", "--jq", ".[0].url", ]); return { content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }], }; } // ── repull ───────────────────────────────────────────────────────────── if (name === "repull") { const { stack_name: _sn } = (args ?? {}) as { stack_name?: string }; const stack_name = _sn || DEFAULT_STACK_NAME; if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong."); GH([ "workflow", "run", "re-pull.yml", "--repo", REPO, "--field", `stack_name=${stack_name}`, "--field", `stack_env=${STACK_ENV}`, ]); await new Promise((r) => setTimeout(r, 3000)); const runUrl = GH([ "run", "list", "--repo", REPO, "--workflow", "re-pull.yml", "--limit", "1", "--json", "url", "--jq", ".[0].url", ]); return { content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }], }; } // ── run_status ───────────────────────────────────────────────────────── if (name === "run_status") { const { workflow = "all", limit = 5 } = (args ?? {}) as { workflow?: string; limit?: number; }; const workflowArgs = workflow === "all" ? [] : ["--workflow", workflow]; const output = GH([ "run", "list", "--repo", REPO, ...workflowArgs, "--limit", String(limit), "--json", "workflowName,status,conclusion,startedAt,url,databaseId", "--jq", '.[] | "[\(.status)/\(.conclusion // "-")] \(.workflowName) — \(.startedAt)\n \(.url)"', ]); return { content: [{ type: "text", text: output || "Tidak ada run ditemukan." }], }; } // ── check_version ────────────────────────────────────────────────────── if (name === "check_version") { const localVersion = readPkgVersion(); let stgVersion = "tidak dapat dijangkau"; if (BASE_URL) { try { const res = await fetch(`${BASE_URL}/api/version-app`); const data = (await res.json()) as { version?: string }; stgVersion = data.version ?? "?"; } catch (e) { stgVersion = `error: ${(e as Error).message}`; } } else { stgVersion = "BASE_URL tidak di-set"; } const match = localVersion === stgVersion ? "✓ sama" : "✗ beda"; return { content: [ { type: "text", text: [ `Lokal (package.json) : ${localVersion}`, `Stg (/api/version-app): ${stgVersion}`, `Status : ${match}`, ].join("\n"), }, ], }; } return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; } }); const transport = new StdioServerTransport(); await server.connect(transport);