From ebde5a72845ab4bf4718219a94c64b020f24e251 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:46:48 +0000 Subject: [PATCH 01/10] fix(mcp): invalidate stale tool catalogs --- src/app/mcp/route.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index d56d307..357cb12 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -10,10 +10,11 @@ import { executeAgentTool, toolDefinitionsForScopes } from "@/lib/mcp-agent"; const LEGACY_PROTOCOL_VERSION = "2025-11-25"; const LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"]; +const TOOLSET_REVISION = "2026-09-20.1"; const SERVER_INFO = { name: "GroundControl", title: "GroundControl", - version: "0.1.0", + version: "0.2.0", }; const SERVER_INSTRUCTIONS = "GroundControl exposes scoped infrastructure operations. Inspect state before changing it, use durable operation handles for mutations, and never assume access to workloads outside the OAuth grant."; @@ -58,6 +59,7 @@ function addServerMeta(result: unknown, modern: boolean): unknown { _meta: { ...existingMeta, "io.modelcontextprotocol/serverInfo": SERVER_INFO, + "io.groundcontrol/toolsetRevision": TOOLSET_REVISION, }, }; } @@ -169,7 +171,7 @@ export async function POST(req: NextRequest) { if (body.method === "initialize") { return rpc(body.id, { protocolVersion: negotiateLegacyVersion(body), - capabilities: { tools: { listChanged: false } }, + capabilities: { tools: { listChanged: true } }, serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS, }, false); @@ -178,7 +180,7 @@ export async function POST(req: NextRequest) { if (body.method === "server/discover") { return rpc(body.id, { supportedVersions: [MCP_PROTOCOL_VERSION, ...LEGACY_PROTOCOL_VERSIONS], - capabilities: { tools: { listChanged: false } }, + capabilities: { tools: { listChanged: true } }, instructions: SERVER_INSTRUCTIONS, ttlMs: 30_000, cacheScope: "private", From 57ad3e40c29683085bce1b1c90ed5e2fb38ba922 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:46:50 +0000 Subject: [PATCH 02/10] test(mcp): cover tool catalog revision --- src/app/mcp/route.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/mcp/route.test.ts b/src/app/mcp/route.test.ts index a5bf14c..4df39fe 100644 --- a/src/app/mcp/route.test.ts +++ b/src/app/mcp/route.test.ts @@ -100,10 +100,12 @@ describe("GroundControl MCP transport", () => { expect(response.status).toBe(200); expect(data.result.supportedVersions).toContain("2026-07-28"); - expect(data.result.capabilities.tools).toEqual({ listChanged: false }); + expect(data.result.capabilities.tools).toEqual({ listChanged: true }); expect(data.result._meta["io.modelcontextprotocol/serverInfo"]).toMatchObject({ name: "GroundControl", + version: "0.2.0", }); + expect(data.result._meta["io.groundcontrol/toolsetRevision"]).toBe("2026-09-20.1"); expect(response.headers.get("MCP-Protocol-Version")).toBe("2026-07-28"); expect(mock.authenticateAccessToken).not.toHaveBeenCalled(); }); From 28f46251fb54c9941bd668d80d00857299710ee8 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:46:52 +0000 Subject: [PATCH 03/10] feat(mcp): expose durable source deploy --- src/lib/mcp-agent.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/lib/mcp-agent.ts b/src/lib/mcp-agent.ts index f952b19..e832610 100644 --- a/src/lib/mcp-agent.ts +++ b/src/lib/mcp-agent.ts @@ -295,6 +295,33 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [ }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, + { + name: "deployment.source.deploy", + title: "Build and deploy linked source", + description: "Queue a durable deployment that syncs a linked GitHub source branch through the GroundControl GitHub App, builds on the target host, recreates the Compose workload, and verifies it without relying on GitHub Actions.", + requiredScope: "deployment:redeploy", + inputSchema: { + type: "object", + properties: { + deployment: { type: "string", description: "Deployment slug or numeric id." }, + branch: { type: "string", minLength: 1, maxLength: 200, description: "Optional GitHub branch. Defaults to the linked repository default branch." }, + idempotencyKey: { type: "string", minLength: 8, maxLength: 160, description: "Stable unique key. Reusing it returns the original operation instead of deploying twice." }, + reason: { type: "string", maxLength: 500, description: "Short operational reason for the source deployment." }, + }, + required: ["deployment", "idempotencyKey"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + operation: OPERATION_SCHEMA, + reused: { type: "boolean" }, + }, + required: ["operation", "reused"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, { name: "deployment.redeploy", title: "Redeploy deployment", @@ -569,6 +596,40 @@ export async function executeAgentTool( }; } + if (name === "deployment.source.deploy") { + const deployment = await allowedDeployment(context, args.deployment); + if (deployment.managementMode !== "managed") { + throw new Error("Source deployment requires a GroundControl-managed deployment."); + } + if (!deployment.legacyProject) { + throw new Error("This deployment does not have a managed GroundControl project."); + } + const idempotencyKey = String(args.idempotencyKey || "").trim(); + if (idempotencyKey.length < 8 || idempotencyKey.length > 160) { + throw new Error("idempotencyKey must be between 8 and 160 characters"); + } + const branch = typeof args.branch === "string" ? args.branch.trim() : ""; + if (branch.length > 200) throw new Error("branch must be 200 characters or fewer"); + const existing = await prisma.agentOperation.findUnique({ + where: { grantId_idempotencyKey: { grantId: context.grant.id, idempotencyKey } }, + }); + if (existing) return { operation: serializeOperation(existing), reused: true }; + + const operation = await prisma.agentOperation.create({ + data: { + grantId: context.grant.id, + deploymentId: deployment.id, + type: "deployment.source.deploy", + idempotencyKey, + inputJson: JSON.stringify({ + branch: branch || undefined, + reason: String(args.reason || "").trim().slice(0, 500), + }), + }, + }); + return { operation: serializeOperation(operation), reused: false }; + } + if (name === "deployment.redeploy") { const deployment = await allowedDeployment(context, args.deployment); const idempotencyKey = String(args.idempotencyKey || "").trim(); From 388d1dc7e2d65b1ebbd78d2f0589429a89a3f5a0 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:46:54 +0000 Subject: [PATCH 04/10] test(mcp): cover source deploy capability --- src/lib/mcp-agent.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/mcp-agent.test.ts b/src/lib/mcp-agent.test.ts index 505ebbc..3c24af7 100644 --- a/src/lib/mcp-agent.test.ts +++ b/src/lib/mcp-agent.test.ts @@ -12,6 +12,7 @@ describe("GroundControl MCP tool catalog", () => { expect(names).toContain("deployment.config.check"); expect(names).toContain("operation.get"); expect(names).not.toContain("deployment.redeploy"); + expect(names).not.toContain("deployment.source.deploy"); }); it("exposes redeploy only when explicitly granted and never exposes a shell tool", () => { @@ -23,6 +24,7 @@ describe("GroundControl MCP tool catalog", () => { "operation:read", ])).map((tool) => tool.name); expect(names).toContain("deployment.redeploy"); + expect(names).toContain("deployment.source.deploy"); expect(names.some((name) => /shell|exec|terminal/i.test(name))).toBe(false); }); @@ -34,6 +36,7 @@ describe("GroundControl MCP tool catalog", () => { "deployment.logs", "deployment.health", "deployment.config.check", + "deployment.source.deploy", "deployment.redeploy", "operation.get", ]); From 0e6970665668604fc636f1982388ecfa4d085b99 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:47:11 +0000 Subject: [PATCH 05/10] feat(deploy): sync private GitHub source on target --- src/lib/github-source-deploy.ts | 163 ++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/lib/github-source-deploy.ts diff --git a/src/lib/github-source-deploy.ts b/src/lib/github-source-deploy.ts new file mode 100644 index 0000000..4730a8d --- /dev/null +++ b/src/lib/github-source-deploy.ts @@ -0,0 +1,163 @@ +import { decryptMaybe } from "@/lib/crypto"; +import { prisma } from "@/lib/prisma"; +import { createGithubInstallationToken } from "@/lib/github-app"; +import { execOnTargetStrict } from "@/lib/host-exec"; +import { shQuote, type VpsConnection } from "@/lib/vps"; + +export type GithubSourceSyncResult = { + repository: string; + branch: string; + commitSha: string; + tokenExpiresAt: string; +}; + +export function normalizeSourceBranch(value: unknown, fallback = "main"): string { + const branch = String(value || fallback).trim(); + const invalid = + !branch || + branch.length > 200 || + branch.startsWith("-") || + branch.startsWith("/") || + branch.endsWith("/") || + branch.endsWith(".") || + branch.includes("..") || + branch.includes("@{") || + branch.includes("//") || + branch.includes("\\") || + /[~^:?*\[\]\x00-\x20\x7f]/.test(branch) || + branch.split("/").some((part) => !part || part.startsWith(".") || part.endsWith(".lock")); + if (invalid) { + throw new Error("The requested GitHub branch is not a safe branch name."); + } + return branch; +} + +function redactCredential(value: string, credential: string): string { + if (!value || !credential) return value; + return value.split(credential).join("[REDACTED]"); +} + +async function sourceAccessForDeployment(deploymentId: number) { + const links = await prisma.githubRepositoryDeployment.findMany({ + where: { enrolledDeploymentId: deploymentId }, + include: { + repository: { + include: { + installation: { + include: { connection: true }, + }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + const link = links.find((candidate) => candidate.source === "explicit") || links[0]; + if (!link) { + throw new Error("No GitHub repository is linked to this deployment."); + } + + const repository = link.repository; + if (repository.isArchived) { + throw new Error("The linked GitHub repository is archived."); + } + if (repository.installation.suspendedAt) { + throw new Error("The GitHub App installation for this repository is suspended."); + } + + const privateKey = decryptMaybe(repository.installation.connection.privateKeyEncrypted); + if (!privateKey) { + throw new Error("GroundControl cannot decrypt the GitHub App private key."); + } + const access = await createGithubInstallationToken({ + appId: repository.installation.connection.appId, + privateKey, + installationId: repository.installationId, + }); + if (!["read", "write"].includes(access.permissions.contents || "")) { + throw new Error("The GroundControl GitHub App does not have repository contents access."); + } + + return { + token: access.token, + expiresAt: access.expiresAt, + repository: { + fullName: repository.fullName, + defaultBranch: repository.defaultBranch || "main", + }, + }; +} + +/** + * Sync a linked GitHub repository onto an existing deployment tree without + * persisting the short-lived installation token in the git remote or command. + * The token is supplied over stdin to a temporary GIT_ASKPASS helper. + */ +export function buildGithubSourceSyncCommand(input: { + projectPath: string; + repository: string; + branch: string; +}): string { + const remote = `https://github.com/${input.repository}.git`; + return [ + "set -eu", + "umask 077", + "IFS= read -r GC_GITHUB_TOKEN", + "export GC_GITHUB_TOKEN", + "export GIT_TERMINAL_PROMPT=0", + "gc_askpass=$(mktemp /tmp/gc-git-askpass.XXXXXX)", + "trap 'rm -f \"$gc_askpass\"' EXIT HUP INT TERM", + "cat > \"$gc_askpass\" <<'GCASKPASS'", + "#!/bin/sh", + "case \"$1\" in", + " *Username*) printf '%s\\n' 'x-access-token' ;;", + " *) printf '%s\\n' \"$GC_GITHUB_TOKEN\" ;;", + "esac", + "GCASKPASS", + "chmod 700 \"$gc_askpass\"", + "export GIT_ASKPASS=\"$gc_askpass\"", + `mkdir -p ${shQuote(input.projectPath)}`, + `cd ${shQuote(input.projectPath)}`, + "if [ ! -d .git ]; then git init; fi", + `if git remote get-url origin >/dev/null 2>&1; then git remote set-url origin ${shQuote(remote)}; else git remote add origin ${shQuote(remote)}; fi`, + `git fetch --depth 1 origin ${shQuote(input.branch)}`, + `git checkout -B ${shQuote(input.branch)} --force FETCH_HEAD`, + "git reset --hard FETCH_HEAD", + "printf 'commit=%s\\n' \"$(git rev-parse HEAD)\"", + ].join("\n"); +} + +export async function syncGithubDeploymentSource(input: { + deploymentId: number; + projectPath: string; + branch?: unknown; + vps?: VpsConnection | null; +}): Promise { + const access = await sourceAccessForDeployment(input.deploymentId); + const branch = normalizeSourceBranch(input.branch, access.repository.defaultBranch); + const command = buildGithubSourceSyncCommand({ + projectPath: input.projectPath, + repository: access.repository.fullName, + branch, + }); + const result = await execOnTargetStrict(command, input.vps, undefined, `${access.token}\n`); + if (result.code !== 0) { + const detail = redactCredential(result.stderr || result.stdout || "git source sync failed", access.token) + .trim() + .slice(-1500); + throw new Error(`GitHub source sync failed: ${detail}`); + } + const commitSha = result.stdout + .split("\n") + .map((line) => line.trim()) + .find((line) => line.startsWith("commit=")) + ?.slice("commit=".length) || ""; + if (!/^[a-f0-9]{40,64}$/i.test(commitSha)) { + throw new Error("GitHub source sync completed without a verifiable commit SHA."); + } + return { + repository: access.repository.fullName, + branch, + commitSha, + tokenExpiresAt: access.expiresAt, + }; +} From 9ba6632a09a0d852f06ec7bbcf4253f3e23541a1 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:47:14 +0000 Subject: [PATCH 06/10] test(deploy): cover credential-safe source sync --- src/lib/github-source-deploy.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/lib/github-source-deploy.test.ts diff --git a/src/lib/github-source-deploy.test.ts b/src/lib/github-source-deploy.test.ts new file mode 100644 index 0000000..41eb144 --- /dev/null +++ b/src/lib/github-source-deploy.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { buildGithubSourceSyncCommand, normalizeSourceBranch } from "./github-source-deploy"; + +describe("GitHub source deploy", () => { + it("accepts normal branch names and rejects unsafe refs", () => { + expect(normalizeSourceBranch("feature/direct-deploy")).toBe("feature/direct-deploy"); + expect(normalizeSourceBranch("", "main")).toBe("main"); + expect(() => normalizeSourceBranch("../main")).toThrow(); + expect(() => normalizeSourceBranch("feature@{1}")).toThrow(); + expect(() => normalizeSourceBranch("-main")).toThrow(); + }); + + it("uses a temporary askpass helper without embedding credentials in the command", () => { + const command = buildGithubSourceSyncCommand({ + projectPath: "/srv/groundcontrol/deployments/rentaweekend", + repository: "teckedd-code2save/RentAWeekend", + branch: "main", + }); + expect(command).toContain("GIT_ASKPASS"); + expect(command).toContain("IFS= read -r GC_GITHUB_TOKEN"); + expect(command).toContain("https://github.com/teckedd-code2save/RentAWeekend.git"); + expect(command).not.toContain("github_pat_"); + expect(command).not.toContain("git clean"); + }); +}); From 11a89cf3849f525d33245dddca328b434b211117 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:47:23 +0000 Subject: [PATCH 07/10] feat(agent): execute source deploy operations --- scripts/agent-operation-worker.cjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/agent-operation-worker.cjs b/scripts/agent-operation-worker.cjs index f9affd2..08885f4 100644 --- a/scripts/agent-operation-worker.cjs +++ b/scripts/agent-operation-worker.cjs @@ -26,7 +26,7 @@ function sessionCookie(user, jwtSecret) { } async function fetchJson(url, init) { - const response = await fetch(url, { ...init, signal: AbortSignal.timeout(15 * 60 * 1000) }); + const response = await fetch(url, { ...init, signal: AbortSignal.timeout(30 * 60 * 1000) }); const text = await response.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 1000) }; } @@ -84,10 +84,12 @@ async function executeRedeploy({ prisma, operation, baseUrl, jwtSecret }) { if (!parseResources(current.grant.resources).includes(current.deploymentId)) { return markFailed(prisma, current, "The grant no longer authorizes this deployment"); } - if (current.type !== "deployment.redeploy") { + if (!["deployment.redeploy", "deployment.source.deploy"].includes(current.type)) { return markFailed(prisma, current, `Unsupported operation type: ${current.type}`); } + const input = parseJson(current.inputJson) || {}; + const sourceDeploy = current.type === "deployment.source.deploy"; const projectSlug = current.deployment.legacyProject?.slug || current.deployment.slug; const latestRelease = current.deployment.legacyProjectId ? await prisma.deployment.findFirst({ @@ -111,7 +113,8 @@ async function executeRedeploy({ prisma, operation, baseUrl, jwtSecret }) { projectPath: current.deployment.sourcePath || undefined, composePath: current.deployment.composePath || undefined, publicUrl, - action: "redeploy", + action: sourceDeploy ? "source-deploy" : "redeploy", + branch: sourceDeploy && typeof input.branch === "string" ? input.branch : undefined, }), }); @@ -125,7 +128,11 @@ async function executeRedeploy({ prisma, operation, baseUrl, jwtSecret }) { data: { status: "verifying", resultJson: JSON.stringify(body).slice(0, 20000), - evidenceJson: JSON.stringify({ phase: "redeploy_started", projectSlug }).slice(0, 20000), + evidenceJson: JSON.stringify({ + phase: sourceDeploy ? "source_deploy_started" : "redeploy_started", + projectSlug, + branch: sourceDeploy && typeof input.branch === "string" ? input.branch : undefined, + }).slice(0, 20000), leaseUntil: new Date(Date.now() + LEASE_MS), }, }); From 17d2a61d8cfb139707443cee8715fc02284ad022 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:22:46 +0000 Subject: [PATCH 08/10] feat(deploy): build linked source directly on target --- src/app/api/projects/compose/route.ts | 164 ++++++++++++++++++++------ 1 file changed, 128 insertions(+), 36 deletions(-) diff --git a/src/app/api/projects/compose/route.ts b/src/app/api/projects/compose/route.ts index b214f78..6240100 100644 --- a/src/app/api/projects/compose/route.ts +++ b/src/app/api/projects/compose/route.ts @@ -29,6 +29,7 @@ import { resolveTemplateDeploymentTarget } from "@/lib/template-deployment-state import { requireAuth } from "@/lib/auth"; import { handleApiError, HttpError } from "@/lib/errors"; import { validateSafePath } from "@/lib/host-safety"; +import { syncGithubDeploymentSource } from "@/lib/github-source-deploy"; function normalizePath(value: unknown): string { return String(value || "").trim().replace(/\/+$/, ""); @@ -284,6 +285,7 @@ export async function POST(req: NextRequest) { verificationChecks: requestedVerificationChecks, services, action, + branch, } = await req.json(); if (!isValidProjectSlug(projectSlug)) { return NextResponse.json({ error: "A valid projectSlug is required" }, { status: 400 }); @@ -302,7 +304,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: requestedComposePathError }, { status: 400 }); } const vps = await getActiveVps(); - if (action === "redeploy") { + const isRedeploy = action === "redeploy" || action === "source-deploy"; + const isSourceDeploy = action === "source-deploy"; + if (isRedeploy) { redeployLogFile = `/tmp/gc-redeploy-${projectSlug}.log`; redeployVps = vps; await recordRedeployEvidence( @@ -337,7 +341,7 @@ export async function POST(req: NextRequest) { 404 ); } - if (action === "redeploy") { + if (isRedeploy) { await recordRedeployEvidence( redeployLogFile!, `[target] Using ${target.projectPath} (${target.source})`, @@ -370,7 +374,7 @@ export async function POST(req: NextRequest) { ].map(normalizePublicEndpointUrl).find((url): url is string => Boolean(url)) || null; const verificationChecks = normalizeDeploymentVerificationChecks(publicUrl, requestedVerificationChecks); - if (action !== "redeploy") { + if (!isRedeploy) { if (["start", "recreate", "restart"].includes(action || "start") && project) { await applyEnvToDeployment( { ...project, path: target.projectPath }, @@ -411,6 +415,58 @@ export async function POST(req: NextRequest) { } const startedAt = Date.now(); + + let sourceSync: Awaited> | null = null; + if (isSourceDeploy) { + if (!project) { + throw new HttpError("Source deployment requires a managed GroundControl project.", 409); + } + const enrolled = await prisma.enrolledDeployment.findFirst({ + where: { + OR: [ + { legacyProjectId: project.id }, + { sourcePath: target.projectPath }, + { slug: projectSlug }, + ], + }, + select: { id: true, managementMode: true }, + }); + if (!enrolled) { + throw new HttpError("Source deployment is not enrolled in GroundControl.", 409); + } + if (enrolled.managementMode !== "managed") { + throw new HttpError("Source deployment requires managementMode=managed.", 409); + } + + redeployPhase = "source"; + await recordRedeployEvidence( + redeployLogFile!, + "[source] Syncing linked GitHub repository on the deployment host", + vps + ); + sourceSync = await syncGithubDeploymentSource({ + deploymentId: enrolled.id, + projectPath: target.projectPath, + branch, + vps, + }); + await recordRedeployEvidence( + redeployLogFile!, + `[source] Synced ${sourceSync.repository}@${sourceSync.commitSha.slice(0, 12)} branch=${sourceSync.branch}`, + vps + ); + + const refreshedCompose = await resolveComposeFile( + target.projectPath, + vps, + requestedComposePathForCandidate(requestedComposePath, target.projectPath) + ); + if (!refreshedCompose) { + throw new HttpError("The synced source does not contain the expected Compose file.", 409); + } + composeFile = refreshedCompose; + } + redeployPhase = "configuration"; let environmentHash: string | undefined; if (project) { @@ -488,41 +544,66 @@ export async function POST(req: NextRequest) { const selectedServices = Array.isArray(services) ? services.map((service) => String(service)) : undefined; const expectedImages = expectedComposeImages(configCheck.stdout, selectedServices); - redeployPhase = "registry"; - await recordRedeployEvidence( - redeployLogFile!, - "[registry] Authenticating configured container registry", - vps - ); - await ensureGithubRegistryLogin(vps); - await recordRedeployEvidence( - redeployLogFile!, - "[registry] Registry authentication ready", - vps - ); - redeployPhase = "pull"; - await recordRedeployEvidence( - redeployLogFile!, - "[pull] Pulling images from the effective Compose configuration", - vps - ); - const pullResult = await execOnTargetStrict( - `cd ${shQuote(target.projectPath)} && ${buildManagedComposeInvocation(composeCmd, `pull${serviceArgs ? ` ${serviceArgs}` : ""}`, composeFile)}`, - vps - ); - if (serviceArgs && pullResult.code !== 0) { - throw new HttpError( - `Image pull failed: ${(pullResult.stderr || pullResult.stdout || "registry rejected the image").trim().slice(0, 500)}`, - 400 + let pullResult: { stdout: string; stderr: string; code: number } = { stdout: "", stderr: "", code: 0 }; + if (isSourceDeploy) { + redeployPhase = "build"; + await recordRedeployEvidence( + redeployLogFile!, + "[build] Building Compose services on the deployment host", + vps + ); + const buildResult = await execOnTargetStrict( + `cd ${shQuote(target.projectPath)} && ${buildManagedComposeInvocation(composeCmd, `build${serviceArgs ? ` ${serviceArgs}` : ""}`, composeFile)}`, + vps + ); + if (buildResult.code !== 0) { + throw new HttpError( + `Source build failed: ${(buildResult.stderr || buildResult.stdout || "docker compose build failed").trim().slice(-1500)}`, + 400 + ); + } + await recordRedeployEvidence( + redeployLogFile!, + "[build] Host build completed", + vps + ); + } else { + redeployPhase = "registry"; + await recordRedeployEvidence( + redeployLogFile!, + "[registry] Authenticating configured container registry", + vps + ); + await ensureGithubRegistryLogin(vps); + await recordRedeployEvidence( + redeployLogFile!, + "[registry] Registry authentication ready", + vps + ); + redeployPhase = "pull"; + await recordRedeployEvidence( + redeployLogFile!, + "[pull] Pulling images from the effective Compose configuration", + vps + ); + pullResult = await execOnTargetStrict( + `cd ${shQuote(target.projectPath)} && ${buildManagedComposeInvocation(composeCmd, `pull${serviceArgs ? ` ${serviceArgs}` : ""}`, composeFile)}`, + vps + ); + if (serviceArgs && pullResult.code !== 0) { + throw new HttpError( + `Image pull failed: ${(pullResult.stderr || pullResult.stdout || "registry rejected the image").trim().slice(0, 500)}`, + 400 + ); + } + await recordRedeployEvidence( + redeployLogFile!, + pullResult.code === 0 + ? "[pull] Images resolved" + : "[pull] Image pull completed with build-only services skipped", + vps ); } - await recordRedeployEvidence( - redeployLogFile!, - pullResult.code === 0 - ? "[pull] Images resolved" - : "[pull] Image pull completed with build-only services skipped", - vps - ); redeployPhase = "recreate"; const deployArgs = `up -d --remove-orphans --force-recreate${serviceArgs ? ` ${serviceArgs}` : ""}`; @@ -624,6 +705,11 @@ export async function POST(req: NextRequest) { imageDigest, vps, }); + if (sourceSync) { + sourceFingerprint.repoUrl = `https://github.com/${sourceSync.repository}`; + sourceFingerprint.commitSha = sourceSync.commitSha; + sourceFingerprint.branch = sourceSync.branch; + } if (project) { if (sourceFingerprint.repoUrl && !project.repoUrl) { await prisma.project.update({ @@ -648,6 +734,7 @@ export async function POST(req: NextRequest) { `[validate] Effective Compose configuration OK (${composeFile})`, environmentHash ? `[configuration] revision=${environmentHash}` : "", sourceFingerprint.commitSha ? `[source] revision=${sourceFingerprint.commitSha.slice(0, 12)} repo=${sourceFingerprint.repoUrl || "unresolved"}` : "[source] revision unresolved", + sourceSync ? `[source] ${sourceSync.repository}@${sourceSync.commitSha} branch=${sourceSync.branch}` : "", pullResult.stdout || pullResult.stderr ? `[pull]\n${pullResult.stdout || pullResult.stderr}` : "", result.stdout, ].filter(Boolean).join("\n") || null, @@ -691,6 +778,11 @@ export async function POST(req: NextRequest) { detached: detached || undefined, imageDigest: imageDigest || undefined, sourceFingerprint, + sourceSync: sourceSync ? { + repository: sourceSync.repository, + branch: sourceSync.branch, + commitSha: sourceSync.commitSha, + } : undefined, environmentHash, changedFields: changedFields.length > 0 ? changedFields : undefined, }); From ab0e0d0bc56ce8edc1c92d2a5a4757044f76882e Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:23:09 +0000 Subject: [PATCH 09/10] fix(mcp): keep discovery cache short without false notifications --- src/app/mcp/route.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 357cb12..178b57f 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -14,7 +14,7 @@ const TOOLSET_REVISION = "2026-09-20.1"; const SERVER_INFO = { name: "GroundControl", title: "GroundControl", - version: "0.2.0", + version: "0.1.0", }; const SERVER_INSTRUCTIONS = "GroundControl exposes scoped infrastructure operations. Inspect state before changing it, use durable operation handles for mutations, and never assume access to workloads outside the OAuth grant."; @@ -171,7 +171,7 @@ export async function POST(req: NextRequest) { if (body.method === "initialize") { return rpc(body.id, { protocolVersion: negotiateLegacyVersion(body), - capabilities: { tools: { listChanged: true } }, + capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS, }, false); @@ -180,7 +180,7 @@ export async function POST(req: NextRequest) { if (body.method === "server/discover") { return rpc(body.id, { supportedVersions: [MCP_PROTOCOL_VERSION, ...LEGACY_PROTOCOL_VERSIONS], - capabilities: { tools: { listChanged: true } }, + capabilities: { tools: { listChanged: false } }, instructions: SERVER_INSTRUCTIONS, ttlMs: 30_000, cacheScope: "private", @@ -195,7 +195,7 @@ export async function POST(req: NextRequest) { if (body.method === "tools/list") { return rpc(body.id, { tools: toolDefinitionsForScopes(), - ...(modern ? { ttlMs: 30_000, cacheScope: "private" } : {}), + ...(modern ? { ttlMs: 5_000, cacheScope: "private" } : {}), }, modern); } From 5f1ed6d5812be86fc831f98824ab2048931fd91e Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:23:12 +0000 Subject: [PATCH 10/10] test(mcp): align discovery cache behavior --- src/app/mcp/route.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/mcp/route.test.ts b/src/app/mcp/route.test.ts index 4df39fe..cbda72a 100644 --- a/src/app/mcp/route.test.ts +++ b/src/app/mcp/route.test.ts @@ -100,10 +100,10 @@ describe("GroundControl MCP transport", () => { expect(response.status).toBe(200); expect(data.result.supportedVersions).toContain("2026-07-28"); - expect(data.result.capabilities.tools).toEqual({ listChanged: true }); + expect(data.result.capabilities.tools).toEqual({ listChanged: false }); expect(data.result._meta["io.modelcontextprotocol/serverInfo"]).toMatchObject({ name: "GroundControl", - version: "0.2.0", + version: "0.1.0", }); expect(data.result._meta["io.groundcontrol/toolsetRevision"]).toBe("2026-09-20.1"); expect(response.headers.get("MCP-Protocol-Version")).toBe("2026-07-28");