ZodOps

Anatomy · MCP · 6 min · Aug 11, 2026

Agent Security

The Key in the Env Block

You reviewed the system prompt. You did not review the access key the MCP server loads on start. That key is the permission grant.

At a glance

Anatomy summary: system, stack, and the miss
SystemInternal MCP server in front of tickets and customer data
StackMCP, Docker Compose, AWS IAM
The missA long-lived AWS access key in the environment. Every tool call runs as one shared account.

The short version: teams review what the model is told to do. They skip the credentials the MCP server uses to do it. Those credentials are the permission grant. The prompt is commentary.

The config

This is a composite, not a dump from one tenant. I keep seeing the same three lines in agent hosts and the compose file that starts the same server on a shared host. That yaml is a launch file in a repo. It is not an AWS resource, and it is not where the permission grant belongs.

mcp.json — what the agent host actually loads
{
  "mcpServers": {
    "itsm-mcp": {
      "command": "npx",
      "args": ["-y", "@platform/mcp-itsm"],
      "env": {
        "AWS_REGION": "us-east-1",
        "AWS_ACCESS_KEY_ID": "AKIA••••••••••••W3KQ",
        "AWS_SECRET_ACCESS_KEY": "••••••••••••••••••••••••••••••••••••••••",
        "SALESFORCE_INSTANCE_URL": "https://••••.my.salesforce.com",
        "SALESFORCE_TOKEN": "00D••••••••!••••••••••••••••",
        "SLACK_BOT_TOKEN": "xoxb-••••••••••••-••••••••••••-••••••••••••••••",
        "SLACK_TEAM_ID": "T0•••••••"
      }
    }
  }
}

The container version is the same grant with a restart policy. The keys did not get safer because they moved into Compose.

docker-compose.yml — same server, same identity, from the shared-host repo
services:
  itsm-mcp:
    image: ************.dkr.ecr.us-east-1.amazonaws.com/platform/mcp-itsm:1.4.2
    container_name: itsm-mcp
    restart: unless-stopped
    networks:
      - shared-services
    environment:
      AWS_REGION: us-east-1
      AWS_ACCESS_KEY_ID: AKIA••••••••••••W3KQ
      AWS_SECRET_ACCESS_KEY: ••••••••••••••••••••••••••••••••••••••••
      SALESFORCE_INSTANCE_URL: https://••••.my.salesforce.com
      SALESFORCE_TOKEN: 00D••••••••!••••••••••••••••
      SLACK_BOT_TOKEN: xoxb-••••••••••••-••••••••••••-••••••••••••••••
      SLACK_TEAM_ID: T0•••••••
      LOG_LEVEL: info
    command:
      - mcp-server
      - --transport
      - stdio
      - --tools
      - tickets,customers,sql,slack
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    labels:
      com.corp.owner: platform-ai
      com.corp.service: itsm-mcp

networks:
  shared-services:
    external: true

Three lines that matter

  • The access key. It is a long-lived principal. It does not expire with the session, the user, or the ticket the agent is reading. Every copy on every laptop and in repo history works until you rotate, and rotating breaks every one of them, so nobody does. The Salesforce value next to it is a session token — it expires — but it is still sitting beside a key that does not.
  • The tool list. tickets,customers,sql,slack is not a support agent. It is read, join, and send on one process. sql is a general-purpose query surface sitting behind a model that will call whatever the last document told it to call.
  • The missing user. Nothing in this file binds a call to the person who opened the chat. Every tool runs as itsm-mcp. A prompt that says "only look up the current requester" is not an authorization check.

What it actually grants

Whatever IAM and Salesforce attached to those secrets. In the rollouts I have reviewed, that is usually: read on the ticket store, read on customer records, and post to a Slack workspace. Sometimes write. Nobody mapped it because the server "just needed to work for the pilot."

The model did not create that reach. It made it callable in one turn, from a ticket body, a pasted stack trace, or a page the agent was told to summarize.

  • A fooled agent can do anything this account can do, not anything the current user can do.
  • The key lives on laptops and in the compose file. Laptop compromise and repo history are now the same as compromising the integration.
  • There is no offboarding. The engineer who stood this up in March can leave. The key will not.

Where this file sits

Compose is not IAM. It is how ops starts this container next to the other internal services. mcp.json is the same process on a laptop. Neither file grants AWS permission. They only decide how the process boots and what it is handed at start.

The AWS grant is already IAM. The broken version created an IAM user, minted a long-lived access key, and pasted AKIA into the env block. IAM did the grant. The file just copied it onto every host and into git. "Do it in IAM" is the right instinct. The miss is that the grant was then duplicated as a secret that never expires.

PieceWhere it livesWhat it decides
mcp.json / ComposeLaptop or the shared-host repoHow the process starts, which tools it exposes, what sits in env
IAM roleAWS, attached to the instance, task, or podWhich AWS APIs the process can call, including fetching secrets
Salesforce / Slack tokensSecrets Manager, then those vendorsWhat the ticket and Slack APIs allow once the process has a token

Create a role. Scope it. Attach it to the compute that runs the container — instance profile on EC2, task role on ECS, IRSA or Pod Identity on EKS. The process asks the metadata service and gets a short-lived credential. Compose never sees a key. You rotate, attribute, and revoke in IAM.

prod-itsm-mcp — IAM role policy. Attach this to the host. Do not mint a user key.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadItsmSecretsOnly",
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": [
        "arn:aws:secretsmanager:us-east-1:************:secret:prod/itsm-mcp/slack-bot-••••••",
        "arn:aws:secretsmanager:us-east-1:************:secret:prod/itsm-mcp/salesforce-••••••"
      ]
    },
    {
      "Sid": "DecryptThoseSecrets",
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:us-east-1:************:key/••••••••-••••-••••-••••-••••••••••••",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": "secretsmanager.us-east-1.amazonaws.com"
        }
      }
    }
  ]
}

IAM still cannot name ticket_status instead of sql, require a human on slack_post, or pin the image. Those stay in the launch file. That is why the tighter compose exists, and why it no longer carries AWS_ACCESS_KEY_ID.

The tighter version

The identity is the role above. This file only starts the process, names the tools, and points at secret ARNs. Split the tools. Do not let the model send or query freely.

Orange marks what changed from the compose file above.

docker-compose.yml — identity on the workload, tools cut down
services:
  itsm-mcp:
    image: ************.dkr.ecr.us-east-1.amazonaws.com/platform/mcp-itsm:1.4.2@sha256:4f2c••••••••••••••••••••••••••••••••••••••••••••••••••••••••
    container_name: itsm-mcp
    restart: unless-stopped
    read_only: true
    networks:
      - shared-services
    environment:
      AWS_REGION: us-east-1
      # No AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.
      # EC2: instance profile. ECS: task role. EKS: IRSA or Pod Identity.
      # Off AWS: IAM Roles Anywhere or an SSO profile — not a pasted key.
      SALESFORCE_INSTANCE_URL: https://••••.my.salesforce.com
      TICKET_AUDIENCE: tickets.internal
      SLACK_BOT_TOKEN_SECRET_ARN: arn:aws:secretsmanager:us-east-1:************:secret:prod/itsm-mcp/slack-bot-••••••
      SALESFORCE_TOKEN_SECRET_ARN: arn:aws:secretsmanager:us-east-1:************:secret:prod/itsm-mcp/salesforce-••••••
      LOG_LEVEL: info
    command:
      - mcp-server
      - --transport
      - stdio
      - --tools
      - ticket_status,requester_account,slack_post
      - --confirm
      - slack_post
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    labels:
      com.corp.owner: platform-ai
      com.corp.service: itsm-mcp

networks:
  shared-services:
    external: true
  • No AWS_ACCESS_KEY_ID. The host already has the role. Off AWS, IAM Roles Anywhere or an SSO profile — still not a pasted key.
  • Two read tools, named for the use case. ticket_status and requester_account. Not sql. Not "customers." The join lives in your code, not in a prompt.
  • slack_post is in the tool list, and --confirm gates it. High-impact actions require a human in the same loop.
  • Slack and Salesforce secrets do not come back as env. Pull a bot token from a secrets manager with rotation, or issue a per-session OAuth token for the user who opened the chat. Do not paste either one into the file.
  • Pin the image digest. Treat the MCP server like any other dependency you would not float on latest.

On a laptop the same rule holds. The env block should not contain AKIA. If the only way the server works is a pasted production key, it is not ready to sit behind an agent.

How to tell you are looking at this

  • mcp.json or Compose has AWS_ACCESS_KEY_ID, a Salesforce token, or a Slack bot token in plain env.
  • The command is npx -y @something/mcp-…. That flag fetches whatever latest is today, the same unpinned-dependency problem as an unpinned image tag.
  • The tool list includes sql, query, or exec, or more than one system in a single server.
  • You cannot name the IAM principal, its last rotation, or who is allowed to mint a new key.
  • The same server is used by more than one person, and every call looks like the same account in the logs — if there are logs.

The takeaway

Do not approve the agent until you can approve the role and the launch file. The model will do what the tools allow. The tools will do what that identity allows. Start there.