MAQPNADocs

Build a support agent with DLP and refund approvals

Build a customer-support agent that reads tickets and issues refunds, redact personal data with a DLP profile, hold refunds over 100 EUR for a human, and prove every step in the audit ledger.

In this tutorial you build a customer-support agent that reads tickets and refunds orders. You run it on your laptop against a local MAQPNA. Three controls protect it: a data loss prevention (DLP) profile redacts email addresses, phone numbers, card numbers and IBANs before the agent sees them, a policy holds every refund over 100 EUR until a person approves it, and the audit ledger records each decision so you can verify the chain afterwards.

What you'll build#

flowchart LR
    subgraph laptop["Your laptop"]
        A["agent.py<br/>support-bot"] -->|"tools/call<br/>session token"| GW["MAQPNA gateway<br/>:8080"]
        GW -->|"/mcp/helpdesk"| H["helpdesk.py<br/>tickets, refunds"]
        H -->|"ticket with PII"| GW
        GW -->|"[REDACTED:email] ..."| A
        GW --> L[("audit ledger<br/>.maqpna/audit.jsonl")]
        P["policies.json"] -.-> GW
        D["dlp-support.json"] -.-> GW
    end
    R["team lead<br/>maqpna approvals"] -->|"approve or deny<br/>refunds > 100 EUR"| GW
Time About 25 minutes
Level Beginner: you can read Python and run shell commands
You learn Adding your own MCP server, DLP profiles on a local MAQPNA, when conditions on arguments, approvals, the audit ledger

Prerequisites#

  • The maqpna CLI (see Install). The first maqpna dev up downloads maqpna-gateway and maqpna-identity.
  • Python 3.10 or later. The tutorial uses only the standard library.
  • Free ports 8080 (gateway), 8081 (identity broker) and 9100 (the helpdesk server).
  • jq, to read the audit ledger.
  • Two terminals open in the same new, empty folder (mkdir support-agent && cd support-agent).
  • New to MAQPNA? Your first governed agent explains the local stack in five minutes.

Step 1: Write the helpdesk MCP server#

The agent needs tools. A Model Context Protocol (MCP) server offers them; the MAQPNA gateway sits in front of it. This one is a small JSON-RPC server over HTTP with three tools: list_tickets, get_ticket and issue_refund. Its tickets contain the personal data a real helpdesk holds.

Create helpdesk.py:

"""helpdesk: a tiny MCP server (JSON-RPC over HTTP POST). Refunds are simulated."""
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

TICKETS = {
    "T-1001": {"id": "T-1001", "order": "A-5521", "amount": 39.90, "customer": "Alice Martin",
               "email": "[email protected]", "phone": "+491701234567",
               "text": "My headphones arrived broken. Card 4111 1111 1111 1111 was charged."},
    "T-1002": {"id": "T-1002", "order": "A-5530", "amount": 249.00, "customer": "Bram de Vries",
               "email": "[email protected]", "phone": "+31612345678",
               "text": "The coffee machine leaks. I want my money back to NL91ABNA0417164300."},
}
TOOLS = [
    {"name": "list_tickets", "description": "List open support tickets.",
     "inputSchema": {"type": "object", "properties": {}}},
    {"name": "get_ticket", "description": "Read one support ticket.",
     "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}},
    {"name": "issue_refund", "description": "Refund an order (amount in EUR).",
     "inputSchema": {"type": "object", "properties": {
         "order": {"type": "string"}, "amount": {"type": "number"}, "note": {"type": "string"}},
         "required": ["order", "amount"]}},
]


def text(s, is_error=False):
    return {"content": [{"type": "text", "text": s}], "isError": is_error}


def call(name, args):
    if name == "list_tickets":
        return text(json.dumps([{"id": t["id"], "order": t["order"]} for t in TICKETS.values()]))
    if name == "get_ticket":
        t = TICKETS.get(args.get("id"))
        return text(json.dumps(t)) if t else text("no such ticket", True)
    if name == "issue_refund":
        return text(f"refunded {args.get('amount')} EUR on order {args.get('order')} (simulated)")
    return None


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        msg = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
        if "id" not in msg:                      # a notification: no response body
            self.send_response(202)
            self.end_headers()
            return
        res, err = None, None
        method = msg.get("method")
        if method == "initialize":
            res = {"protocolVersion": msg.get("params", {}).get("protocolVersion", "2025-06-18"),
                   "capabilities": {"tools": {"listChanged": False}},
                   "serverInfo": {"name": "helpdesk", "version": "0.1.0"}}
        elif method == "tools/list":
            res = {"tools": TOOLS}
        elif method == "tools/call":
            p = msg.get("params", {})
            res = call(p.get("name"), p.get("arguments") or {})
            if res is None:
                err = {"code": -32602, "message": "unknown tool"}
        else:
            err = {"code": -32601, "message": "method not found: " + str(method)}
        out = {"jsonrpc": "2.0", "id": msg["id"]}
        out.update({"error": err} if err else {"result": res})
        body = json.dumps(out).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        pass


if __name__ == "__main__":
    HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "9100"))), Handler).serve_forever()

Start it in the first terminal with python3 helpdesk.py and leave it running.

Check it works#

In the second terminal, ask the server for its tools directly, without the gateway:

curl -s 127.0.0.1:9100/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name'
list_tickets
get_ticket
issue_refund

Step 2: Write the DLP profile#

A DLP profile names the detectors the gateway runs and what it does on a match. maqpna dev up --dlp-profile FILE loads one profile, names it dev and applies it to all traffic.

Create dlp-support.json:

{
  "detectors": ["email", "phone_e164", "pan", "iban"],
  "requestAction": "redact",
  "responseAction": "redact",
  "onSecret": "deny"
}

The detectors find email addresses, E.164 phone numbers, card numbers (checked with Luhn) and IBANs (checked with mod-97); email and phone_e164 are not in the default set, so list them. requestAction covers the arguments the agent sends, responseAction the results it receives. onSecret: deny blocks, instead of redacting, any secret-class detector you add later. A match becomes [REDACTED:<detector>] and the JSON structure stays intact. Names such as "Alice Martin" are not detected: no detector for personal names exists. All detectors and keys are in Protect data with DLP profiles.

Step 3: Write the policy#

The policy says what agent support-bot in namespace support may do on server helpdesk. The local gateway reads policies as bundle JSON. Create policies.json:

{
  "policies": [
    {
      "name": "support-agent",
      "namespace": "support",
      "agents": ["support-bot"],
      "defaultAction": "deny",
      "rules": [
        {"name": "read-tickets", "servers": ["helpdesk"], "tools": ["list_tickets", "get_ticket"],
         "action": "allow", "maxCallsPerMinute": 60},
        {"name": "large-refund-needs-human", "servers": ["helpdesk"], "tools": ["issue_refund"],
         "when": [{"arg": "amount", "op": "gt", "value": 100}],
         "action": "require_approval", "reason": "refunds over 100 EUR need a human"},
        {"name": "small-refund", "servers": ["helpdesk"], "tools": ["issue_refund"],
         "when": [{"arg": "amount", "op": "lte", "value": 100}],
         "action": "allow"}
      ]
    }
  ]
}

The first matching rule wins. when compares the amount argument as a number. A refund without an amount, or with a non-numeric one, matches neither refund rule, so defaultAction: deny applies. Any tool the policy does not name is denied too.

Check it works#

Lint the file and test three refunds offline, before anything runs:

maqpna policy lint policies.json
maqpna policy test --policy policies.json --ns support --agent support-bot \
  --server helpdesk --tool issue_refund --args '{"order":"A-5530","amount":249}' --expect require_approval
maqpna policy test --policy policies.json --ns support --agent support-bot \
  --server helpdesk --tool issue_refund --args '{"order":"A-5521","amount":39.9}' --expect allow
maqpna policy test --policy policies.json --ns support --agent support-bot \
  --server helpdesk --tool issue_refund --args '{"order":"A-5521"}' --expect deny

policy lint prints 1 file(s), 0 error(s), 0 warning(s). Each policy test prints the decision as JSON, with the policy, rule and reason (the first one: rule large-refund-needs-human), and exits 0. A decision other than --expect exits with status 3.

To keep these checks in a test suite and run them in CI, see Policy as code in CI.

Step 4: Start a local MAQPNA#

Start the identity broker and the gateway with your MCP server, policy and DLP profile. --no-echo leaves out the built-in test server, which this tutorial does not need.

maqpna dev up --no-echo \
  --upstream helpdesk=http://127.0.0.1:9100/mcp \
  --policy policies.json \
  --dlp-profile dlp-support.json
local MAQPNA is up (~/support-agent/.maqpna)
  gateway   http://127.0.0.1:8080
  broker    http://127.0.0.1:8081
  mcp       helpdesk -> http://127.0.0.1:9100/mcp
next: maqpna dev run -- <your agent command>

The gateway now serves the helpdesk at http://127.0.0.1:8080/mcp/helpdesk. --policy copies your file to .maqpna/policies.json, which the gateway re-reads every 2 seconds.

Check it works#

Read a ticket through the gateway as the agent would. maqpna dev run mints a session token for agent support-bot in namespace support, and maqpna call makes one tool call with it:

maqpna dev run --agent support-bot --namespace support -- \
  maqpna call --server helpdesk --tool get_ticket --arg id=T-1002
maqpna dev: session dev-e0e140d0 (agent support-bot, namespace support, on behalf of you@localhost)
{
  "content": [
    {
      "type": "text",
      "text": "{\"id\": \"T-1002\", \"order\": \"A-5530\", \"amount\": 249.0, \"customer\": \"Bram de Vries\", \"email\": \"[REDACTED:email]\", \"phone\": \"[REDACTED:phone_e164]\", \"text\": \"The coffee machine leaks. I want my money back to [REDACTED:iban].\"}"
    }
  ],
  "isError": false
}
maqpna dev: dev-e0e140d0: 1 allow, cost $0.0000 (maqpna dev timeline --last)

The helpdesk sent the full ticket. The gateway redacted the email, phone number and IBAN in the result, so the agent, and any model it would pass the ticket to, never sees them.

Step 5: Write the agent#

The agent lists the open tickets, reads each one and refunds the order amount. A production agent would ask a model what to do; this one follows a fixed rule so you can see each governed call. It reads the gateway URL and session token that maqpna dev run (and, in a cluster, the operator) puts in its environment.

Create agent.py:

"""support-bot: reads every open ticket and refunds the order amount."""
import json
import os
import urllib.request

GATEWAY = os.environ["MAQPNA_GATEWAY_URL"]
TOKEN = os.environ["MAQPNA_TOKEN"]


def call_tool(name, arguments):
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
                       "params": {"name": name, "arguments": arguments}}).encode()
    req = urllib.request.Request(GATEWAY + "/mcp/helpdesk", data=body, headers={
        "Authorization": "Bearer " + TOKEN,
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
    })
    # A call held for approval waits here until a person decides (up to 5 minutes locally).
    with urllib.request.urlopen(req, timeout=330) as resp:
        msg = json.loads(resp.read())
    if "error" in msg:
        err = msg["error"]
        raise RuntimeError(f"{name}: {err['message']} ({(err.get('data') or {}).get('reason')})")
    return msg["result"]["content"][0]["text"]


tickets = json.loads(call_tool("list_tickets", {}))
for t in tickets:
    ticket = json.loads(call_tool("get_ticket", {"id": t["id"]}))
    print(f"{ticket['id']}: {ticket['text']}")
    print(f"  contact: {ticket['email']}, {ticket['phone']}")
    try:
        print("  " + call_tool("issue_refund", {"order": ticket["order"], "amount": ticket["amount"],
                                                "note": "refund for " + ticket["id"]}))
    except RuntimeError as e:
        print("  refund refused:", e)

A refused call comes back as a JSON-RPC error with code -32001 and a data object naming the policy, rule and reason. The agent prints it and moves on. The Python SDK turns the same errors into PolicyDenied and ApprovalPending exceptions if you prefer a library.

Run it:

maqpna dev run --agent support-bot --namespace support -- python3 -u agent.py
maqpna dev: session dev-9bc4b8ca (agent support-bot, namespace support, on behalf of you@localhost)
T-1001: My headphones arrived broken. Card [REDACTED:pan] was charged.
  contact: [REDACTED:email], [REDACTED:phone_e164]
  refunded 39.9 EUR on order A-5521 (simulated)
T-1002: The coffee machine leaks. I want my money back to [REDACTED:iban].
  contact: [REDACTED:email], [REDACTED:phone_e164]

The 39.90 EUR refund ran at once (rule small-refund). The 249 EUR refund is held for approval, so the agent waits. Leave it waiting.

Step 6: Approve the large refund#

In the second terminal, load the local admin token and list the pending approvals:

eval "$(maqpna dev env)"
maqpna approvals list -n support
ID                           STATUS    NAMESPACE      AGENT          TOOL                     REASON
apr_a03ae9dfea7ac762b89b24f2 pending   support        support-bot    helpdesk/issue_refund    refunds over 100 EUR need a human

maqpna approvals get ID shows what you are approving: the session, user, rule, and an argument preview, {"amount":249.0,"note":"refund for T-1002","order":"A-5530"}.

Approve it as the team lead. The agent acts for you@localhost, and approvers cannot approve calls made on their own behalf, so --approver you@localhost is refused with HTTP 409: self_approval:

maqpna approvals approve apr_a03ae9dfea7ac762b89b24f2 --approver [email protected] --note "leaking machine, photo in ticket"
apr_a03ae9dfea7ac762b89b24f2: approved (by [email protected])

Check it works#

The first terminal finishes the run:

  refunded 249.0 EUR on order A-5530 (simulated)
maqpna dev: dev-9bc4b8ca: 5 allow, cost $0.0000 (maqpna dev timeline --last)

Run the agent once more and deny the refund with maqpna approvals deny ID --approver [email protected] --note "ask for a photo first". The agent then prints refund refused: issue_refund: denied by policy support-agent rule large-refund-needs-human: approval_denied by [email protected] (apr_...): ask for a photo first (approval_denied). Nobody deciding within five minutes ends the call with reason approval_expired. To decide from a window instead, run maqpna desk; see Human approvals for four-eyes rules and notifications.

Step 7: Read the timeline and the audit ledger#

The timeline is one session's ledger-ordered view:

maqpna dev timeline --session dev-9bc4b8ca
TIME      KIND       SERVER/TOOL            DECISION          DETAIL
17:35:55  tool_call  helpdesk/list_tickets  allow             matched rule read-tickets
17:35:55  tool_call  helpdesk/get_ticket    allow             matched rule read-tickets
17:35:55  tool_call  helpdesk/issue_refund  allow             matched rule small-refund
17:35:55  tool_call  helpdesk/get_ticket    allow             matched rule read-tickets
17:35:55  approval   helpdesk/issue_refund  require_approval  apr_a03ae9dfea7ac762b89b24f2 approved
17:36:07  tool_call  helpdesk/issue_refund  allow             approval:approved by [email protected] (apr_a03ae9dfea7ac762b89b24f2)
dev-9bc4b8ca: 5 allow, cost $0.0000 (maqpna dev timeline --last)

Each line is an audit record in .maqpna/audit.jsonl. Look at what the ledger keeps about DLP and the approval:

jq -c 'select(.session=="dev-9bc4b8ca" and (.ext.dlp or .approver != "")) | {seq, tool, rule, approver, dlp: .ext.dlp}' .maqpna/audit.jsonl
{"seq":4,"tool":"get_ticket","rule":"support-agent/read-tickets","approver":"","dlp":"email:1,pan:1,phone_e164:1"}
{"seq":6,"tool":"get_ticket","rule":"support-agent/read-tickets","approver":"","dlp":"email:1,iban:1,phone_e164:1"}
{"seq":7,"tool":"issue_refund","rule":"support-agent/large-refund-needs-human","approver":"[email protected]","dlp":null}

The ledger records which detectors matched and how often, never the matched values. The approved refund carries the rule that held it and the approver who released it. Your seq numbers depend on the calls you made before.

DLP also protects what the agent sends. Pass personal data in a refund note:

maqpna dev run --agent support-bot --namespace support -- maqpna call --server helpdesk \
  --tool issue_refund --arg order=A-5521 --arg amount=5 --arg note="pay to NL91ABNA0417164300, mail [email protected]"
tail -1 .maqpna/audit.jsonl | jq -c '.ext | {dlp, dlpAction, dlpProfile}'
{"dlp":"email:1,iban:1","dlpAction":"redact","dlpProfile":"dev"}

The helpdesk received the note with [REDACTED:iban] and [REDACTED:email]. The record keeps the hash of the original arguments (argsSha256) and the hash of what was forwarded (argsRedactedSha256).

Check it works#

Recompute the hash chain:

maqpna audit verify .maqpna/audit.jsonl
OK: 13 records, hash chain intact

Change one decision in a copy of the file (for example "allow" to "deny" on line 5) and verify the copy: maqpna audit verify prints TAMPERED: chain breaks at seq 5 after 4 valid records: ... (content modified) and exits with status 3.

What you built#

  • An MCP server of your own, governed by the MAQPNA gateway through maqpna dev up --upstream.
  • A DLP profile that redacts email addresses, phone numbers, card numbers and IBANs in both directions: in ticket text the agent reads and in arguments it sends.
  • A policy that allows reads, allows refunds up to 100 EUR, holds larger refunds for a person and denies everything else.
  • An approval flow where a different person approves or denies, with self-approval refused.
  • An audit ledger that records every decision, detector counts and approvers, and that maqpna audit verify proves intact.

Clean up#

maqpna dev down

Stop helpdesk.py with Ctrl+C in the first terminal. The state and the ledger stay in .maqpna/; delete the folder when you no longer need them.

Troubleshooting#

Symptom Cause Fix
maqpna call fails with HTTP 502 or a connection error to the helpdesk helpdesk.py is not running, or not on port 9100 Start it in the first terminal; check with the curl in step 1
Every call is denied with no_applicable_policy The session's agent or namespace does not match the policy Run with --agent support-bot --namespace support
Ticket data is not redacted maqpna dev up ran without --dlp-profile, or the detector is not listed Run maqpna dev down, then step 4 again
The 249 EUR refund is allowed without approval The when value or argument name differs from what the agent sends Run the maqpna policy test commands from step 3

Next steps#