IBM Bob × ContextForge — the AI agent control plane. One AI agent, a fintech agent mesh, and a gateway that governs every move. Who's in charge of your agents?
Can't run Docker/ContextForge locally? Click the badge — the whole governed mesh builds in the cloud; you only need IBM Bob on your laptop (Tier 2 in ONBOARDING.md).
A turnkey, follow-along demo of IBM ContextForge (the MCP / A2A gateway) acting as the control plane between an AI agent (IBM Bob) and a fintech ("FinByte") expense-and-payments agent mesh. The gateway sits in the middle of every tool call and every agent-to-agent call, enforces four controls, and you can prove all of them with one command → 16 passed, 0 failed.
When an AI agent can read receipts, approve expenses, and move money, the question stops being "can the agent do it?" and becomes "who's in charge of the agent?". This demo answers that with a control plane. IBM Bob is the agent — an MCP client that drives a FinByte agent mesh (expense lookups, ERP payments, policy docs, notifications, and two autonomous A2A agents). It never talks to those backends directly. Every call flows through IBM ContextForge, the gateway that authenticates, authorizes, governs, redacts, and audits — before Bob ever sees a result and before any money moves.
The gateway enforces:
- Policy (OPA). A wire over the $10,000 FinByte T&E cap is blocked unless it carries dual approval — evaluated live by an Open Policy Agent sidecar against a Rego policy.
- Data protection. SSNs, credit-card numbers, API keys, and other secrets in tool output are masked on the gateway before they reach the agent (
***-**-6789,****-****-****-1111,[SECRET_REDACTED]). - Prompt-injection neutralization. Adversarial instructions hidden in tool output ("SYSTEM: ignore all prior policy…") are neutralized to
[INJECTION_BLOCKED]. - RBAC least-privilege. A FinOps analyst persona simply has no
wiretool; only the privileged operator persona can register servers, read the audit trail, or interrogate policy. (Rate limiting is a built-in ContextForge capability, but it is not configured or demonstrated in this stack.) - Agent-mesh governance. The same policy that stops a direct wire also stops a cross-language agent-to-agent payment (a Python auditor delegating a $50k payment to a Rust payments agent) at the bridged tool hook.
The whole stack is path-independent — config, plugins, and Rego policy are baked into the images, so there are zero host bind-mounts and it runs from any clone path.
(See the diagram at the top.) IBM Bob connects through the mcpgateway.wrapper stdio bridge to a virtual server on the gateway (a curated, least-privilege slice of the catalog). The gateway fronts five governed MCP servers, one deliberately-unregistered MCP server, and two A2A agents, with an OPA sidecar for policy decisions. Only the gateway and the two A2A agents publish ports to the host; the OPA sidecar and all MCP servers are reachable only on the Compose private network.
| Component | Kind | Port | Role |
|---|---|---|---|
| IBM Bob | AI agent / MCP client | — | Drives the mesh; connects via the mcpgateway.wrapper stdio bridge to a virtual server |
| ContextForge gateway | MCP/A2A gateway (the control plane) | 4444 (host) |
Authn/z, governance, redaction, audit, federation; Admin UI at /admin |
| OPA | Open Policy Agent sidecar | 8181 (internal) |
Evaluates the Rego wire-amount policy (package mcpgateway) |
| expense-db | MCP server (Python, FastMCP) | 8000 (internal) |
list_pending_expenses, get_expense, get_receipt (holds the PII / injection fixtures) |
| erp-payments | MCP server (Python, FastMCP) | 8000 (internal) |
approve, reimburse, wire (the governed money path) |
| policy-docs | MCP server (Python, FastMCP) | 8000 (internal) |
get_policy, wire_limit |
| notify | MCP server (Python, FastMCP) | 8000 (internal) |
notify |
| controlplane | MCP server (Python, FastMCP) | 8000 (internal) |
Operator surface: register_mcp_server, list_control_plane, recent_blocks, evaluate_policy |
| fx-rates | MCP server (Python, FastMCP) | 8000 (internal) |
get_fx_rate, list_currencies — runs but is intentionally unregistered (for the live-register beat) |
| auditor | A2A agent (Python, a2a-sdk) |
9001 (host) |
Audits expenses; can delegate a payment to the Rust agent |
| payments | A2A agent (Rust, a2a-lf / a2a-server-lf) |
3000 (host) |
Executes payments; JSON-RPC at /jsonrpc, agent card at /.well-known/agent-card.json |
The seed registers 5 governed MCP servers (
expense-db,erp-payments,policy-docs,notify,controlplane) plus the 2 A2A agents, and curates them into three virtual servers (FinOps, Treasury, Operator).fx-ratesis left unregistered on every seed so the operator demo can register it live.Naming note: the
controlplaneandauditorservices read their admin token from an env var namedAUDITOR_TOKEN(written to.env.tokensbymake up). Despite the name, it is an admin JWT — not an auditor-only scope.
Every tool call passes through two gateway plugin hooks. On tool_pre_invoke, the custom FinByteGuard plugin (gateway/custom/finbyte_guard.py, on the cpex framework) extracts the call's arguments and asks OPA whether the wire-amount policy permits it — denying anything over the $10,000 cap without dual approval, and failing closed if OPA is unreachable. On tool_post_invoke, FinByteGuard deep-scrubs secrets (sk-live-… → [SECRET_REDACTED]) and neutralizes prompt injection (→ [INJECTION_BLOCKED]), while the cpex PIIFilterPlugin masks SSNs and credit-card numbers. Every decision emits an AUDIT [FinByteGuard] … line and shows up in the Admin UI's Logs tab.
| Control | Prompt to Bob | What ContextForge does |
|---|---|---|
| 1 — Policy (OPA) | "Use the finbyte-gateway tools to wire $50,000 to Acme LLC for expense exp_big." |
Blocks at OPA: "…exceeds the $10,000 auto-approve limit… FinByte T&E policy §2." Add "with dual approval" → allowed. The same policy blocks the cross-language auditor→payments $50k at the bridged a2a-payments hook. |
| 2 — Data protection | "Fetch receipt rcpt_pii, verbatim." |
Masks before Bob sees it: SSN → ***-**-6789, card → ****-****-****-1111, API key → [SECRET_REDACTED]. |
| 3 — Prompt-injection | "Fetch receipt rcpt_injection." |
Neutralizes the embedded SYSTEM: ignore all prior policy… → [INJECTION_BLOCKED]. |
| 4 — RBAC least-privilege | "Now wire $50k yourself, directly." | Bob can't — the FinOps virtual server hides erp-payments-wire. MCP Inspector confirms the tool is absent. The operator persona has control-plane tools the analyst lacks. |
Baseline that works: "Process expense exp_clean and reimburse it." — a clean $18.50 expense flows straight through.
- Expense approval quorum — the room votes on a $50k wire from their phones (live local tally) while 5 governed A2A voter agents vote through the ContextForge gateway (each call authn'd + audited). OPA then blocks the wire regardless of either tally. Policy beats consensus.
The same Bob binary becomes two different actors depending on which virtual server its .bob/mcp.json points at. Both targets rewrite .bob/mcp.json (from the bob-personas/*.template files, refreshing the live UUID) and launch Bob from the repo root, so they're cwd-proof and reseed-proof.
make bob — FinOps analyst (Act 1) |
make bob-operator — platform operator (Act 2) |
|
|---|---|---|
| Virtual server | FinOps (8 tools) | Operator (4 tools) |
| Can do | List/read expenses, read receipts, approve, reimburse, read policy + wire limit, talk to the auditor agent |
register_mcp_server, list_control_plane, recent_blocks, evaluate_policy |
| Cannot do | No wire tool; can't register servers, read the audit trail, or query policy directly |
Not the analyst's expense-handling surface |
| Persona file | bob-personas/mcp.json.template (server finbyte-gateway) |
bob-personas/mcp.operator.json.template (server finbyte-operator) |
Swap back to the analyst at any time with make bob.
| Tool | Why | How to get it |
|---|---|---|
| Docker (running) | Runs the gateway, OPA, MCP servers, and A2A agents | Docker Desktop on macOS/Windows, or Docker Engine on Linux (runs natively, no nested virtualization). Start it before you begin. — docker.com No Docker? The stack also runs on Podman — see Run on Podman, or one-shot a fresh Ubuntu/WSL2/x86 host with bash scripts/test-fresh-host.sh. |
| uv | Mints the gateway JWT offline (no network round-trip) | https://docs.astral.sh/uv/ |
IBM Bob Shell (bob) |
Optional — only to drive Bob; the stack + 16/16 proof run without it |
macOS/Linux: curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash (bob.ibm.com/download) — checks Node ≥ 22.15 first |
| Node.js ≥ 22.15 | Optional — required by IBM Bob Shell (it's a Node app) and the MCP Inspector (npx); not needed to bring up the stack or prove the controls |
nodejs.org, or nvm install 22 |
Budget ~5 GB of free disk. On the first run, the pinned ContextForge image pulls once and the seven source images (six MCP servers + the Rust payments agent) build locally. Subsequent cold starts (
make down && make quickstart) take roughly ~38 seconds once images are cached.
Running on Linux / in a VM. Only Docker and uv are truly required to bring up the stack and prove
16/16—bob/Node are needed only to drive the demo. On Apple silicon a full macOS-guest VM is impractical (~60 GB+ disk); the practical path is a lightweight Linux VM (Multipass/Lima) + Docker Engine, which runs the stack natively on arm64 (the OPA image is multi-arch, so no emulation). IBM Bob Shell is cross-platform and can also be installed in the VM to drive the demo (first run uses an IBMid device-code login on a headless box). See Running on a fresh Linux box / VM below.
Same stack, two stories — pick your path:
🎓 Progressive build — build an agent tool from scratch, then watch it get governed (developer path)
Bottom-up, the inverse of make quickstart: start from a bare MCP server you'd recognise anywhere and earn ContextForge one layer at a time — carrying the tool you built the whole way. Open the prompt-card and drive Bob through four stages:
make dev-start # opens docs/build.html — the follow-along build guide (copy-paste Bob prompts)| Stage | Command | What you (and Bob) do | Bob persona |
|---|---|---|---|
| ① Build | make stage1-build |
Prompt Bob to write mcp-servers/sales-tax/server.py from scratch (a FastMCP add_tax tool). It runs bare on :8000; a call prints add_tax(100) → 108.50. It works — and it's totally ungoverned: no token, no policy, anyone on the port runs anything. |
— |
| ② Govern | make stage2-govern |
The same server is containerised onto the mesh, registered (in the catalog, token-gated — not callable yet), then granted into a Builder virtual server and called back through the gateway → 108.50. 2b bonus: Bob extends a service it didn't write (fx-rates gains convert). |
operator → builder |
| ③ Control | make stage3-controls |
One prompt drives a batch; the four controls bite real calls — PII redaction, injection neutralised, OPA blocks a $50k cross-language wire, RBAC (Bob has no wire tool). |
analyst |
| ④ Mesh | make stage4-mesh |
The full governed picture — identical to the quickstart end-state, but you watched it get built. |
— |
The throughline is register → grant → call. Your tool goes from works-but-ungoverned → in the catalog but not callable → granted and callable through the one governed seam, with redaction, policy, and audit applied. Registering a backend doesn't make it callable — granting it to an agent is a separate, privileged step (make salestax-grant adds it to the Builder vserver). That boundary is least-privilege.
The builder persona (make bob-install-builder) is the developer's seat — it calls your own granted tools (add_tax, convert), alongside the existing analyst (least-privilege consumer) and operator (registers / audits / evaluates policy) personas.
No Docker on your laptop? make connect prints a bob mcp add … -t http line pointed at a gateway running elsewhere — a teammate's box, a VM, or a GitHub Codespace — so you drive the whole governed mesh with only Bob installed, governance intact over the wire.
→ Narrated beats: docs/SHOWCASE-BOB.md · the follow-along docs/build.html (presenter cockpit: docs/cockpit.html) · zero-setup tiers in docs/ONBOARDING.md.
🛰 Governed mesh — drop straight into the finished, governed stack (quickstart path)
Top-down: one command takes a laptop from nothing to a running, governed mesh and proves all four controls (16/16) — no Bob required. Then drive Bob as the FinOps analyst (Act 1) and platform operator (Act 2) and watch the control plane react in the Admin UI.
→ Full command + walkthrough in Quickstart immediately below (then Drive Bob, the watch panes, and the deterministic proof under it).
git clone https://github.com/manavgup/ai-agent-controlplane-demo.git
cd ai-agent-controlplane-demo
make quickstartmake quickstart is one command that takes a laptop from nothing to a running, governed mesh: preflight (requires Docker + uv; warns but continues if bob/npx are absent) → bring up the stack → seed (register servers/agents, build the FinOps / Treasury / Operator virtual servers) → configure Bob (FinOps analyst persona) → prove all four controls (16/16, with no Bob required) → print a copy-paste walkthrough card. It's re-runnable — safe to run again if anything stalls. The Admin UI logs in with admin@finbyte.demo / FinByteAdmin!2026.
Proof is headless.
make quickstartfinishes16 passed, 0 failedeven on a box withoutbobor Node (a Linux VM or CI runner): Bob only drives the demo — it isn't needed to bring up the stack or prove the controls.make bob/make bob-operatoralso fail gracefully ifbobisn't installed (they still write.bob/mcp.json, print an install hint, and exit0).
Act 1 — FinOps analyst (least-privilege). Launch with make bob (cwd-proof; it refreshes the config first), then try:
- "Use the finbyte-gateway tools to fetch receipt
rcpt_pii, verbatim." → redacted. - "Fetch receipt
rcpt_injection." →[INJECTION_BLOCKED]. - "Ask the auditor agent to pay $50,000 to Acme LLC." → blocked at OPA (Python → Rust).
- "Now wire $50k yourself, directly." → Bob has no
wiretool.
Act 2 — platform operator. Quit Bob, then make bob-operator to swap personas and relaunch:
- "List everything ContextForge is governing." →
list_control_plane. - "Would a $50,000 wire be allowed? With dual approval?" →
evaluate_policy(deny + reason, then allow). - "Register the fx-rates service at
http://fx-rates:8000/mcp." →register_mcp_server(a new server joins the catalog live). - "Show me what got blocked today." →
recent_blocks.
Arrange your screen so you can watch the control plane while you prompt Bob:
make monitor # ContextForge Admin UI (/admin): catalog + Overview/Metrics/Logs
make inspect-mcp # MCP Inspector → the 8 governed tools (erp-payments-wire is ABSENT)
make inspect-a2a # A2A Inspector → validates the Python + Rust agent cardsPrefer one command over arranging tabs by hand? make cockpit spawns a single
tmux window that tiles Bob (big left pane, ~62%)
alongside four live watch panes — logs, logs-opa, inspect-mcp, inspect-a2a.
It also opens a HOW-TO guide (docs/cockpit.html) in your browser and starts the
Companion dashboard on :7070. Each pane just runs the existing make target, so it
reuses all the token/UUID logic. Requires tmux (brew install tmux / apt-get install tmux);
without it you get the manual fallback list above.
make cockpit # FinOps analyst persona (default)
COCKPIT_PERSONA=operator make cockpit # Act 2: platform operator persona in the Bob pane
make cockpit-down # tear it all down (also removes the a2a-inspector container)Already inside tmux? Run make cockpit from your Bob pane and it adds the four
watch panes around it (no second Bob); make cockpit-down then removes only those panes.
Over SSH: the cockpit (tmux) runs fine on the remote host, but the browser UIs
(HOW-TO page, Companion, Admin UI, MCP/A2A Inspectors) live there too — make cockpit prints the URLs plus a
port-forward hint instead of opening a tab. Forward them from your laptop:
ssh -L 4444:localhost:4444 -L 7070:localhost:7070 -L 6274:localhost:6274 -L 6277:localhost:6277 -L 8090:localhost:8090 <host>From nothing to 16/16 on a clean Linux box (or a lightweight Multipass/Lima VM on Apple silicon — the practical path, since a macOS-guest VM needs ~60 GB+ disk):
# 1. Install the runtime + build deps (Docker Engine runs natively, no nested virtualization):
# - Docker Engine https://docs.docker.com/engine/install/
# - Node.js ≥ 22.15 nvm install 22 (only needed to DRIVE Bob)
# - uv https://docs.astral.sh/uv/
# - git + make your distro's package manager
# 2. Clone + bring it all up → 16 passed, 0 failed (no Bob required):
git clone https://github.com/manavgup/ai-agent-controlplane-demo.git
cd ai-agent-controlplane-demo
make quickstart
# 3. To DRIVE the demo, install IBM Bob Shell (cross-platform; checks Node ≥ 22.15):
curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash
make bob # first run prompts an IBMid device-code login on a headless boxBuild-time DNS gotcha (fresh Docker Engine). If
make quickstartfails during an image build withCould not resolve host: index.crates.io(cargo/pip inside aRUNstep) even though image pulls succeed, the build container is using the host's deadsystemd-resolved127.0.0.53stub. Fix it once and re-run (quickstart is idempotent):echo '{"dns":["8.8.8.8","1.1.1.1"]}' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker make quickstart
The headless proof is one command:
make verify-controls # → RESULT: 16 passed, 0 failedIt runs scripts/money-shots/run-all.sh, which asserts all four controls plus the cross-language agent-mesh block and the Rust agent successfully executing a within-policy payment — 16 assertions in total. This is the safety net: run it any time to confirm the stack is honest.
To watch the controls fire live as you prompt Bob, see docs/LOG-CHEATSHEET.md — the exact prompt → log-line map (including which beat is supposed to log nothing) — and tail:
make logs # raw gateway logs (blocked calls surface as ERROR 'invocation failed')
make logs-opa # live, readable OPA decisions: ALLOW/DENY + args + reasonai-agent-controlplane-demo/
├── a2a-agents/ # the 2 A2A agents: auditor/ (Python, a2a-sdk) + payments/ (Rust, a2a-lf)
├── bob-personas/ # mcp.json templates for the analyst + operator + builder personas (live-UUID rewrite)
├── companion/ # optional browser dashboard (Flask, :7070) to watch the control plane
├── gateway/ # ContextForge image + OPA image: custom/ plugin, plugins/ config, policies/ Rego, seed/
├── mcp-servers/ # the 6 MCP servers: expense-db, erp-payments, policy-docs, notify, controlplane, fx-rates
│ # + sales-tax/ — the Dev Day "Bob builds it from scratch" server (generated; Stage 1→2)
├── scripts/ # quickstart.sh, demo.sh, watch-decisions.sh, money-shots/ proof suite
├── slides/ # the conference talk deck (.pptx) + outline
└── docs/ # QUICKSTART/RUNBOOK/LOG-CHEATSHEET/SCENARIOS/SHOWCASE-BOB + diagrams/
- Virtual servers. The gateway exposes curated, least-privilege slices of the full tool catalog: FinOps (8 tools, no wire — the analyst), Treasury (the wire path:
wire,reimburse,a2a_payments), and Operator (4 control-plane tools). RBAC is enforced by which virtual server a persona points at, not by the token identity. - The stdio bridge. Bob connects through
uvx --from mcp-contextforge-gateway python -m mcpgateway.wrapper, withMCP_SERVER_URL=http://localhost:4444/servers/<UUID>/sseandMCP_AUTH=Bearer <admin JWT>. Bob reads.bob/mcp.jsonfrom the repo root — which is why you always launch viamake bob/make bob-operator. - Path-independence. The gateway config, FinByteGuard plugin, and Rego policy are baked into the images (
gateway/Dockerfile,gateway/Dockerfile.opa). Zero host bind-mounts means the stack runs from any clone path — even ones Docker can't share, like/tmpon macOS.
- Attendee walkthrough:
QUICKSTART.md— the 3-pane, follow-along guide. - Presenter run order + recovery:
docs/RUNBOOK.md. - Anything drifts /
16/16fails:make demo-reset(recreate + reseed the gateway), thenmake verify-controls. - Footgun — "No MCP servers configured": you launched
bobfrom the wrong directory (often thebob-personas/subfolder). Bob looks for.bob/mcp.jsonrelative to its cwd. Quit it and runmake bob, which launches from the repo root and refreshes the live UUID after a reseed. - Fresh Linux VM —
Could not resolve host …during amake quickstartimage build (e.g.index.crates.io, while image pulls succeed): the Docker build container is using the host's deadsystemd-resolved127.0.0.53stub. Fix → write/etc/docker/daemon.jsonwith{"dns":["8.8.8.8","1.1.1.1"]},sudo systemctl restart docker, then re-runmake quickstart(idempotent). See Running on a fresh Linux box / VM.
This is a demo, not production. The JWTs are signed with a public demo secret (demo-only-change-me-…), CSRF is disabled for the local HTTP demo, and SSRF guards are loosened for the Compose private network. Do not reuse any of this configuration outside the demo. For production, mint your own secrets, enable HTTPS + CSRF, and tighten SSRF — see the upstream IBM ContextForge project.
Released under the MIT License — see LICENSE. The demo-only caveats above still apply: the bundled secrets and passwords are intentionally public and must not be reused in production.