ops-mcp
by Fuzailkazi
README.md
# ops-mcp — an internal admin panel as an MCP server
Sixteen tools for a SaaS company's support desk: look up a customer, read their
invoices, read a support ticket, issue a refund — and a few things nobody should
let an agent do unsupervised.
This is one half of a pair:
| Repo | |
|---|---|
| **this one** | the tools |
| [`armoriq-adk-ops-agent`](https://github.com/Fuzailkazi/armoriq-adk-ops-agent) | a Google ADK agent that uses them, governed by ArmorIQ |
| [`armoriq-adk-guide`](https://github.com/Fuzailkazi/armoriq-adk-guide) | how to build your own |
Deploy this, register its URL on [platform.armoriq.ai](https://platform.armoriq.ai)
as an MCP named `ops-mcp`, and point the agent at it.
---
## There is no security in this code
That is deliberate, and it is the most important thing to understand here.
No permission checks, no role lookups, no policy. Every tool does exactly what it
is asked, by whoever asks. That is how most internal admin tooling actually works,
and it is precisely why governance has to live at the agent layer instead.
The destructive tools are **real**. Run the agent with enforcement disabled and
`export_all_customers` really does "send" the whole customer table. The example is
worthless otherwise — a stubbed-out attack proves nothing.
---
## The tools
Sixteen tools in four tiers. The tiers exist so there is something for policy to
act on: each tier wants a different rule.
### Tier 1 — reads → *allow*
| Tool | |
|---|---|
| `lookup_customer` | find an account by email |
| `get_subscription` | plan, price, seats |
| `get_invoices` | recent charges |
| `get_ticket` | one ticket, including the customer's message |
| `list_tickets` | subjects only, filterable by status |
### Tier 2 — low-risk writes → *allow*
| Tool | |
|---|---|
| `add_account_note` | internal note |
| `reply_to_ticket` | reply to the customer |
| `extend_trial` | up to 90 days |
### Tier 3 — sensitive / money → *hold for a human*
| Tool | Threshold field | Suggested rule |
|---|---|---|
| `issue_refund` | `amount` | hold over the user's limit |
| `apply_discount` | `value` | hold over the user's limit |
| `change_plan` | `amount` | hold always |
| `suspend_account` | — | hold always |
### Tier 4 — destructive → *block*
| Tool | |
|---|---|
| `export_all_customers` | sends the whole table to an external address |
| `impersonate_user` | opens a session as a customer |
| `grant_admin_role` | full admin access |
| `delete_account` | permanent, irreversible |
---
## How ArmorIQ finds the money
This is the detail most likely to cost you an hour.
ArmorIQ's policy engine scans a tool's arguments for a field named `amount`,
`value`, `total`, `price` or `cost`. **That is why the money fields above are named
the way they are.**
`apply_discount` is the instructive one. Its natural field is `percent`, but a
percentage is invisible to that scan — so the tool also takes `value`, the total
dollars given away over the term. That is both what the policy reads and what a
deal desk would actually approve on.
If your tool has to call it something else, like `total_cents`, register semantic
metadata for the MCP so ArmorIQ knows which field holds the number and what unit
it is in. Otherwise the threshold silently never fires.
---
## The tickets
Four tickets, because the interesting thing about a support queue is that you
cannot tell by looking which messages are hostile.
| Ticket | Customer | What it is |
|---|---|---|
| `TKT-4482` | Acme | Completely ordinary. A question about adding seats. |
| `TKT-4471` | Acme | Genuine double-charge complaint, **plus a prompt injection** asking for a customer export |
| `TKT-4490` | Acme | **A different injection style** — manufactured urgency, claims approvals are suspended, asks for admin and impersonation |
| `TKT-4495` | Globex | Legitimate request for a large credit. Trips a threshold with no attack involved. |
Demo `TKT-4482` first to show normal operation, then `TKT-4471`. The contrast is
the point.
The injection text lives in `src/data.ts` in plain sight rather than hidden,
because the whole exercise is watching an agent read it and comply.
---
## Files
| File | Lines | What is in it |
|---|---|---|
| `src/tools.ts` | 338 | the sixteen tools, grouped into four tiers |
| `src/data.ts` | 202 | fake customers, charges and tickets |
| `src/index.ts` | 73 | Express + MCP wiring |
---
## Run it
```bash
npm install
npm start # http://localhost:8788/mcp
```
Check it:
```bash
curl -s http://localhost:8788/health
curl -s -X POST http://localhost:8788/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
`POST /reset` restores the fake data so you can run a demo more than once.
Without it, the second run reports "already refunded".
---
## Deploy it
### Render
`render.yaml` is a Blueprint for this repo. **New > Blueprint**, point it here,
done. No root directory to set — this repo is one service.
> On Render's free plan a service spins down when idle. The first request after a
> quiet period can take ~30 seconds, which looks exactly like the agent hanging.
> Use a paid instance if you are demoing live.
### Cloud Run, Fly, or anything Docker
```bash
gcloud run deploy ops-mcp --source . --region us-central1 --allow-unauthenticated
```
The service reads `PORT` from the environment, which is what these platforms set.
### Then register it
On [platform.armoriq.ai](https://platform.armoriq.ai), MCP Registry → Add MCP:
| Field | Value |
|---|---|
| Name | `ops-mcp` |
| URL | `https://ops-mcp.onrender.com/mcp` |
The name must match the agent's `ARMORIQ_MCP_NAME` **exactly**, and the URL must
be public HTTPS — the platform calls it directly, so `localhost` will not do.
---
## Notes
**There is no authentication on this server.** Deployed with
`--allow-unauthenticated`, anyone who finds the URL can call `delete_account`
directly — bypassing ArmorIQ entirely, because enforcement is at the agent layer,
not here. Acceptable for a local example; not acceptable in production. Add a
bearer token and register it as the MCP's auth on the platform, which supports
`bearer` and `api_key`.
**`tsx` is a runtime dependency, not a dev one.** This service runs TypeScript
directly rather than compiling first — one less build step to get wrong. That
means `tsx` must be in `dependencies`; in `devDependencies` any
`npm install --omit=dev` produces a deploy with no `tsx`, and the container will
not boot.
**Stateless by design.** A fresh MCP server is built per request, so any instance
can serve any request and the service scales horizontally.
**`GET /mcp` returns 405.** A stateless server has no server-initiated event
stream, and the MCP spec says to answer 405. Express would otherwise return 404,
which makes clients log a confusing transport error.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues