Skip to content

Banner image Banner image

SRE Agents Diagnose. ITSM Agents Resolve. This One Does Both.

An agent asked to create a ticket. The policy parked it, because creating things is a tier-3 action and tier 3 waits for a person. I approved it. The API returned 200, the approval was consumed, and no ticket existed.

That is not the failure anyone warns you about. The fear with agents on a ticket queue is the rogue one that does too much. What actually bit me was an agent doing nothing at all while every layer above it reported success — including the approval gate that was supposed to be the safety mechanism. A gate that says "done" when nothing happened is worse than no gate, because now you've stopped watching.

This post is the stack underneath that: a triage agent in front of HolmesGPT and an ITSM specialist, so the whole ops queue gets a first pass from an agent, and a risk policy that decides what each of them may do unattended. And the part I'd read if I were you — what broke when I pointed it at a real Freshservice instance, which was almost nothing I'd predicted.

The one-line policy that makes this deployable

unlisted_action: tier_4_draft_only. Any verb the policy has never heard of gets drafted for a human instead of executed. Tool lists grow; policies don't grow by themselves. Fail closed on the verb you haven't tiered yet and "let an agent touch the ticket queue" becomes a Tuesday decision rather than a leap of faith.

C4 Architecture Diagram C4 Architecture Diagram


Why a router, when Holmes already works

I've written before about what HolmesGPT does with the incident third of an ops queue — alert to root cause, runbooks that draft themselves. It's good at that job. But it only ever sees requests a human has already decided are incidents, and deciding that is the job.

Agents on the market each speak one dialect. Holmes and its peers investigate infrastructure and stop. ITSM agents like the Moveworks generation resolve tickets and reset access, and have no idea what a CrashLoopBackOff is. Nobody spans the blend, so a person stands in front of the queue reading every message to work out which lane it belongs in — the one task in the queue requiring none of their expertise.

Single-lane requests are merely slow. The expensive one is the ticket that says "app is down" and is really a pod eviction: it bounces from service desk to platform team and back, gathering wait time at every hop. Classification is the only point where that bounce can be prevented, and it happens before anyone with the right expertise has looked at it.

So the fix isn't a smarter investigation agent. It's putting the classification decision, and the lanes that aren't incidents, inside the agent layer too.


The three shapes a request takes

An alert fires. It lands at the front door and the triage-router classifies it: infra incident. sre-investigator calls HolmesGPT at its in-cluster endpoint, and Holmes runs its loop over whatever read-only MCP servers the estate has attached. Here that's Kubernetes, Prometheus, Grafana and Thanos, with Azure MCP wired the same way in read-only mode — and AWS or GCP attach identically, because to the bridge they are all just another MCP server in .mcp.json. Meanwhile the runbook MCP searches for coverage and runs root-cause analysis. Back comes the diagnosis: what broke, why, which runbook applies. No runbook covers it? runbook_draft opens a draft PR — executes immediately, inert until merged. Nothing here needed a human until there was something worth a human's judgement.

The investigation surface is a deployment decision, not an architectural one. A managed-database incident, a load balancer, an IAM policy that changed — all reachable through the relevant provider's MCP server, none of it requiring a change to the router, the agents, or the policy. Kubernetes is where this estate's incidents live, not a boundary of the design.

Cloud provider guardrails are not equal, which is the argument for tiering above them

Attaching a cloud MCP server is easy; trusting its own safety controls is not. Kubernetes remediation gates a specific mutating tool behind approval. Azure's MCP server has historically offered one boolean for all write access — on or off, no per-action granularity. That asymmetry is exactly why the risk tiers live in this stack rather than being delegated to each provider: readOnlyMode is a setting someone can flip, whereas an unlisted verb falling to tier_4_draft_only is a property of the gate every backend passes through. Attach the provider read-only, and let the tier decide what write ever means.

Someone asks "why is my ticket stuck?" Same front door, different lane. The router hands it to itsm-support, which pulls the ticket over the ITSM MCP server — Jira here, Freshservice before it — looks for a matching knowledge article, and posts the answer as a ticket comment. The requester gets a response in minutes instead of whenever a service desk agent next works the queue — and if the resolution needs a ticket created, that action waits for explicit human approval first.

The cross-lane one. "My app's ticket has been open for two days" — the router reads it, spots that the underlying issue is infra, and the investigation output lands in the ticket. The bounce between service desk and platform team, the expensive part, never happens.

Notice what changes about the human's job rather than how much of it disappears. Before, someone reads every request to decide where it goes. After, they still see everything that carries risk — they just see it with a diagnosis attached, a draft to merge, or an approval to grant. The oversight is identical. The reading-to-classify is what's gone.

Both front doors are live now — this section originally said neither was

When this was first published, /triage was an HTTP endpoint behind a deny-all NetworkPolicy with nothing wired to it, and every request in the post had been posted by hand. That's no longer true, and the fix for it is worth more than the feature.

Both front doors dial out. Slack runs in Socket Mode over an outbound WebSocket; Grafana alerts are polled rather than received. Neither needs an ingress, a public hostname or an inbound firewall hole — which is what made them deployable on a tailnet-only estate at all. The NetworkPolicy still admits exactly one thing: the metrics scraper, to a port that serves nothing but /metrics.

There is now also an optional webhook for Grafana, for estates that would rather push. It needs a public endpoint, so it mounts on a separate listener with its own token and one route — never on the port that carries /approvals.

Humans move from routers to approvers

The job becomes reviewing what the agents did (tier 2), approving what they want to do (tier 3), and merging what they drafted (tier 4). That's a better use of an on-call engineer than working out whether a message is an incident.


How it works: a bridge hosts, Holmes investigates, policy gates

Three moving parts, each doing one job.

The bridge is the orchestrator. It's a small Express service speaking MCP through the official SDK, deployed by Helm as one Deployment, and it's the single place agents meet tools — every MCP server the platform uses is registered in its .mcp.json. I started on an off-the-shelf agent runtime and dropped it: once the policy gate has to sit in the tool-call path, and every backend is already an MCP server, the runtime was carrying framework I didn't use. What's left is about 500 lines. The topology is one JSON file:

// swarm.config.json — agents and wiring, rendered into the chart
{
  "topology": "hierarchical",
  "maxAgents": 4,
  "agents": [
    { "id": "triage-router", "role": "coordinator" },
    { "id": "sre-investigator", "role": "specialist", "access": "read-only" },
    { "id": "itsm-support", "role": "specialist" }
  ]
}

maxAgents: 4 with three defined is headroom for a fourth specialist — access requests are the obvious candidate.

HolmesGPT stays outside the bridge. It ships its own Helm chart and releases on its own cadence; sre-investigator points at its service endpoint over in-cluster DNS. A NetworkPolicy enforces the shape: agents reach the bridge, the bridge reaches Holmes and the MCP servers, nothing else reaches any of them. Don't fold a working tool into your runtime just because you can.

Backends are config, not code. The ITSM system is a <name>.mcp.json plus an action-mapping file, selected by itsmProvider in the chart's values — Freshservice today, via the community MCP server run under uvx, swappable without touching an agent. A Jira Service Management provider sits alongside it in the repo; switching is a one-line values change, and I'll come back to why you shouldn't trust either one until you've run it.

Two repos: platform-agent-stack holds the agents, the policy, the backends and the bridge that enforces them, and holmesgpt-runbook-mcp is the existing runbook server. The bridge lives in the same repo as the policy on purpose — a tier change and the mapping change it depends on are one commit and one review.

Why the bridge matters

One choke point where every agent meets every tool means one place to enforce policy, one place to audit, and one .mcp.json to review when someone asks "what can these agents actually do?" Scatter the tool access and you scatter the answers.


Inside the bridge

It's worth slowing down on the bridge, because it's the piece doing the unglamorous work. It's one Deployment with three jobs: it hosts the agents that live in-process (triage-router and itsm-support run inside it; sre-investigator calls out to Holmes), it terminates MCP — every tool call from every agent goes out through it, using credentials only the bridge holds — and it enforces the policy, because a gate you can bypass isn't a gate. The Helm chart is small and honest about this: a Deployment, a Service, and a NetworkPolicy that pins who may talk to whom.

Here's one request traced end to end. Someone posts "why is ticket #4312 stuck?" to /triage:

  1. triage-router classifies it with a single LLM call: not an infra incident — ITSM. It hands off to itsm-support in-process.
  2. itsm-support gets one completion, with a tool schema per verb the action mapping actually maps, and picks zero or one of them. Say it picks add_ticket_comment.
  3. The bridge resolves the generic verb through action-mappings/freshservice.yaml to create_ticket_note, the real tool name that server exposes.
  4. Policy check: add_ticket_comment is tier 2 — execute, then notify. Had the agent picked create_ticket, the call would park at tier 3 with an approval id and execute nothing; a verb the policy has never seen would land at tier 4 as a draft.
  5. The bridge makes the MCP call, the note lands on ticket #4312, and the audit trail wrote itself.

The agent never saw a credential, never called Freshservice directly, and never got to skip step 4. That's the shape worth copying even if you swap every other component.

Agents hold zero credentials

Every secret — ITSM API key, LLM key — lives with the bridge, not the agents. An agent that goes off the rails can only misbehave through the gated path, which is exactly where the tiers are waiting.

One completion, one action

itsm-support doesn't loop. It gets a single completion, calls at most one tool, and returns — it can't call a tool, read the result and decide to call another. That keeps the blast radius of a bad turn small and the trace easy to read, but it also means a request needing two actions gets one. Worth knowing before you point it at a queue and expect a conversation.


Why it's safe to turn on

The reason most teams haven't pointed an agent at their ticket queue isn't capability — it's trust. The whole trust story here is one YAML file, risk-tiers.yaml, shared by both specialists. The idea: every action an agent can take is a named verb, every verb belongs to a tier, and the tier — not the model — decides what happens next. Tier goes up as blast radius goes up, and nothing above tier 2 runs unattended.

Tier What happens Example verbs
1 — auto Executes unattended. Read-only or fully reversible. search_tickets, runbook_search, root_cause_analyse
2 — notify Executes immediately, posts a notification for visibility. add_ticket_comment, update_ticket_status, runbook_draft
3 — approval Waits for explicit human sign-off before executing. create_ticket, close_ticket, assign_ticket
4 — draft-only Never executes. The agent drafts; a human performs. grant_access, revoke_access, modify_production_infra

The tier boundaries follow reversibility, not effort. A ticket comment is tier 2 because the worst case is an awkward comment you delete. Closing a ticket is tier 3 because it ends a conversation someone else may still need. Access and identity changes are tier 4 no matter how routine they feel — that's the lane where "the agent got it wrong" becomes an incident of its own. And runbook_draft shows the pattern's flexibility: it's a write, but it lands at tier 2 because the thing it writes is a draft PR that's inert until merged. The gate matches what the action can break, not what it technically does.

Enforcement sits outside the model. Both specialists point at the same policy file via actionPolicy in the swarm config, and the bridge checks every tool call against it before anything executes — the agent doesn't get to reason its way past a tier, because the tier check isn't in its context to reason about.

# risk-tiers.yaml — the default is the whole point
default_policy:
  unlisted_action: tier_4_draft_only
  on_unlisted: warn   # log the verb so it can be tiered properly

risk_tiers:
  tier_1_auto:
    actions: [search_tickets, get_ticket, list_kb_articles,
              runbook_search, root_cause_analyse]
  tier_2_notify:
    actions: [add_ticket_comment, update_ticket_status, runbook_draft]
  tier_3_approval:
    actions: [create_ticket, close_ticket, assign_ticket]
  tier_4_draft_only:
    actions: [grant_access, revoke_access, modify_identity,
              modify_production_infra]

The default is the load-bearing line. Enumerating four tiers says nothing about the verb you didn't list, and tool lists grow — every new MCP server brings verbs your policy has never seen. Anything unlisted lands at draft-only and gets logged by name until a human tiers it. So the rollout path is genuinely incremental: day one, enable tier 1 and the agents can only read and diagnose. Watch the notification trail for a week. Promote ticket comments to tier 2 when you trust them. The blast radius at every step is exactly what you've explicitly granted, not whatever the newest tool happened to expose.

One gotcha deserves respect: policy verbs map to each backend's real tool names via a mapping file, and a wrong mapping silently ungates an action. The chart refuses to render on a missing mapping and the bundled validation script warns on untiered verbs; neither can catch a verb mapped to the wrong tool. Get tool names from the running server's tools/list, never from an example.

Freshservice makes the point sharper than Jira did. Its MCP server has no dedicated status-transition tool and no dedicated assignment tool — status, priority, assignee and every other field all go through one general-purpose update_ticket. Map update_ticket_status (tier 2) to it and you have also granted close_ticket and assign_ticket (both tier 3), plus free-form editing of every field on the ticket. A tier-2 auto-execute would quietly be doing tier-3 work. So all three stay unmapped, and a human makes those changes in Freshservice directly. Unmapped means it cannot execute at all, which is the safe state.

I covered why these gates belong outside the model's context in production agent security — this file is that argument as YAML.


What the choke point earns you

Running this against a real Freshservice instance turned up four failures. Every one of them presented as HTTP 200. And every one was findable in a single file, because there is exactly one place where an agent meets a tool.

That's the argument for the architecture, so it's worth being concrete about it.

A backend can lie about failure, and the gate can catch it. This server handles its own errors and returns them as successful results — isError: false, with Error: Either email or requester_id must be provided sitting in the text. Scatter your tool access across three agents and you'd need that check in three places and would fix it in one. Here the detection is declared per provider, in the provider file, and applies to every call any agent ever makes. It's deliberately narrow, firing only when the whole result is a bare string: a false positive would report a completed action as failed and invite a retry that does it twice.

A rejected call can no longer masquerade as a success. MCP reports tool failure inside the result rather than by throwing, so returning it verbatim made rejection indistinguishable from completion. One change in the executor now turns that into a real error, and because every call routes through the executor, it holds for all four agents, both lanes, and any backend you swap in tomorrow.

A human's approval survives a failed attempt. The approval record used to be deleted before the backend call was attempted, so a rejection consumed the sign-off and left nothing to retry. Now it executes first and only clears on success — a failed approval stays in the queue with a 502 and the reason attached. That behaviour is a property of the gate, not of any agent.

Verifying a mapping means calling it, not spelling it. The mapping file translates policy verbs to a backend's real tool names, so I checked all six against a live tools/list. Every name resolved. Three then failed every call, because that server annotates those tools as returning a string while returning a dictionary. That's now a script in the repo and a rule: call each mapped verb once against the real backend. Because verbs are declared in one mapping file rather than scattered through agent code, checking all of them is one command.

This is the class of failure that kills ungated agent deployments

An agent that crashes is easy — stack trace, pod restart, someone notices. An agent whose call was rejected, whose approval was consumed, and whose caller was told everything worked will keep doing that quietly for as long as nobody reads the ticket queue by hand. A stack with tool calls spread across agents has no single place to fix that. This one had four bugs of exactly that shape and closed all four in one file each.


The whole thing, end to end, on a real queue

Since writing the above I moved the ITSM backend from Freshservice to Jira, added a way to say no, and ran the lane properly. Here is the actual trace — one sentence in, one ticket out, nine steps, nothing elided.

STEP 1  POST /triage         -> 202, job e0274c71
STEP 2  classified           -> itsm_ticket   9.3s
STEP 3  policy               -> verb=create_ticket  tier=tier_3_approval
                                action=park  executed=false
STEP 4  approver sees        -> project=DO  type=Bug  site=14b1a00e..
        summary             -> "Build agent on kubenode-16 out of disk space,
                                pipelines failing"
STEP 5  approve, no token    -> HTTP 401
STEP 6  approve, no actor    -> HTTP 400
STEP 7  approve, valid       -> HTTP 200   CREATED DO-5
STEP 8  read back from Jira  -> DO-5 [Bug] To Do  reporter=platform-agent
STEP 9  audit trail          -> EXECUTED create_ticket -> createJiraIssue
                                project=DO  by=surj@polarpoint.io

The input was: "Please raise a ticket for surj@polarpoint.io: the build agent on kubenode-16 has run out of disk and pipelines are failing. Started about an hour ago."

Steps 5 and 6 are the interesting ones. The approval endpoint used to take no credentials at all — a UUID was the only thing between a caller and releasing a parked action, with a deny-all NetworkPolicy doing the actual work. It now needs a bearer token and a named actor, and refuses differently for each so you can tell a misconfiguration from an intruder.

The bug this run found, which is the point of running it

The first attempt at exactly that sentence classified as infra_incident and took 61 seconds to produce nothing useful. HolmesGPT dutifully investigated, then explained that it couldn't file a ticket because the only ITSM toolset it knows about is a disabled ServiceNow reader.

It was right. The infra lane is read-only by design. The classifier had sent an explicit "please raise a ticket" to the one lane structurally incapable of raising tickets, because the old prompt defined that lane as "anything about running infrastructure" — and a build agent out of disk is unambiguously about running infrastructure.

That is not a near miss. A misrouted diagnosis is a slow answer; a misrouted ticket request is a dead end that returns a confident non-answer. The fix is a precedence rule that runs before the topic is considered at all:

If the message explicitly asks for a ticket to be raised, filed, opened, logged or created, classify it itsm_ticket even if the subject is infrastructure. Only that lane can create tickets; the infrastructure lane is read-only, so routing such a request there means nothing happens at all.

Same sentence, after the change: itsm_ticket in 9.3 seconds. Lanes now split on intent — does this person want something investigated, or want something recorded — rather than on subject matter, which both lanes share.

Saying no is a decision too

Tier 3 approve or reject Tier 3 approve or reject

The original build could approve and nothing else. Clearing an action nobody wanted meant deleting it out of MongoDB by hand, which left no evidence a human had considered it and declined — indistinguishable from a record that went missing.

There is now a reject path, a Reject button beside Approve in Slack, and a decisions collection that outlives the pending record:

EXECUTED  create_ticket -> createJiraIssue  project=DO
          by=surj@polarpoint.io  verified=false
REJECTED  create_ticket -> createJiraIssue  project=DO
          by=surj@polarpoint.io  verified=false
          reason="we already have a spare monitor"

Two details worth stealing. Rejecting is gated by the same approver allowlist as approving — otherwise anyone can clear another team's queue, which is a denial of service wearing a helpful hat. And the actor carries verified=false when it arrived over HTTP, because a shared bearer token authorises a call but cannot prove who made it. A Slack identity the platform validated records true. Recording a weak name honestly labelled beats recording nothing, and beats pretending it's strong.

Step 4 exists because approving blind is not approving

Deployment facts like the Jira cloudId and project key are injected by the bridge and override whatever the model produced — a model-supplied cloudId is one a prompt injection can redirect into somebody else's Atlassian tenant. But injection happened at call time, so the parked record showed both as undefined: a human was asked to approve "create a ticket" without being told which project, on which site. They are now shown at park time as well. It stays a preview — the live values are re-applied at execution, so a config change between park and approve can't be staged by a stale record.

The claim I made above, and what it actually cost

I wrote that swapping the ITSM backend is "two files — a <name>.mcp.json and an action mapping. No agent changes, no code." Swapping to Jira took those two files and a bridge fix, because connectAll() only ever called connectStdio(). Every provider so far had been a local process, so the URL branch had never run — a hosted provider silently became connectStdio(undefined). The committed Jira provider had looked configured for months and could never have connected. The abstraction was right; the untested half of it wasn't.

What you actually get

  • Classification stops being a person's job. Every request gets a first pass in seconds, around the clock, instead of waiting for someone to read it and decide which lane it belongs in.
  • Cross-lane tickets stop bouncing. The "app down" ticket that's really a pod eviction gets the diagnosis attached to the ticket, instead of a two-day tour of the wrong queues.
  • Senior engineers spend their interrupt budget on judgement. They review diagnoses and approve actions rather than triaging.
  • Runbook gaps close themselves. Missing coverage becomes a draft PR, so the improvement loop feeds from the whole queue rather than just incidents.
  • Swapping the ITSM backend is two files. A <name>.mcp.json and an action mapping. No agent changes, no policy changes, no code.
  • A rollout you can defend. Read-only on day one, every escalation of autonomy an explicit one-line policy change, every action logged, approved, or drafted — and a failure path loud enough that you find out when it doesn't.

Proven, on a live queue: the ITSM lane end to end against Jira — tier 1 reads, a tier-2 comment, and a tier-3 create that parked, was approved by a named human, and produced DO-5. A second identical request was rejected, and no ticket exists for it. Unmapped and unlisted verbs refuse to execute. The SRE lane investigates the live cluster and, asked about a pod that doesn't exist, says so instead of inventing a diagnosis. Both lanes run on two replicas with the alert poller leader-elected, and every decision a human made is queryable after the fact.


FAQ

Why write the bridge instead of using LangGraph or CrewAI?

I tried an off-the-shelf runtime first. The problem is where the policy gate has to sit: in the tool-call path, on every call, with no way for the agent to route around it. Once you've built that, and every backend is already an MCP server reached through the official SDK, the framework underneath is doing very little you still need. What's left is one Express service of about 500 lines that you can read in a sitting — which matters a lot when it's the thing standing between an agent and your ticket queue. If your agents are Python code first and infra second, LangGraph answers a different question.

Does this replace my service desk?

No — it removes the routing and first-response toil, not the humans. Tier 3 and tier 4 actions exist precisely because ticket lifecycle decisions and anything touching access or identity should end at a person. The service desk stops being a switchboard and starts being an approval authority.

Why is Kubernetes remediation not enabled?

The upstream Kubernetes Remediation MCP exists — restart, scale, drain. It's deliberately not wired in. The tiering is already written into risk-tiers.yaml (restart and scale at tier 3, drain at tier 4), so if it's ever enabled the gates are waiting. Write the policy before you enable the capability, not after.

Can I swap Freshservice for Jira Service Management or ServiceNow?

That's the point of the provider layout — a <name>.mcp.json plus a <name>.yaml action mapping, selected by itsmProvider in the chart values. Freshservice and Jira Service Management both ship in the repo. But treat any provider entry you haven't run as a template, not tested config — including the ones in this repo. Connect to your real MCP server, read the live tool list, and write the mapping from that.

Do I need a Slack or Teams bot to use this?

For the conversational lane, yes. For the rest, no — and the cheapest first integration needs no bot at all. An alert source can POST straight to the bridge's Service from inside the cluster: no public endpoint, no signature verification, no bot registration. Same for ticket-driven entry, which most ITSM tools can do with an outbound webhook on create or update. A bot is only genuinely required for the case where a person types a sentence — "why is my ticket stuck?" — and for approving tier-3 actions with a button rather than POSTing a UUID, which is the UX the tiers are obviously asking for.

Whichever you pick, the bridge has no authentication of its own. The NetworkPolicy is the access control. Anything that exposes /triage beyond the cluster has to bring its own auth, or /approvals/:id/approve becomes a public endpoint for releasing gated actions.

Why can't a Slack slash command just call /triage directly?

Because Slack wants an acknowledgement within 3 seconds and this doesn't answer in three seconds. Measured on the running deployment: the ITSM lane takes about 6s, the infra lane about 29s — Holmes genuinely goes and investigates the cluster — and runbook_draft runs an LLM generation then opens a PR, which is why its timeout is set to 180s.

So a chat integration can't be a thin proxy. It has to acknowledge immediately, do the work asynchronously, and post the result back to the thread afterwards. That's a different shape from the synchronous request/response /triage offers today, and it's the actual work in "just add a Slack bot". Teams' Bot Framework has the same constraint.

Why is the first request after a pod restart so slow?

Because the ITSM MCP server is a Python package spawned with uvx, and uvx resolves it fresh on every pod start — it downloads a CPython runtime and about forty packages before serving anything, which takes a minute or so on a cold pod. That also means your ITSM lane has a runtime dependency on PyPI being reachable, which is a strange thing to discover during an incident. Bake the server into the image if that bothers you, as it probably should.

What if the model picks a verb that needs arguments it doesn't have?

It parks or fails, loudly, and that took a fix to be true. The tool schema shown to the model now comes from the backend's own inputSchema rather than a generic shape, so it's at least being asked for the right fields. But nothing validates arguments at park time — a tier-3 action with arguments the backend will reject still parks, still notifies, and still sits in the queue looking legitimate until a human approves it and it fails. Validating against the schema before parking would be better than reporting it after.


Holmes solved diagnosis a while ago. What keeps an ops queue slow was never the investigation — it's the person standing in front of it deciding where each request goes. Take that job off them, keep the decisions that actually need judgement, and the interesting work starts.

And the reason I'd build it this shape again is the boring one. Every tier in that policy file is a promise about what will happen when an agent asks for something. Four times this month a backend broke one of those promises and returned 200 while doing it — and each time the fix was a few lines in the one file every tool call already passes through, applying immediately to every agent, every verb and every backend I might swap in later.

That's what a choke point is for. Not elegance. The ability to make one change and know it holds everywhere, on the day you find out your ticket queue has been quietly doing nothing.

For the investigation side, see alert to root cause with HolmesGPT. For gating agent actions in production, see agent security in production. And for the protocol layer under the bridge, see MCP goes stateless.