Development Choices

Running MCP-Driven Work in CI Instead of Locally

Author
Joseph TrasattiMember of technical staff
Published
Section
MCP
Length
7 min read3 sources cited

To run MCP-driven work in CI, authenticate the server with header credentials from the CI secret store, assemble the client config at run time, restrict the tool allowlist because no human approves calls, assert on the resulting state rather than the steps taken, and budget calls against rate limits shared with developers on the same account.

Before you start

You need three things in place, and the first one rules out some servers outright.

Steps

  1. Choose the server and the credential form for CI, not for your laptop.

    Locally you probably signed into a remote server through OAuth and never saw a secret. In CI, pick the path that takes a header or environment credential. For Cloudinary, the MediaFlows server at https://mediaflows.mcp.cloudinary.com/v2/mcp authenticates with cld-cloud-name, cld-api-key and cld-secret headers, and the local servers take credentials manually. The asset-management, environment-config and structured-metadata remote endpoints are OAuth and stay on developer machines. Whether to run the local process in CI or reach a header-authenticated remote endpoint is the same decision as anywhere else — see Remote versus local MCP servers for vendor integrations — with the added constraint that only the non-interactive option is available.

    Use a product environment that is not the one developers work in. On Cloudinary’s Free plan you get one product environment; Plus ($99/month, checked 2026-08-18) is the first tier with two. If CI has to share the developers’ environment, the rate-limit step below matters more, and any write the pipeline makes lands next to hand-managed assets.

  2. Put the secret in the CI secret store and assemble the config at run time.

    The client config file — the JSON that names servers, commands and headers — is committed alongside the code, and a committed file is the wrong home for an API secret. So the secret lives in the provider’s store and the config is generated when the job starts. Keep a template in the repo with placeholders and render it in the first step:

    envsubst < mcp.template.json > mcp.json

    with CLD_API_KEY and CLD_SECRET injected by the provider. Nothing secret is on disk after the job ends, and rotating the credential is a change in the store, not a commit. This is the CI-side half of keeping MCP credentials separate across development and production environments: a developer’s OAuth grant is scoped to a person and expires; the CI header credential is scoped to a job and does not, so it should be the only one of its kind, and it should be revocable on its own.

  3. Cut the tool allowlist down to what the job is for.

    On a developer machine the client asks before a tool runs, and a person can decline. The Model Context Protocol specification places that consent with the host application; in a headless run there is nobody to give it, so any approval prompt is either auto-accepted or aborts the job. Either way the tool allowlist is the only remaining control on what can happen.

    Write it as the smallest set that completes the task. A pipeline that tags new uploads needs the tag and list tools, not delete or environment-config tools; a pipeline that produces a report needs read tools only. If the client supports a deny list as well, deny destructive operations explicitly rather than relying on their absence from the allow list, because a later edit that widens the allow list should not silently reintroduce them. The tools a project genuinely needs are usually fewer than it configures — Choosing which MCP servers a project actually needs covers the pruning — and in CI the pruning is a safety property, not tidiness.

  4. Assert on the outcome, not on the steps.

    This is the harder problem, and the one that catches teams who got the first three right. A conventional CI step is deterministic: same input, same output, and you can diff it. A model-driven step is not. Given the same prompt and the same assets, the model may call the tools in a different order, batch differently, or phrase a generated caption differently, and the resulting diff changes each run. Asserting that the run “did what it did last time” fails on the next run for no reason worth investigating.

    Assert on the state you wanted instead. After the MCP step, run a deterministic check against the outcome — for Cloudinary, an Admin API query. If the job’s purpose was to ensure every asset in a folder carries a product_id metadata value, the assertion is “list the folder; count resources missing the field; fail if nonzero”. If the purpose was moderation, assert that no resource remains in the pending state. If it was a report, assert that the report exists and has the expected sections. The model is free to get there however it likes; the pipeline goes red only when it did not get there.

    Two consequences follow. First, keep the model step idempotent where you can — asking it to “ensure X” rather than “do X”, so a rerun after a partial failure converges instead of duplicating work. Second, when the assertion fails, the transcript of tool calls is your evidence; capture it as a job artifact. Diagnosing an MCP tool call that fails or returns the wrong thing is written for the interactive case, but the same reading applies to a saved log.

  5. Budget the pipeline against rate limits you share with people.

    Rate limits are counted per account, and the CI job draws from the same allowance as every developer working in that account. Cloudinary’s Free plan allows 500 Admin API requests per hour (pricing page, checked 2026-08-18); an agent that lists, inspects and re-lists on every push can spend that in a few runs, at which point developers’ local tool calls start failing before the pipeline’s own do — the pipeline is bursty, so it hits the wall and moves on, while a person mid-task gets the errors.

    Three controls, in order of effect. Trigger the job on a schedule or a specific path change rather than every commit. Cap the number of tool calls per run in the client if it supports it, and fail the run when the cap is hit rather than letting the model keep trying. And make sure the client backs off on a rate-limit response rather than retrying immediately, which turns one exhausted window into two — Retry and backoff behaviour for agent-driven MCP calls sets out what that looks like. If you moved CI onto its own product environment in step 1, that separates the assets; check with the vendor whether it also separates the limit before assuming it does.

Expected result

The MCP step runs unattended on the CI provider with no browser and no person present. Its credential exists in exactly one place, the provider’s secret store, and the committed config contains none of it. The tools it can call are enumerated in the job’s config and nothing outside that list is reachable. The job passes or fails on a deterministic check of the resulting state, so two green runs may have made different tool calls and two red runs point at the same missing outcome. Developers on the same account keep their local rate allowance because the pipeline runs on a schedule, stops at a call cap, and backs off when it is told to.

Sources

  1. Cloudinary's MCP server documentationcloudinary.com
  2. Admin APIcloudinary.com
  3. Model Context Protocol specificationmodelcontextprotocol.io

See also