Manual Testing Guide: Approval Gate
Requires core 2.1.0 or newer. On earlier releases none of this can pass:
approval_listwas read by no config parser, the gate service was never constructed, andGET /api/approvalsanswered500(#678). The scenarios below were written against the intended behaviour and only became runnable in 2.1.0.
Prerequisites
- Core 2.1.0+
- Python 3.11+ with
uvinstalled - Node.js 18+ (for dashboard)
- mcp-hangar checked out on
main - Optional: a delivery adapter, if you are testing a channel other than
dashboard/noop
1. Configuration
1.1 Dashboard Channel (default)
Add to your config.yaml:
approvals:
enabled: true # the default; set false to switch the gate off entirely
channel: dashboard
1.2 Slack Channel
approvals:
channel: slack
slack:
webhook_url: "https://hooks.slack.com/services/T.../B.../xxx"
signing_secret: "your-slack-signing-secret"
Core ships only dashboard and noop. From 2.0.0 slack resolves from the
mcp_hangar.approvals.delivery entry-point group and needs an adapter you
install; without one it degrades to noop with a warning. See
Approval delivery adapters.
1.3 NoOp Channel (for testing without notifications)
approvals:
channel: noop
2. Policy Configuration
Add approval_list to a MCP server's tool access policy:
mcp_servers:
grafana:
tools:
deny_list:
- "admin_*"
approval_list:
- "delete_*"
- "create_alert_rule"
approval_timeout_seconds: 300
approval_channel: dashboard
Policy Precedence
| List | Effect |
|---|---|
deny_list | Blocked (highest) |
approval_list | Held for approval |
allow_list | Immediate execution |
| (none) | Unrestricted |
A tool on deny_list is always blocked -- even if also on approval_list.
3. Test Scenarios
3.1 Approve Flow (Dashboard)
Steps:
-
Start mcp-hangar:
cd mcp-hangar && uv run mcp-hangar -
Start the dashboard:
cd hangar-app && npm run dev -
Open the dashboard at
http://localhost:5173 -
Navigate to Approvals in the sidebar (under Governance)
-
From an MCP client (e.g., Claude Code), invoke a tool matching the
approval_listpattern:delete_dashboard(id="dash-123") -
Observe in the dashboard:
- The "Approvals" page shows a new pending request
- Card shows: MCP server ID, tool name, countdown timer, arguments
- Badge shows pending count
-
Click Approve
-
Observe:
- The tool execution completes in the MCP client
- The card moves to "Approved" tab
- The card shows
decided_byinfo
Expected Result: Tool executes successfully after approval.
3.2 Deny Flow (Dashboard)
- Invoke a tool matching
approval_list - In the dashboard, expand the card and optionally enter a deny reason
- Click Deny
Expected Result: MCP client receives an error response with error_code: "approval_denied" and the deny reason.
3.3 Timeout Flow
- Set
approval_timeout_seconds: 10in policy (short timeout for testing) - Invoke a tool matching
approval_list - Do NOT approve or deny -- wait for timeout
Expected Result: After 10 seconds, MCP client receives error with error_code: "approval_timeout", message "No response within timeout".
3.4 Deny-List Override
-
Configure a tool that matches BOTH
deny_listandapproval_list:deny_list: - "admin_*" approval_list: - "admin_*" -
Invoke
admin_reset()
Expected Result: Tool is blocked immediately (deny_list wins). No approval request is created.
3.5 Sensitive Argument Redaction
-
Invoke a tool with sensitive arguments:
connect_database(host="localhost", password="secret123", api_token="tok_abc") -
Check the approval card in the dashboard
Expected Result: Arguments show password: "[REDACTED]" and api_token: "[REDACTED]", while host shows the actual value.
4. REST API Testing (curl)
4.1 List Pending Approvals
curl -s http://localhost:8080/api/approvals?state=pending | jq
4.2 Get Single Approval
curl -s http://localhost:8080/api/approvals/{approval_id} | jq
4.3 Approve via API
From 2.0.0 resolution is authorized: the caller must present a token whose principal holds
approval:resolve. Thex-principal-idheader these steps used to send no longer sets identity — it was never authentication, and a client-supplied value landing in the provenance chain is what 2.0.0 removed. ExportTOKENbefore running the calls below. On a gateway started with--unsafe-no-auththe header is unnecessary and the decision is attributed to the system principal.
curl -X POST http://localhost:8080/api/approvals/{approval_id}/resolve \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"decision": "approve"}'
4.4 Deny via API
curl -X POST http://localhost:8080/api/approvals/{approval_id}/resolve \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"decision": "deny", "reason": "Not authorized for production"}'
4.5 Double Resolve (idempotency check)
After resolving once, send the same request again:
# Should return 409 Conflict
curl -s -o /dev/null -w "%{http_code}" -X POST \
http://localhost:8080/api/approvals/{approval_id}/resolve \
-H "Content-Type: application/json" \
-d '{"decision": "approve"}'
Expected: HTTP 409
5. Slack Integration Testing
This section changed in 2.0.0. Core no longer terminates a Slack webhook. Pointing Slack's Request URL at the resolve endpoint, as earlier revisions of this page instructed, sends an unverified request to an endpoint that no longer checks Slack signatures. Delivery now runs as an adapter you deploy; see Approval delivery adapters.
5.1 Prerequisite Setup
- Install a delivery adapter that registers under the
mcp_hangar.approvals.deliveryentry-point group, and configureapprovals.channelto the name it registers. - Create a Slack App with Interactivity enabled.
- Set the Request URL to the adapter's callback endpoint, not Hangar's.
- Give the adapter the Signing Secret and a Hangar token whose principal holds
approval:resolve.
With no adapter installed, an unknown channel degrades to noop and logs a
warning: approvals queue undelivered but stay resolvable over REST. That is the
intended behaviour, not a failure to debug.
5.2 Notification Test
- Configure the adapter's channel in config.
- Invoke a tool matching
approval_list.
Expected: Slack message appears with:
- Header: "Approval Required"
- MCP Server and tool name
- Sanitized arguments in a code block
- Expiry countdown
- "Approve" (green) and "Deny" (red) buttons
5.3 Slack Approve/Deny
- Click Approve or Deny in Slack.
- Verify the adapter verified the Slack signature and called
POST /api/approvals/{approval_id}/resolvewith its token. - Verify the tool execution completes (or fails with denied).
- Verify
decided_bynames the Hangar principal the adapter mapped the Slack user onto. The oldslack:{user_id}form is retired: provenance names a principal Hangar authenticated, not a vendor handle.
6. Permission Verification
6.1 Roles
| Role | Can view approvals | Can resolve |
|---|---|---|
| mcp_server_admin | Yes | Yes |
| auditor | Yes | No |
| viewer | No | No |
6.2 Test Steps
-
Log in as
auditorrole -
Navigate to Approvals page -- should see pending requests
-
Try to approve -- should be blocked (no
approval:resolvepermission) -
Log in as
mcp_server_admin -
Navigate to Approvals page
-
Approve/Deny -- should succeed
7. Domain Event Verification
After each approval action, verify events in the event store/log:
| Action | Expected Event |
|---|---|
| Request | ToolApprovalRequested |
| Approve | ToolApprovalGranted |
| Deny | ToolApprovalDenied |
| Timeout | ToolApprovalExpired |
Check via:
# If event store exposed via API:
curl -s http://localhost:8080/api/events?type=ToolApprovalRequested | jq
Or check server logs for approval_id entries.
8. Automated Test Suite
Run all approval-related tests:
cd mcp-hangar
# Unit tests (106 tests)
uv run pytest tests/unit/domain/value_objects/test_tool_access_policy_approval.py \
tests/unit/enterprise/approvals/ -v
# Integration tests (14 tests)
uv run pytest tests/integration/test_approval_flow.py \
tests/integration/test_approval_api_e2e.py -v
# Fuzz tests (serialization round-trip)
uv run pytest tests/unit/test_event_serialization_fuzz.py -v
# Enterprise boundary check
bash scripts/check_enterprise_boundary.sh
9. Checklist
- Approve flow works via dashboard
- Deny flow works with reason
- Timeout expires correctly
- deny_list overrides approval_list
- Sensitive args are redacted
- REST API returns correct status codes (200, 400, 404, 409)
- Double resolve returns 409
- Slack notifications arrive (if configured)
- Slack buttons resolve correctly
- mcp_server_admin can resolve, auditor can only view
- Domain events published for all transitions
- Concurrent approvals do not interfere
- All automated tests pass (unit + 14 integration)