import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' const GH_TOKEN = process.env.GH_TOKEN ?? '' const STACK_NAME = process.env.STACK_NAME ?? '' const BASE_URL = process.env.BASE_URL ?? '' // https://api.github.com/repos/owner/repo const STG_URL = process.env.STG_URL ?? '' // https://monitoring-stg.example.com const VERSION_PATH = process.env.VERSION_PATH ?? '/api/system/version' // ─── GitHub API helpers ──────────────────────────────────────────────────────── const ghHeaders = { Authorization: `Bearer ${GH_TOKEN}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28', } async function triggerWorkflow(workflow: string, inputs: Record) { const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, { method: 'POST', headers: ghHeaders, body: JSON.stringify({ ref: 'main', inputs }), }) if (!res.ok) throw new Error(`GitHub API error ${res.status}: ${await res.text()}`) } async function waitForWorkflow( workflow: string, afterTime: Date, timeoutMs = 600_000, ): Promise<{ conclusion: string; url: string }> { const deadline = Date.now() + timeoutMs await Bun.sleep(8_000) // tunggu run muncul di API while (Date.now() < deadline) { const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/runs?per_page=5`, { headers: ghHeaders, }) const data = await res.json() as { workflow_runs: any[] } const run = data.workflow_runs?.find( (r: any) => new Date(r.created_at) >= afterTime, ) if (run) { if (run.status === 'completed') { return { conclusion: run.conclusion ?? 'failure', url: run.html_url } } } await Bun.sleep(12_000) } throw new Error(`Workflow ${workflow} timeout setelah ${timeoutMs / 1000}s`) } // ─── Shell helpers ───────────────────────────────────────────────────────────── async function sh(cmd: string[]): Promise<{ out: string; err: string; ok: boolean }> { const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', cwd: process.cwd() }) const [out, err, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]) return { out: out.trim(), err: err.trim(), ok: code === 0 } } // ─── MCP Server ──────────────────────────────────────────────────────────────── const server = new McpServer({ name: 'deploy-stg', version: '1.0.0' }) // ─── Tool: publish (manual, single step) ────────────────────────────────────── server.tool( 'publish', 'Trigger publish.yml untuk build & push Docker image staging', { tag: z.string().describe('Image tag, contoh: 1.0.0') }, async ({ tag }) => { await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) return { content: [{ type: 'text', text: `✅ publish.yml dipicu → stg-${tag}` }] } }, ) // ─── Tool: repull (manual, single step) ─────────────────────────────────────── server.tool( 'repull', 'Trigger re-pull.yml untuk redeploy stack staging di Portainer', {}, async () => { await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) return { content: [{ type: 'text', text: `✅ re-pull.yml dipicu → ${STACK_NAME}-stg` }] } }, ) // ─── Tool: deploy (full pipeline) ───────────────────────────────────────────── server.tool( 'deploy', [ 'Full deploy pipeline ke staging:', '1. Cek pending migrations', '2. Version bump di package.json', '3. Commit & push ke build/stg', '4. Trigger publish.yml → tunggu selesai', '5. Trigger re-pull.yml → tunggu selesai', '6. Cek version di staging & local untuk konfirmasi', ].join('\n'), { tag: z.string().describe('Versi baru, contoh: 1.2.3') }, async ({ tag }) => { const log: string[] = [] // ── 1. Cek & jalankan migrasi jika ada ───────────────────────────────── const migrateStatus = await sh(['bunx', 'prisma', 'migrate', 'status']) if (!migrateStatus.ok || migrateStatus.out.includes('not yet been applied')) { log.push('⏳ Ada pending migrations — menjalankan migrate deploy...') const migrateRun = await sh(['bunx', 'prisma', 'migrate', 'deploy']) if (!migrateRun.ok) { return { content: [{ type: 'text', text: [ ...log, '❌ Migrate deploy gagal:', migrateRun.err || migrateRun.out, ].join('\n'), }], } } log.push('✅ Migrations: deployed') } else { log.push('✅ Migrations: up to date') } // ── 2. Version bump ────────────────────────────────────────────────────── const pkgPath = `${process.cwd()}/package.json` const pkg = await Bun.file(pkgPath).json() const prevVersion = pkg.version as string pkg.version = tag await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n') log.push(`✅ Version bump: ${prevVersion} → ${tag}`) // ── 3. Commit & push build/stg ─────────────────────────────────────────── await sh(['git', 'add', 'package.json']) const commit = await sh(['git', 'commit', '-m', `chore: bump version to ${tag}`]) if (!commit.ok) { return { content: [{ type: 'text', text: `❌ git commit gagal:\n${commit.err}` }] } } log.push('✅ Committed') const push = await sh(['git', 'push', 'origin', 'HEAD:build/stg']) if (!push.ok) { return { content: [{ type: 'text', text: `❌ git push gagal:\n${push.err}` }] } } log.push('✅ Pushed → build/stg') // ── 4. Publish workflow ────────────────────────────────────────────────── log.push('⏳ Menjalankan publish.yml...') const publishTriggeredAt = new Date() await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) const publish = await waitForWorkflow('publish.yml', publishTriggeredAt) if (publish.conclusion !== 'success') { return { content: [{ type: 'text', text: [ ...log, `❌ publish.yml ${publish.conclusion}`, `Detail: ${publish.url}`, ].join('\n'), }], } } log.push(`✅ publish.yml sukses → ${publish.url}`) // ── 5. Re-pull workflow ────────────────────────────────────────────────── log.push('⏳ Menjalankan re-pull.yml...') const repullTriggeredAt = new Date() await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) const repull = await waitForWorkflow('re-pull.yml', repullTriggeredAt) if (repull.conclusion !== 'success') { return { content: [{ type: 'text', text: [ ...log, `❌ re-pull.yml ${repull.conclusion}`, `Detail: ${repull.url}`, ].join('\n'), }], } } log.push(`✅ re-pull.yml sukses → ${repull.url}`) // ── 6. Cek version ─────────────────────────────────────────────────────── await Bun.sleep(5_000) // tunggu container restart log.push('⏳ Mengecek version di staging...') const localCommitProc = await sh(['git', 'rev-parse', '--short', 'HEAD']) const localCommit = localCommitProc.out let stgInfo: { version?: string; commit?: string } = {} try { const versionRes = await fetch(`${STG_URL}${VERSION_PATH}`) stgInfo = await versionRes.json() } catch (e) { log.push(`⚠️ Gagal mengecek version staging: ${e}`) } const versionMatch = stgInfo.version === tag const commitMatch = stgInfo.commit === localCommit log.push('') log.push('─── Version Check ───────────────────────────') log.push(`Local : version=${tag}, commit=${localCommit}`) log.push(`Staging: version=${stgInfo.version ?? '?'}, commit=${stgInfo.commit ?? '?'}`) log.push(versionMatch && commitMatch ? '✅ Staging sudah terupdate dan sesuai local' : `⚠️ Mismatch — version: ${versionMatch ? 'OK' : 'BEDA'}, commit: ${commitMatch ? 'OK' : 'BEDA'}`, ) return { content: [{ type: 'text', text: log.join('\n') }] } }, ) const transport = new StdioServerTransport() await server.connect(transport)