Guide · MCP GitOps Integration

MCP Server ArgoCD — get app status, sync, rollback, and manage GitOps deployments

ArgoCD is the leading GitOps operator for Kubernetes, managing application deployment state by reconciling live cluster resources against a Git repository. This guide covers building TypeScript MCP tools around the ArgoCD REST API: JWT authentication, getting application health and sync status (two independent state machines), triggering syncs with selective resource targeting, rolling back to a previous revision, streaming pod logs, and wiring a /health/argocd endpoint so AliveMCP can detect ArgoCD server restarts and token expiry before your deployment tools stop working.

TL;DR

Authenticate with a JWT: POST /api/v1/session with username/password → Bearer token. ArgoCD apps have two independent status fields: sync.status (Synced/OutOfSync/Unknown) and health.status (Healthy/Progressing/Degraded/Missing/Unknown) — an app can be Synced but Degraded simultaneously. Trigger syncs via POST /api/v1/applications/:name/sync; rollback via POST /api/v1/applications/:name/rollback with the numeric history ID. Wire AliveMCP to /api/v1/session/userinfo to verify the JWT is still valid before a sync call fails mid-deployment.

Authentication: JWT session tokens

ArgoCD's REST API uses JWT bearer tokens. You obtain a token by logging in with a username and password (or API key, for service accounts). Tokens expire — typically after 24 hours for local users and 1 hour for SSO sessions — so your MCP server needs to handle token refresh.

import axios, { AxiosInstance } from 'axios';

const ARGOCD_URL = process.env.ARGOCD_URL ?? 'https://argocd.example.com';
const ARGOCD_USER = process.env.ARGOCD_USER ?? 'admin';
const ARGOCD_PASS = process.env.ARGOCD_PASS!;

let jwtToken: string | null = null;
let tokenExpiry: number = 0;

async function getArgoToken(): Promise {
  // Refresh if token has less than 5 minutes remaining
  if (jwtToken && Date.now() < tokenExpiry - 300_000) return jwtToken;

  const res = await axios.post(
    `${ARGOCD_URL}/api/v1/session`,
    { username: ARGOCD_USER, password: ARGOCD_PASS },
    { timeout: 10_000 }
  );

  jwtToken = res.data.token;
  // Decode JWT expiry without a library (ArgoCD tokens are standard JWTs)
  const payload = JSON.parse(
    Buffer.from(jwtToken!.split('.')[1], 'base64').toString('utf8')
  );
  tokenExpiry = payload.exp * 1000;  // convert seconds to milliseconds
  return jwtToken!;
}

async function argoRequest(): Promise {
  const token = await getArgoToken();
  return axios.create({
    baseURL: `${ARGOCD_URL}/api/v1`,
    headers: { Authorization: `Bearer ${token}` },
    timeout: 30_000
  });
}

// Usage: const argo = await argoRequest();
// Create a new axios instance per tool call (cheap — just sets headers)

For production service accounts, create a dedicated ArgoCD local user (not the admin account) with only the RBAC permissions your MCP server needs. Add the user in argocd-cm and set policies in argocd-rbac-cm: p, mcp-agent, applications, get, */*, allow for read-only, plus sync and action permissions if your tools need to trigger syncs.

get_app_status tool: health and sync are independent

The most important concept for ArgoCD MCP tools is that health and sync status are two completely independent state machines. An application can be in any combination of these states simultaneously.

import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';

server.tool(
  'get_argocd_app_status',
  {
    app_name: z.string().min(1),
    include_resources: z.boolean().default(false)   // include individual K8s resource health
  },
  async ({ app_name, include_resources }) => {
    const argo = await argoRequest();

    const res = await argo.get(`/applications/${encodeURIComponent(app_name)}`);
    const app = res.data;
    const status = app.status;

    const summary = {
      name: app.metadata.name,
      namespace: app.metadata.namespace,
      project: app.spec.project,

      // Sync status — is the live state matching what Git says it should be?
      sync: {
        status: status.sync?.status,      // 'Synced' | 'OutOfSync' | 'Unknown'
        revision: status.sync?.revision,  // current Git SHA deployed
        message: status.conditions?.map((c: { message: string }) => c.message).join('; ') ?? null
      },

      // Health status — are the deployed resources actually running?
      health: {
        status: status.health?.status,    // 'Healthy' | 'Progressing' | 'Degraded' | 'Missing' | 'Unknown'
        message: status.health?.message ?? null
      },

      // Operation state — last sync attempt
      operation: status.operationState
        ? {
            phase: status.operationState.phase,          // 'Running' | 'Succeeded' | 'Failed' | 'Error'
            started_at: status.operationState.startedAt,
            finished_at: status.operationState.finishedAt ?? null,
            message: status.operationState.message ?? null
          }
        : null,

      // Source — where ArgoCD is pulling from
      source: {
        repo_url: app.spec.source?.repoURL,
        path: app.spec.source?.path,
        target_revision: app.spec.source?.targetRevision ?? 'HEAD'
      }
    };

    if (include_resources) {
      summary.resources = (status.resources ?? []).map((r: Record) => ({
        kind: r.kind,
        name: r.name,
        namespace: r.namespace,
        health: (r.health as Record)?.status ?? 'Unknown',
        sync: r.status
      }));
    }

    return {
      content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }]
    };
  }
);
Sync status Health status What's happening
Synced Healthy Normal operation — Git matches live; pods running
OutOfSync Healthy Git has new commits not yet applied; app still running
Synced Degraded Sync applied but deployment failed (crash loops, bad image)
Synced Progressing Sync applied; rollout in progress (pods rolling)
OutOfSync Degraded Both: unsynced AND currently unhealthy — worst state
Unknown Missing ArgoCD can't reach the cluster or resources were deleted

sync_app and rollback_app tools

server.tool(
  'sync_argocd_app',
  {
    app_name: z.string().min(1),
    revision: z.string().optional(),     // specific Git SHA or branch; omit for targetRevision
    prune: z.boolean().default(false),   // delete resources in cluster not in Git
    dry_run: z.boolean().default(false), // preview changes without applying
    confirm: z.literal(true)
  },
  async ({ app_name, revision, prune, dry_run }) => {
    const argo = await argoRequest();

    const syncBody: Record = {
      prune,
      dryRun: dry_run
    };
    if (revision) syncBody.revision = revision;

    const res = await argo.post(
      `/applications/${encodeURIComponent(app_name)}/sync`,
      syncBody
    );

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          app_name,
          sync_triggered: true,
          dry_run,
          prune,
          operation_phase: res.data.status?.operationState?.phase ?? 'Running',
          revision: res.data.status?.sync?.revision ?? null,
          message: res.data.status?.operationState?.message ?? null
        }, null, 2)
      }]
    };
  }
);

server.tool(
  'rollback_argocd_app',
  {
    app_name: z.string().min(1),
    history_id: z.number().int().min(0),  // from app.status.history[].id
    prune: z.boolean().default(false),
    confirm: z.literal(true)
  },
  async ({ app_name, history_id, prune }) => {
    const argo = await argoRequest();
    const res = await argo.post(
      `/applications/${encodeURIComponent(app_name)}/rollback`,
      { id: history_id, prune }
    );
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          app_name,
          rolled_back: true,
          history_id,
          operation_phase: res.data.status?.operationState?.phase ?? 'Running'
        }, null, 2)
      }]
    };
  }
);

server.tool(
  'list_argocd_app_history',
  {
    app_name: z.string().min(1)
  },
  async ({ app_name }) => {
    const argo = await argoRequest();
    const res = await argo.get(`/applications/${encodeURIComponent(app_name)}`);
    const history = (res.data.status?.history ?? []).map((h: Record) => ({
      id: h.id,
      revision: h.revision,
      deployed_at: h.deployedAt,
      source: {
        repo_url: (h.source as Record)?.repoURL,
        target_revision: (h.source as Record)?.targetRevision
      }
    })).reverse();  // most recent first

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ app_name, history, count: history.length }, null, 2)
      }]
    };
  }
);

list_apps tool

server.tool(
  'list_argocd_apps',
  {
    project: z.string().optional(),      // filter by ArgoCD project name
    namespace: z.string().optional(),    // filter by destination namespace
    health_status: z.enum(['Healthy', 'Progressing', 'Degraded', 'Missing', 'Unknown']).optional()
  },
  async ({ project, namespace, health_status }) => {
    const argo = await argoRequest();
    const params: Record = {};
    if (project) params.appNamespace = project;

    const res = await argo.get('/applications', { params });
    let apps = (res.data.items ?? []) as Record[];

    // Client-side filtering for namespace and health (API doesn't support these filters)
    if (namespace) {
      apps = apps.filter(
        (a) => (a.spec as Record).destination?.namespace === namespace
      );
    }
    if (health_status) {
      apps = apps.filter(
        (a) => (a.status as Record).health?.status === health_status
      );
    }

    const summary = apps.map((a) => {
      const spec = a.spec as Record;
      const status = a.status as Record;
      const meta = a.metadata as Record;
      return {
        name: meta.name,
        project: spec.project,
        sync_status: (status.sync as Record)?.status,
        health_status: (status.health as Record)?.status,
        destination_namespace: (spec.destination as Record)?.namespace,
        repo_url: (spec.source as Record)?.repoURL,
        target_revision: (spec.source as Record)?.targetRevision
      };
    });

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ apps: summary, count: summary.length }, null, 2)
      }]
    };
  }
);

Health endpoint: /health/argocd

import express from 'express';

const app = express();

app.get('/health/argocd', async (_req, res) => {
  const start = Date.now();
  try {
    // /api/v1/session/userinfo verifies the JWT and returns the authenticated user
    const argo = await argoRequest();
    const me = await argo.get('/session/userinfo');
    res.status(200).json({
      status: 'ok',
      authenticated_as: me.data.username,
      groups: me.data.groups ?? [],
      latency_ms: Date.now() - start,
      argocd_url: ARGOCD_URL
    });
  } catch (err) {
    const axErr = err as { response?: { status?: number }; message?: string };
    const httpStatus = axErr.response?.status;
    res.status(503).json({
      status: 'error',
      http_status: httpStatus,
      error: axErr.message,
      latency_ms: Date.now() - start,
      note: httpStatus === 401
        ? 'JWT expired or invalid — check ARGOCD_PASS and re-authenticate'
        : undefined
    });
  }
});

app.listen(3001);

Wire AliveMCP to this endpoint at a 60-second check interval. A 401 response from /session/userinfo means the JWT has expired or the ArgoCD server restarted (which invalidates all tokens). The argoRequest() function in the setup section re-authenticates automatically — so a brief 401 from the health check followed by a successful re-auth is normal and should not trigger a downtime alert. Configure AliveMCP to require 2 consecutive failures before alerting to filter out this transient case.

Frequently asked questions

How do I wait for a sync to complete in a single tool call?

Poll GET /applications/:name on a 3-second interval inside the tool handler and check status.operationState.phase. Terminal phases are Succeeded, Failed, and Error. While the sync is running, the phase is Running. After the operation completes, also check status.health.status — a Succeeded sync phase with a Degraded health status means the resources were applied but the pods are crashing. Both pieces of information are needed to determine if a deployment actually succeeded.

What is the difference between sync and refresh in ArgoCD?

A refresh causes ArgoCD to re-check the Git repository for new commits and compare them against the live cluster state, updating the sync.status field. It does not apply any changes to the cluster. A sync applies the Git-defined state to the cluster — it runs kubectl apply effectively. When you call the sync API, ArgoCD performs a refresh first, then applies the diff. If you only need to know whether an app is out of sync without applying changes, trigger a hard refresh: GET /applications/:name?refresh=hard.

How do I sync only specific resources (not the whole app)?

Pass a resources array in the sync request body: { resources: [{ group: 'apps', kind: 'Deployment', name: 'my-deploy', namespace: 'production' }] }. This restricts the sync to only the listed Kubernetes resources. Useful when you want to roll out a new image to one Deployment without syncing the entire application (which might include ConfigMaps, Services, or other resources that haven't changed). This is the ArgoCD equivalent of selective resource apply.

Can I use ArgoCD MCP tools with ApplicationSets?

The ArgoCD REST API exposes individual Application objects — ApplicationSets are managed by the ApplicationSet controller and are not directly operable via the same /api/v1/applications endpoints. To manage ApplicationSets, use kubectl against the applicationsets.argoproj.io CRD via the Kubernetes API. From an MCP perspective, you'd combine this guide's ArgoCD app tools with a Kubernetes MCP server to cover both individual app operations and ApplicationSet management.

Further reading

Know when your ArgoCD MCP tools can't reach the GitOps server

AliveMCP monitors your /health/argocd endpoint and alerts you the moment token expiry or an ArgoCD restart would block your deployment tools.

Start monitoring free