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 } 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 STACK_ENV = "stg"; const BASE_URL = process.env.BASE_URL ?? ""; const VERSION_PATH = process.env.VERSION_PATH ?? "/api/version-app"; const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? ""; const GH_TOKEN = process.env.GH_TOKEN ?? ""; const GH_URL_RAW = process.env.GH_URL ?? ""; // support both "owner/repo" and "https://github.com/owner/repo" formats const REPO = GH_URL_RAW.startsWith("http") ? GH_URL_RAW.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "") : GH_URL_RAW; const GIT = (args: string[]) => execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim(); // --- GitHub API client (no gh CLI) --- const GH_API = "https://api.github.com"; type WorkflowRun = { id: number; name: string; status: string; conclusion: string | null; html_url: string; created_at: string; run_started_at: string; }; async function ghFetch( pathname: string, init: RequestInit = {} ): Promise { if (!GH_TOKEN) throw new Error("GH_TOKEN tidak di-set."); if (!REPO) throw new Error("GH_URL tidak di-set."); const res = await fetch(`${GH_API}${pathname}`, { ...init, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${GH_TOKEN}`, "X-GitHub-Api-Version": "2022-11-28", ...(init.body ? { "Content-Type": "application/json" } : {}), ...(init.headers ?? {}), }, }); if (!res.ok) { const body = await res.text(); throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`); } // 204 No Content (e.g. workflow dispatch) if (res.status === 204) return undefined as T; return (await res.json()) as T; } async function triggerWorkflow( workflow: string, ref: string, inputs: Record ): Promise { await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, { method: "POST", body: JSON.stringify({ ref, inputs }), }); } async function getLatestRun(workflow: string): Promise { const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>( `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=1` ); if (!data.workflow_runs?.length) { throw new Error(`Tidak ada run untuk workflow ${workflow}.`); } return data.workflow_runs[0]; } async function listRuns( workflow: string | "all", limit: number ): Promise { const url = workflow === "all" ? `/repos/${REPO}/actions/runs?per_page=${limit}` : `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=${limit}`; const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(url); return data.workflow_runs ?? []; } async function waitForRun( runId: number, timeoutMs = 30 * 60 * 1000 ): Promise { const interval = 10_000; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const run = await ghFetch( `/repos/${REPO}/actions/runs/${runId}` ); if (run.status === "completed") { if (run.conclusion !== "success") { throw new Error( `Run ${runId} selesai dengan conclusion: ${run.conclusion}` ); } return run; } await new Promise((r) => setTimeout(r, interval)); } throw new Error(`Timeout menunggu run ${runId}.`); } // --- 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}${VERSION_PATH}`; 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}:stg`, "--force"]); // 4. Trigger publish workflow await triggerWorkflow("publish.yml", STACK_ENV, { stack_env: STACK_ENV, tag: newVersion, }); await new Promise((r) => setTimeout(r, 4000)); const publishRun = await getLatestRun("publish.yml"); // 5. Wait for publish to finish await waitForRun(publishRun.id); // 6. Trigger repull await triggerWorkflow("re-pull.yml", STACK_ENV, { stack_name, stack_env: STACK_ENV, }); await new Promise((r) => setTimeout(r, 4000)); const repullRun = await getLatestRun("re-pull.yml"); // 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 : ${publishRun.html_url}`, `Repull run : ${repullRun.html_url}`, ``, `Versi lokal : ${localVer}`, versionCheck, ].join("\n"), }, ], }; } // ── publish ──────────────────────────────────────────────────────────── if (name === "publish") { const tag = readPkgVersion(); await triggerWorkflow("publish.yml", STACK_ENV, { stack_env: STACK_ENV, tag, }); await new Promise((r) => setTimeout(r, 3000)); const run = await getLatestRun("publish.yml"); return { content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${run.html_url}` }], }; } // ── 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."); await triggerWorkflow("re-pull.yml", STACK_ENV, { stack_name, stack_env: STACK_ENV, }); await new Promise((r) => setTimeout(r, 3000)); const run = await getLatestRun("re-pull.yml"); return { content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${run.html_url}` }], }; } // ── run_status ───────────────────────────────────────────────────────── if (name === "run_status") { const { workflow = "all", limit = 5 } = (args ?? {}) as { workflow?: string; limit?: number; }; const runs = await listRuns(workflow, limit); const output = runs .map( (r) => `[${r.status}/${r.conclusion ?? "-"}] ${r.name} — ${r.run_started_at}\n ${r.html_url}` ) .join("\n"); 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}${VERSION_PATH}`); 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 (${VERSION_PATH}): ${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);