REST API Reference

Complete reference for all REST API endpoints exposed by MCP Hangar in HTTP mode. This page is the authoritative reference; the REST API guide is an overview that links here.

Base URL: http://localhost:8000/api

All endpoint paths shown below are relative to this base URL. Every route resolves only under the /api prefix (e.g. GET /mcp_servers is served at GET /api/mcp_servers).

Collection endpoints carry a trailing slash. GET /api/mcp_servers answers 307 and redirects to /api/mcp_servers/; curl does not follow a redirect unless you pass -L (a 307 keeps the method and body, so a POST followed with -L arrives intact). Using the trailing slash avoids the round trip. The same applies to /api/groups/, /api/tools/, /api/config/ and /api/system/.

All responses are JSON. Domain errors are nested:

{"error": {"code": "<ExceptionType>", "message": "<description>", "details": {"field": "..."} }}

details is null unless the error carries structured context. The HTTP status is in the status line, not the body. The status follows the exception: McpServerNotFoundError and ToolNotFoundError are 404, ValidationError 422, AuthenticationError 401, AccessDeniedError and AuthorizationError 403, RateLimitExceeded 429 (with Retry-After when the refill time is known), McpServerNotReadyError and McpServerNotHereError 409, McpServerDegradedError 503, ToolTimeoutError 504, and any other error 500 with the generic message An internal server error occurred..

Two other shapes are flat. Authentication failures are produced by the middleware before the handler chain:

{"error": "authentication_failed", "message": "No valid credentials provided", "details": {"auth_method": "none", "expected_methods": ["ApiKeyAuthenticator"]}}

Request-body checks made inside a handler answer 400 with a flat code, e.g. {"error": "missing_fields", "detail": "required field(s) absent: group_id"}, {"error": "invalid_body", ...} or {"error": "invalid_mode", ...}. The approvals routes use a flat {"error": "<message>"} throughout.

Required permissions

With authentication enabled, every route is authorized from one route table; a route missing from it is denied. A refusal is 403 with AccessDeniedError.

RoutePermission
GET /mcp_servers, /mcp_servers/{id} and its tools, tools/history, health, logsmcp_servers:read
POST /mcp_servers, PUT/PATCH /mcp_servers/{id}mcp_servers:write
DELETE /mcp_servers/{id}mcp_servers:write and mcp_servers:lifecycle
POST /mcp_servers/{id}/start, /stop, /block; /sessions/{id}/suspend; /admin/tools/...mcp_servers:lifecycle
GET /mcp_servers/{id}/l7_policypolicy:read
POST/PUT/DELETE /mcp_servers/{id}/l7_policypolicy:write
GET /groups, /groups/{id}group:read
POST /groupsgroup:create
PUT /groups/{id}, /rebalance, members add/removegroup:update
DELETE /groups/{id}group:delete
GET /discovery/sources, /pending, /quarantineddiscovery:read
POST /discovery/sources/{id}/scandiscovery:trigger
Other source management, /discovery/approve, /discovery/rejectdiscovery:approve
GET /config, GET /config/diff, POST /config/exportconfig:read
POST /config/backupconfig:update
POST /config/reloadconfig:reload
GET /toolstool:list
GET /system, /system/meany authenticated principal
GET /approvals, /approvals/{id}approval:read
POST /approvals/{id}/resolveapproval:resolve
/ws/eventsaudit:read
everything under /authadmin (only *:* satisfies it)

A grant held only at tenant scope passes just the tenant-aware routes -- tools/history, /admin/tools/..., /approvals/... and /ws/events -- and each of those confines what it reads or changes to that tenant. Every other route needs a global grant. With authentication disabled none of this applies.


MCP servers

List MCP servers

GET /mcp_servers?state={state}
ParameterInTypeRequiredDescription
statequerystringNoFilter: cold, ready, degraded, dead

Response 200:

{
  "mcp_servers": [
    {
      "mcp_server_id": "math",
      "state": "ready",
      "mode": "subprocess",
      "alive": true,
      "tools_count": 5,
      "health_status": "healthy",
      "tools_predefined": false,
      "dead": null,
      "description": "Math computation mcp_server"
    }
  ]
}

dead is null unless the server is dead; then it says why, since when and what starts it again. description is omitted when none is set.

Create MCP Server

POST /mcp_servers

Request body:

FieldTypeRequiredDefaultDescription
mcp_server_idstringYes--Unique identifier
modestringYes--subprocess, docker, or remote
commandlist[string]For subprocess--Command to run
imagestringFor docker--Docker image
endpointstringFor remote--HTTP endpoint URL
envdictNo{}Environment variables
idle_ttl_sintNo300Idle timeout in seconds
health_check_interval_sintNo60Health check interval
descriptionstringNo--Human-readable description

The registration's provenance is always recorded as api; a source field in the body is ignored. A body without mcp_server_id or mode is 400 missing_fields, and an id that already exists is 422 ValidationError.

volumes, network and read_only are not read by this route. They are accepted by the request parser and dropped: the command it builds carries only the fields above, so a container registered here gets the container defaults rather than the ones you sent. Declare those in config.yaml, where they are honoured.

A private or link-local endpoint is refused with 400 {"error": "ssrf_blocked"}. That check applies to human-supplied endpoints only -- discovery supplies a pod IP with its provenance attached and is accepted.

Response 201:

{"mcp_server_id": "math", "created": true}

Get MCP Server

GET /mcp_servers/{mcp_server_id}

Response 200: MCP Server detail object: mcp_server_id, state, mode, alive, tools (full tool objects), health (the object Get MCP Server Health returns), idle_time, meta and dead.

Response 404: MCP Server not found.

Update MCP Server

PUT /mcp_servers/{mcp_server_id}
PATCH /mcp_servers/{mcp_server_id}

Both PUT and PATCH are accepted and behave identically (partial update).

Request body (all fields optional):

FieldTypeDescription
descriptionstringNew description
envdictNew environment variables (replaces existing)
idle_ttl_sintNew idle timeout
health_check_interval_sintNew health check interval

Response 200:

{"mcp_server_id": "math", "updated": true}

Delete MCP Server

DELETE /mcp_servers/{mcp_server_id}

Stops the MCP server if running, then removes it from the registry.

Response 200:

{"mcp_server_id": "math", "deleted": true}

Response 409 (2.26.0 and later): the server is declared in the gateway's configuration file. Such a delete used to last only until the next reload or restart. The server keeps running. Remove it from the configuration and reload, or stop it with POST /mcp_servers/{mcp_server_id}/stop.

{"error": "declared_in_config", "mcp_server_id": "math", "detail": "McpServer math is declared in the gateway configuration; ..."}

Start MCP Server

POST /mcp_servers/{mcp_server_id}/start

Response 200:

{"mcp_server": "math", "state": "ready", "tools": ["add", "subtract"]}

Stop MCP Server

POST /mcp_servers/{mcp_server_id}/stop

Request body (optional):

FieldTypeDefaultDescription
reasonstring"user_request"Reason for stopping

Response 200:

{"stopped": "math", "reason": "user_request"}

Block MCP Server

POST /mcp_servers/{mcp_server_id}/block

Permanently blocks an MCP server for detection enforcement (stops it with reason detection_enforcement:block).

Response 200:

{"mcp_server_id": "math", "blocked": true}

Get MCP Server Tools

GET /mcp_servers/{mcp_server_id}/tools

Response 200:

{
  "tools": [
    {
      "name": "add",
      "description": "Add two numbers",
      "inputSchema": {"type": "object", "properties": {"a": {"type": "number"}}},
      "digest": "e84e846a57adc93e...",
      "pinned_digest": "e84e846a57adc93e..."
    }
  ]
}

digest is the tool's SHA-256 fingerprint as listed -- the value mcp-hangar pin computes. pinned_digest is present only when the tool has an all-tenants pin (tool_projection.pins); a per-tenant pin is not shown here. See Digest Pinning.

Get MCP Server Health

GET /mcp_servers/{mcp_server_id}/health

Response 200:

{"consecutive_failures": 0, "total_invocations": 1, "total_failures": 0, "success_rate": 1.0, "can_retry": true, "last_success_ago": 0.07, "last_failure_ago": null}

Get MCP Server Logs

GET /mcp_servers/{mcp_server_id}/logs?lines={n}
ParameterInTypeDefaultRangeDescription
linesqueryint1001--1000Number of recent lines; out-of-range values are clamped

Response 200:

{
  "logs": [
    {"mcp_server_id": "math", "stream": "stderr", "content": "...", "recorded_at": 1774260930.5}
  ],
  "mcp_server_id": "math",
  "count": 42
}

Get Tool Invocation History

GET /mcp_servers/{mcp_server_id}/tools/history?limit={n}&from_position={pos}
ParameterInTypeDefaultRangeDescription
limitqueryint1001--500Max records; out-of-range values are clamped
from_positionqueryint0--Event store version to start from (inclusive)

Response 200:

{
  "mcp_server_id": "math",
  "history": [...],
  "total": 42
}

Each entry is a ToolInvocationCompleted or ToolInvocationFailed event. total counts the entries returned, not every invocation in the stream; page on with from_position.


Groups

List Groups

GET /groups

Response 200:

{
  "groups": [
    {
      "group_id": "llm-pool",
      "description": "LLM pool",
      "state": "healthy",
      "strategy": "round_robin",
      "min_healthy": 1,
      "healthy_count": 1,
      "members_in_rotation_count": 2,
      "total_members": 2,
      "is_available": true,
      "circuit_open": false,
      "members": [...]
    }
  ]
}

Each entry is the group's status, read at one instant. state is the group's availability (inactive, partial, healthy, degraded), not a server lifecycle state. healthy_count counts the members that are ready and in rotation; members_in_rotation_count counts the members in rotation in any state. A group whose members are all cold reads healthy_count: 0 and still routes while is_available is true. See Group States.

Create Group

POST /groups

Request body:

FieldTypeRequiredDefaultDescription
group_idstringYes--Unique identifier
strategystringNo"round_robin"Load balancing strategy
min_healthyintNo1Minimum healthy members
descriptionstringNo--Description

Response 201:

{"group_id": "llm-pool", "created": true}

Get Group

GET /groups/{group_id}

Response 200: The same object as one entry of GET /groups; hangar_details returns the same for a group.

Update Group

PUT /groups/{group_id}

Request body (all optional):

FieldTypeDescription
strategystringNew strategy
min_healthyintNew minimum healthy count
descriptionstringNew description

Response 200:

{"group_id": "llm-pool", "updated": true}

Delete Group

DELETE /groups/{group_id}

Response 200:

{"group_id": "llm-pool", "deleted": true}

Rebalance Group

POST /groups/{group_id}/rebalance

Re-checks member health and resets circuit breaker if applicable.

Response 200:

{"status": "rebalanced", "group_id": "llm-pool"}

Add Group Member

POST /groups/{group_id}/members

Request body:

FieldTypeRequiredDefaultDescription
member_idstringYes--MCP Server ID to add
weightintNo1Routing weight
priorityintNo1Routing priority

Response 201:

{"group_id": "llm-pool", "mcp_server_id": "llm-1", "added": true}

Remove Group Member

DELETE /groups/{group_id}/members/{member_id}

Response 200:

{"group_id": "llm-pool", "mcp_server_id": "llm-1", "removed": true}

Discovery

Preview — source management. Registering, updating, deleting, scanning, and enabling/disabling a discovery source (every endpoint below except List Sources) ships in 2.5.0 as Preview, not GA: the behaviour may still change. Each of those responses carries the header X-Hangar-Preview: discovery-source-management so a client can detect the preview status programmatically. List Sources and the approval workflow are stable.

List Sources

GET /discovery/sources

Response 200:

{
  "sources": [
    {
      "id": "28018ad1-4d9d-54dc-9e4e-c5d856af4612",
      "source_type": "docker",
      "mode": "additive",
      "is_healthy": true,
      "is_enabled": true,
      "last_discovery": "2026-08-08T09:22:56+00:00",
      "mcp_servers_count": 3,
      "error_message": null
    }
  ]
}

id is the value every other route on this resource takes -- /scan, /enable, PUT and DELETE. For a source declared in config.yaml it is derived from the source type rather than generated, so it is the same id after a restart and a script can keep using it. Since 2.5.0: before that, configured sources were listed without an id and the scan answered 404 for anything an operator could obtain.

The listing carries an id only while the discovery registry still knows the source -- this route strips it from any source the orchestrator keeps running after a DELETE, so a listed id is always one the per-source routes accept, where the hangar_sources MCP tool returns the raw status and can keep reporting an id these routes now answer 404 for. Since 2.5.0.

last_discovery is null until the first cycle completes, and error_message carries the last failure or null.

Register Source

POST /discovery/sources

Request body:

FieldTypeRequiredDefaultDescription
source_typestringYes--docker, filesystem, kubernetes, entrypoint
modestringYes--additive or authoritative
enabledboolNotrueActivate immediately
configdictNo{}Source-specific configuration

Response 201:

{"source_id": "...", "registered": true}

Keep the source_id from this response. A source registered here is recorded and answers the per-source routes (/scan, /enable, PUT, DELETE), but it is not one of the sources the discovery cycle runs, so List Sources does not show it. A mode other than additive or authoritative is 400 invalid_mode.

Update Source

PUT /discovery/sources/{source_id}

Request body (all optional): mode, enabled, config.

Response 200:

{"source_id": "...", "updated": true}

Delete Source

DELETE /discovery/sources/{source_id}

Response 200:

{"source_id": "...", "deregistered": true}

Trigger Scan

POST /discovery/sources/{source_id}/scan

Response 200:

{"source_id": "...", "scan_triggered": true, "mcp_servers_found": 3}

Enable/Disable Source

PUT /discovery/sources/{source_id}/enable

Request body:

{"enabled": true}

Response 200:

{"source_id": "...", "enabled": true}

List Pending MCP servers

GET /discovery/pending

Response 200:

{"pending": [{"name": "new-mcp-server", "source_type": "docker", "mode": "remote", "connection_info": {...}, "fingerprint": "...", "ttl_seconds": 90, ...}]}

List Quarantined MCP servers

GET /discovery/quarantined

Response 200:

{"quarantined": {...}}

Approve MCP Server

POST /discovery/approve/{name}

Approves a quarantined MCP server. A name that is pending but not quarantined is not approved by this route.

Response 200: {"approved": true, "mcp_server": "...", "status": "registered"} on success. A name not in quarantine also answers 200, with {"approved": false, "mcp_server": "...", "error": "McpServer not found in quarantine"}.

Reject MCP Server

POST /discovery/reject/{name}

Rejects a quarantined MCP server.

Response 200: {"rejected": true, "mcp_server": "..."}, or "rejected": false with the same error when the name is not in quarantine.


Configuration

Get Config

GET /config

Returns the MCP server records the durable configuration repository holds -- the registrations a persistence backend keeps across a restart -- with sensitive fields stripped. It is not the running configuration: on a gateway without a durable persistence backend it answers {"config": {}}. For the running state use Export Config.

Response 200:

{"config": {"mcp_servers": [...]}}

Reload Config

POST /config/reload

Request body (optional):

FieldTypeDefaultDescription
gracefulbooltrueGraceful reload

Sending config_path is refused with 422 and config_path is not accepted; reload always targets the server's own configuration file. Reload re-reads the file the process was started with; there is no way to point it at another one over HTTP.

Response 200:

{"status": "reloaded", "result": {"success": true, "config_path": "...", "mcp_servers_added": [], "mcp_servers_removed": [], "mcp_servers_updated": [], "mcp_servers_unchanged": ["math"], "duration_ms": 3.9}}

Export Config

POST /config/export

Serializes current in-memory state to YAML.

Response 200:

{"yaml": "mcp_servers:\n  math:\n    mode: subprocess\n    ..."}

Backup Config

POST /config/backup

Creates a rotating backup of the current configuration.

Response 200:

{"path": "/path/to/config.yaml.bak1"}

The backup is written next to the configuration file as <config>.bak1, rotating older ones to .bak2 and beyond. The file this route (and Config Diff) reads is the one named by the MCP_CONFIG environment variable, or config.yaml in the process's working directory when that is unset -- not the --config argument, and not the file the gateway booted from. Since 2.25.0 serve falls back to ~/.config/mcp-hangar/config.yaml when neither is present, and these two routes do not follow it. Setting MCP_CONFIG rather than passing --config makes all three agree. The returned path is relative when that name is.

That directory has to be writable by the process, and in the published container image it is not: /app is owned by root and the gateway runs as hangar.

Since 2.5.0 that answers 503 with the reason -- could not write the backup beside 'config.yaml': Permission denied -- rather than a bare 500 and An internal server error occurred.. Mount a writable directory and point MCP_CONFIG at the file in it if you need this endpoint.

Config Diff

GET /config/diff

Compares on-disk configuration with current in-memory state.

Response 200:

{"has_diff": true, "diff": "--- on-disk\n+++ in-memory\n@@ ...", "on_disk": {...}, "in_memory": {...}}

System

Get System Info

GET /system

Response 200:

{
  "system": {
    "total_mcp_servers": 5,
    "mcp_servers_by_state": {"ready": 3, "cold": 2},
    "total_tools": 15,
    "total_invocations": 42,
    "total_failures": 1,
    "overall_success_rate": 0.976,
    "uptime_seconds": 3600.5,
    "version": "2.5.0",
    "instance": {
      "instance_id": "hangar-7f9c4d2b1a-a3f19c",
      "coordinates_with_peers": false,
      "manages_fleet": true,
      "storage_is_shareable": false,
      "rate_limits_are_per_instance": true
    }
  }
}

The counter is total_invocations, not total_tool_calls. instance is present from 2.5.0 and describes the replica that answered -- see Running more than one replica.

Get Current User

GET /system/me

Returns the current authentication status. Used by the SPA to check whether a user is logged in. When auth is not enabled, returns authenticated: false.

Response 200:

{"authenticated": true, "principal": {"id": "...", "type": "user"}}

Tools

List All Tools

GET /tools

Lists all tools across all MCP servers (used by the supervisor to sync tool inventory).

Response 200:

{
  "tools": [
    {"mcp_server_id": "math", "tool_name": "add", "description": "Add two numbers", "input_schema": "{'type': 'object', ...}", "digest": "e84e846a...", "pinned_digest": "e84e846a..."}
  ]
}

input_schema is a string -- the schema rendered as Python text, not a JSON object; use Get MCP Server Tools for a parseable inputSchema. digest and pinned_digest are as described there. Only servers whose tools are known appear: a cold server without predefined tools contributes nothing until it has started.


Sessions

Suspend Session

POST /sessions/{session_id}/suspend

Adds a session to the suspended registry of the replica that answers and announces it, so the other replicas apply it too. A session_id that is not 1-128 letters, digits, dashes or underscores is 400.

Request body (optional):

FieldTypeDefaultDescription
reasonstring--Reason for suspension

Response 200:

{"session_id": "...", "suspended": true}

Unsuspend Session

DELETE /sessions/{session_id}/suspend

Removes a session from the suspended registry.

Response 200:

{"session_id": "...", "suspended": false}

Auth Management

Every route here requires the admin role. With authentication disabled they are not wired and answer 503.

Since 2.25.0 the actor of every change -- the creator of a key or role, the revoker, the assigner, the updater, the deleter -- is the principal that authenticated the request. It is recorded in the domain event, the log line and, for a new key, the response. A body that still carries created_by, revoked_by, assigned_by or updated_by, whatever its value (null and the caller's own id included), is refused with 422 and a ValidationError naming the field, and nothing is changed (mcp-hangar#1649). Before 2.25.0 those fields were accepted, defaulted to "system", and were recorded as written, so an event from an older gateway names whoever the body said.

Create API Key

POST /auth/keys

Request body:

FieldTypeRequiredDefaultDescription
principal_idstringYes--Principal this key authenticates as
namestringYes--Human-readable key name
expires_atstringNo--ISO8601 expiry datetime

Response 201:

{"key_id": "...", "raw_key": "mcp_...", "principal_id": "...", "name": "...", "created_by": "user:alice", "expires_at": null, "warning": "Save this key now - it cannot be retrieved later!"}

created_by is the caller's principal id. A body missing principal_id or name is a 500, not a validation error.

!!! warning The raw_key is returned only once. Store it securely.

Revoke API Key

DELETE /auth/keys/{key_id}

Request body (optional):

FieldTypeDefaultDescription
reasonstring""Revocation reason

List API Keys

GET /auth/keys
ParameterInTypeRequiredDescription
principal_idquerystringYesPrincipal whose keys to list
include_revokedqueryboolNoInclude revoked keys (default true)

Response 200: {"principal_id": "...", "keys": [{"key_id", "name", "created_at", "expires_at", "last_used_at", "revoked"}], "total": 1, "active": 1}. Without principal_id the list is empty.

List All Roles

GET /auth/roles/all
ParameterInTypeRequiredDescription
include_builtinqueryboolNoInclude built-in roles (default true)

List Built-in Roles

GET /auth/roles

Get Role

GET /auth/roles/{role_name}

Create Custom Role

POST /auth/roles

Request body: role_name (required), description, and permissions -- a list of resource:action or resource:action:id strings.

Response 201: {"role_name": "ops", "description": "...", "permissions_count": 2, "created": true, ...}.

Update Custom Role

PATCH /auth/roles/{role_name}

Request body: permissions (replaces the list) and description.

Delete Custom Role

DELETE /auth/roles/{role_name}

Response 204, no body. A built-in role is 403 CannotModifyBuiltinRoleError; an unknown one is 404. The caller is recorded as deleted_by.

Assign Role

POST /auth/roles/assign

Request body:

{"principal_id": "...", "role_name": "developer", "scope": "global"}

Revoke Role

DELETE /auth/roles/revoke

Request body:

{"principal_id": "...", "role_name": "developer", "scope": "global"}

List Principals

GET /auth/principals

List Roles for Principal

GET /auth/principals/roles
ParameterInTypeRequiredDescription
principal_idquerystringYesPrincipal whose roles to list
scopequerystringNoScope filter (default * = all)

List Permissions

GET /auth/permissions

Returns a fixed manifest of resource types and actions.

Response 200:

{"permissions": [{"resource_type": "provider", "actions": ["read", "write", "invoke", "admin"]}, {"resource_type": "group", "actions": ["read", "write", "admin"]}, "..."]}

The manifest is static and does not list the permissions route enforcement checks (see Required permissions); read a role's actual grants with GET /auth/roles/{role_name}.

Check Permission

POST /auth/check-permission

Request body:

{"principal_id": "...", "permission": "mcp_servers:lifecycle"}

permission is resource:action[:id]. Alternatively send action, resource_type and resource_id separately.

Response 200:

{"principal_id": "...", "action": "lifecycle", "resource_type": "mcp_servers", "resource_id": "*", "allowed": false, "granted_by_role": null}

Get Tool Access Policy

GET /auth/policies/{scope}/{target_id}
ParameterInTypeRequiredDescription
scopepathstringYesprovider, group, or member
target_idpathstringYesIdentifier of the provider, group, or member

Any other scope is 400 ValidationError. Response 200: {"found": true, "scope": "provider", "target_id": "math", "allow_list": [...], "deny_list": [...]}; found is false with empty lists when no policy is set.

Set Tool Access Policy

POST /auth/policies/{scope}/{target_id}

Request body:

{"allow_list": ["tool_a", "tool_b*"], "deny_list": ["tool_c"]}

Response 200: the stored policy with "set": true.

Clear Tool Access Policy

DELETE /auth/policies/{scope}/{target_id}

Response 204, no body.


L7 Egress Policy

Read, attach, replace, or clear the L7 egress policy (compiled MCPEgressPolicy) on a single MCP server. The core policy engine and this REST intake are available in v1.6.0; end-to-end delivery from a Kubernetes MCPEgressPolicy custom resource needs the operator's controller to compile and push the policy, which ships in operator v0.14.0 and later. L7 egress is the last gate on the invocation path, evaluated inside invoke_tool immediately before the upstream call.

These routes are gated on policy:read / policy:write, not on mcp_servers:write. The distinction is deliberate: mcp_servers:write is held by developer, and gating policy mutation on it let that role clear an egress policy. Among the built-in roles policy:read and policy:write are held by admin and provider-admin only.

Get L7 Policy

GET /mcp_servers/{mcp_server_id}/l7_policy

Returns the attached policy in the same wire form POST accepts. Requires policy:read.

Response 200: the compiled policy body, with every section filled in and a policyId (sha256:...) added.

Response 404: {"error": "no_l7_policy", "mcp_server_id": "math"} when the MCP server exists but holds no policy.

Set L7 Policy

POST /mcp_servers/{mcp_server_id}/l7_policy
PUT  /mcp_servers/{mcp_server_id}/l7_policy

Attaches or replaces the compiled L7 policy on an MCP server. POST and PUT behave identically. Requires policy:write.

Request body: the compiled policy the operator derives from an MCPEgressPolicy:

FieldTypeDescription
toolsdictTool-name globs: allow, deny, requireApproval
headersdictHeader rules: allow, deny, requireApproval
argumentsdictArgument-level constraints: secretPatterns, maxPayloadBytes
defaultActionstringAllow or Deny when no rule matches; default Deny
modestringAudit or Enforce; anything else, or absent, is Enforce

What requireApproval does depends on whether an approval gate is configured. With one, the match holds the call and waits for a human decision or the gate's approval_timeout_seconds — the egress policy is the second, independent source of "ask a human" beside the tool-access approval_list, and both resolve through the same gate. With no gate configured it stays fail-closed: the call is blocked, indistinguishable from a deny. The two controls are still declared separately — an egress requireApproval match does not read approval_list.

Response 200:

{"mcp_server_id": "math", "l7_policy_set": true, "persisted": true}

persisted (since core 2.22.1) says whether a restart of this gateway gives the policy back. It is false when the gateway has no durable persistence backend, or keeps one but does not read it back at start (MCP_AUTO_RECOVER=false); each such push also logs l7_policy_not_persisted at warning. It reports the configuration, not the storage: SQLite on a volume that does not outlive the pod reports true. See Surviving a gateway restart.

Response 400: {"error": "invalid_l7_policy", "detail": "..."} when the body is not a valid compiled policy.

Response 404: MCP Server not found.

Clear L7 Policy

DELETE /mcp_servers/{mcp_server_id}/l7_policy

Clears the L7 policy on an MCP server, disabling L7 enforcement for it. Requires policy:write.

Response 200:

{"mcp_server_id": "math", "l7_policy_set": false, "persisted": true}

persisted is always true here: a gateway that keeps nothing starts with no policy, so a restart keeps the policy cleared either way.


Admin Tools

Runtime tool withdrawal/restore. Requires mcp_servers:lifecycle, which the built-in admin and developer roles hold. A grant held only within a tenant acts on its own tenant: omitting tenant_id means that tenant, and naming another is 403.

Withdraw Tool

POST /admin/tools/{server}/{tool}/withdraw

Withdraws a tool at runtime. The withdrawal is recorded, so it survives a config reload and a restart, and it reaches every replica in the fleet rather than only the one that served this request.

Before 2.17.1 it lived in the memory of the replica that took the call: the other replicas kept listing and serving the tool, and a rolling restart lifted it entirely. Propagation to peers is not instantaneous -- a peer applies the withdrawal when it reads the event, within one tail interval.

Request body (optional):

FieldTypeDefaultDescription
tenant_idstringnullTenant to withdraw for. Omit/null withdraws globally for all tenants.
kindstring"tool"tool, prompt or resource; anything else is 400 invalid_kind. A resource is named by its uri, slashes included

Response 200:

{"withdrawn": true, "mcp_server": "math", "tool": "add", "kind": "tool", "tenant_id": null}

Restore Tool

POST /admin/tools/{server}/{tool}/restore

Removes a runtime withdrawal (config-declared withdrawals persist independently).

Request body (optional):

FieldTypeDefaultDescription
tenant_idstringnullTenant to restore. Omit/null removes the entire runtime entry.
kindstring"tool"As for withdraw

Response 200:

{"restored": true, "mcp_server": "math", "tool": "add", "kind": "tool", "tenant_id": null}

Approvals

Available when the approval service is enabled. Paths below are the 2.x routes, served under the /api mount — verified against core's published route inventory (api-routes.json). On the closed 1.6.x line these lived under /enterprise/approvals.

These routes became usable in 2.1.0. Before that they read an application-state field that nothing ever populated, so GET /api/approvals answered 500 with an AttributeError on every deployment (#678). The service is now published onto application state from a single place shared by the HTTP-serve path, the server factory and any test client, and the routes fall back to the application context — the same object the enforcement path reads — so the API and enforcement cannot hold different services. When there is genuinely no gate service, these routes answer 503, not a stack trace.

Something has to put a tool behind the gate before anything appears here: see approval_list.

List Approvals

GET /approvals?state={state}&provider_id={id}
ParameterInTypeDefaultDescription
statequerystringpendingFilter: pending, approved, denied, expired, cancelled (2.25.0)
provider_idquerystring--Optional provider filter

Response 200: a bare JSON array of approval request objects, each with approval_id, provider_id, tool_name, arguments, state, channel, requested_at, expires_at, expires_in_seconds, requested_by, decided_by, decided_at, reason and tenant_id. An unknown state is 400.

Get Approval

GET /approvals/{approval_id}

Response 200: Approval request object.

Response 404: Approval not found.

Resolve Approval

POST /approvals/{approval_id}/resolve

Approves or denies a pending approval. Requires an authenticated principal holding approval:resolve; the decision is attributed to that principal.

From 2.0.0 this is the only way in. The Slack HMAC callback branch was removed, and the client-supplied x-principal-id header no longer sets identity. A Slack integration now runs as a delivery adapter that verifies the vendor signature itself and calls this endpoint with an ordinary token — see Approval delivery adapters.

On a gateway with auth disabled the decision is attributed to the system principal rather than refused: refusing there would decide nothing.

Request body:

FieldTypeRequiredDescription
decisionstringYesapprove or deny
reasonstringNoOptional resolution reason

Response 200: {"approval_id": "...", "state": "approved"} (or denied). A decision other than approve or deny is 400; an approval that is already resolved is 409 with its state.

Since 2.25.0 an approval for a held call whose hangar_call batch deadline has already passed is refused: the call will not run, so there is nothing to let through. The answer is 409:

{"error": "Approval refused: the held call was cancelled and did not run", "state": "cancelled"}

The record moves to the terminal state cancelled, and the gate publishes ToolApprovalCancelled, whose attempted_by names the approver, instead of ToolApprovalGranted. On a cancelled record decided_by names who tried to approve the call, not who let it through. A client that reads every 409 as "already resolved" should read state. A denial after the deadline is still recorded as a denial. An approval that lands on a different gateway instance from the held call still answers 200 there; the instance holding the call records it cancelled (mcp-hangar#1702).


WebSocket Endpoints

Events Stream

ws://host:port/api/ws/events

Streams all domain events as JSON frames. Requires audit:read; a principal without it is refused at the handshake (403). A grant held only within a tenant receives only the events that name that tenant.

Since 2.25.0 websockets is a dependency of mcp-hangar, so the endpoint works on a pip or uv install as well as in the image. Before 2.25.0 only the image installed a WebSocket library; on a pip install the upgrade was answered 404 and uvicorn logged No supported WebSocket library detected (mcp-hangar#1676).

See the WebSockets guide for connection details.


Outside /api

These are served at the root, not under the base URL, and skip authentication:

MethodPathResponse
GET/health/live{"status": "healthy"}
GET/health/ready{"status": "healthy", "ready_mcp_servers": 1, "total_mcp_servers": 1}
GET/health/startup{"status": "healthy", "startup_complete": true, "uptime_seconds": 51.6}
GET/metricsPrometheus text format
GET/.well-known/oauth-protected-resourceRFC 9728 metadata; 404 when no OIDC issuer is configured

The MCP protocol itself is served at /mcp.