Skip to main content

REST API

MCPProxy provides a REST API for server management and monitoring.

OpenAPI Specification

Interactive API documentation is available at http://127.0.0.1:8080/swagger/ when MCPProxy is running. The OpenAPI spec file is also available at oas/swagger.yaml.

Authentication​

All /api/v1/* endpoints require authentication via API key:

# Using X-API-Key header (recommended)
curl -H "X-API-Key: your-api-key" http://127.0.0.1:8080/api/v1/servers

# Using query parameter
curl "http://127.0.0.1:8080/api/v1/servers?apikey=your-api-key"

Note: Unix socket connections bypass API key authentication (OS-level auth).

Admin key vs. agent tokens​

Two kinds of credential authenticate against the REST API:

  • Admin API key — full access to every endpoint.
  • Agent tokens (mcp_agt_ prefix, see Agent Tokens) — scoped, read-oriented access. Agent tokens may read (GET /api/v1/servers, diagnostics, config/registry reads, GET /api/v1/index/search) but may not perform mutating operations that touch server/security/config state. These return 403 Forbidden with operation requires admin access for an agent token, mirroring the MCP upstream_servers/quarantine_security denylist so the two surfaces cannot drift. Socket (tray) connections authenticate as admin and are unaffected. Gated routes:
    • Servers — add, remove, patch, enable, disable, restart, reconnect, quarantine, unquarantine, login, logout, config-to-secret, discover-tools, refresh, tool approve/block, the bulk enable_all/disable_all/restart_all, and the security scanner (scan, scan/cancel, security/approve, security/reject).
    • Config — POST /config/apply, PATCH /config, PATCH /config/docker-isolation (config can add/remove/enable/disable servers). POST /config/validate is read-only and stays open.
    • Registries — POST/PUT/DELETE /registries[/{id}] (source management) and POST /registries/{id}/servers/{serverId}/add. Registry browsing (GET) stays open.

Base URL​

http://127.0.0.1:8080/api/v1

Request ID Tracking​

All API responses include an X-Request-Id header for request tracing and log correlation. This is useful for debugging issues and correlating errors with server logs.

Request Header​

You can optionally provide your own request ID:

curl -H "X-API-Key: your-api-key" \
-H "X-Request-Id: my-custom-id-123" \
http://127.0.0.1:8080/api/v1/servers

Validation rules:

  • Pattern: ^[a-zA-Z0-9_-]{1,256}$
  • Max length: 256 characters
  • If missing or invalid, MCPProxy generates a UUID v4

Response Header​

Every response includes the request ID:

X-Request-Id: my-custom-id-123

Error Responses​

Error responses include the request_id in the JSON body for easy correlation:

{
"success": false,
"error": "server 'nonexistent' not found",
"request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

Log Correlation​

Use the request ID to find related activity logs:

# Via CLI
mcpproxy activity list --request-id a1b2c3d4-e5f6-7890-abcd-ef1234567890

# Via API
curl "http://127.0.0.1:8080/api/v1/activity?request_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890"

Endpoints​

Status​

GET /api/v1/status​

Get server status and statistics.

Response:

{
"status": "running",
"version": "0.11.0",
"uptime": 3600,
"servers": {
"total": 5,
"connected": 4,
"quarantined": 1
},
"tools": {
"total": 42
}
}

Servers​

GET /api/v1/servers​

List all upstream servers with unified health status.

Query Parameters:

ParameterTypeDescription
profilestringRestrict to the named profile's effective servers (intersected with the servers the caller may see). Each row's tool_count becomes the number of that server's tools visible under the profile, and stats are recomputed over the returned rows. An unknown or unreachable profile returns 404 profile not found. - returns 400. client and token are not server filters and return 400 unsupported_scope_filter
Header redaction and the mask format​

By default, sensitive header values (Authorization, X-API-Key, Cookie, Set-Cookie, etc.) are replaced with a length-preserving mask of the form ••••<last2> (<N> chars) before serialization. This applies to:

  • GET /api/v1/servers and its single-server children
  • The /events SSE servers.changed payloads
  • The upstream_servers list MCP tool

The mask preserves enough information to identify which token is in use (the last two characters + total length) while keeping the secret out of the response. Values that are already secret references — ${keyring:NAME} or ${env:VAR} — pass through unchanged because they're labels, not secrets.

Setting reveal_secret_headers: true in mcp_config.json disables redaction on all three channels for an authenticated admin only (issue #1167). An agent token, a server-edition non-admin user and an unauthenticated caller keep getting masked values no matter how the flag is set. The flag has no effect at all on the /events SSE stream or on the upstream_stats block of GET /api/v1/status: those payloads are produced once for a mixed-privilege audience with no caller to check, so they are always masked. An admin subscribed to /events with the flag on receives the notify-only form of servers.changed and re-fetches the raw values through GET /api/v1/servers. This is not normally needed: the Web UI / macOS tray / CLI can edit, delete, and convert-to-secret without ever seeing the plaintext, because the PATCH endpoint deep-merges (omitted keys are preserved) and the config-to-secret endpoint reads the real value server-side. Flip the flag only if you need to inspect a raw value through the API for debugging.

On the MCP channel the flag also requires an authenticated caller (issue #1148): an unauthenticated /mcp client is admin only for backward compatibility, and gets the masked values regardless of the flag. The REST API always requires an API key, so it is unaffected.

Redaction is not limited to headers. The same responses — and the /events SSE servers.changed payloads, which go through the identical redactor — also mask env values, URL query credentials, oauth.extra_params, oauth.scopes and credential-shaped argv tokens (--api-key sk-…, --endpoint=ghp_…), using one shared rule set so the REST, SSE and MCP doors cannot drift.

Two rules decide, in that order, and both run on every field:

  1. The field name — Authorization, GITHUB_TOKEN, ?access_token=, --api-key. This is what keeps a payload readable: it says which credential is configured without revealing it.
  2. The value's own shape — an AWS key, a GitHub token, a PEM block, a high-entropy blob — wherever it sits. A credential under a benign name (env: {BUILD_ID: ghp_…}, ?opaque=ghp_…, a custom header) is invisible to rule 1 and obvious to rule 2, so rule 2 runs over everything rule 1 left alone. In a URL it runs per component, so the readable scheme://user:••••(N chars)@host/db rendering survives while a second credential elsewhere in the same URL is still masked.

Three consequences for clients:

  • A masked value echoed back on a write is reverted only when it can be bound to a key it cannot be moved away from — a map key for env / headers / oauth.extra_params, a query-parameter name plus the stored scheme and host:port for url, a field name for oauth.client_secret / client_id / redirect_uri. So a read-modify-write that edits one field never persists another field's mask over the real secret.
  • Everything else is refused with 400, never reverted: args, oauth.scopes, isolation.extra_args, a mask moved to a different key or host, and any field added to the server config later. An argv slot (like a scope) has no key to bind a stored secret to — only its index and its neighbours, all of them caller-supplied in the same request — so an echoed mask is refused rather than reverted. Resend the real value, or omit the field to leave the stored one unchanged. POST /api/v1/servers refuses every echoed mask, since on create there is no stored value to bind one to. See Upstream servers.
  • GET /api/v1/servers/{id}/logs scrubs credentials out of the returned log lines (mcpproxy logs the upstream URL with its query string, and a child MCP server may print its own API key), matching the MCP tail_log operation.

The MCP upstream_servers tool was the original motivator for redaction (see PR #425) — a prompt-injected agent could otherwise read another upstream's PAT via upstream_servers list.

Response:

{
"success": true,
"data": {
"servers": [
{
"name": "github-server",
"protocol": "http",
"enabled": true,
"connected": true,
"quarantined": false,
"tool_count": 15,
"health": {
"level": "healthy",
"admin_state": "enabled",
"summary": "Connected (15 tools)",
"status": "ready",
"usable": true,
"actions": []
}
},
{
"name": "oauth-server",
"protocol": "http",
"enabled": true,
"connected": false,
"quarantined": false,
"tool_count": 0,
"health": {
"level": "unhealthy",
"admin_state": "enabled",
"summary": "Token expired",
"action": "login",
"status": "sign_in_required",
"usable": false,
"actions": ["login"]
}
}
]
}
}

Health Object Fields:

FieldTypeDescription
levelstringSeverity signal for badge/tray coloring only: healthy, degraded, or unhealthy. No surface may render this as text (Spec 109 FR-011) — render status through the one label table instead
admin_statestringAdmin state: enabled, disabled, or quarantined
summarystringHuman-readable status message
detailstringOptional additional context about the status
actionstringSuggested remediation, always equal to actions[0] (or empty when actions is empty): login, restart, enable, approve, view_logs, set_secret, configure, edit_url, or empty
statusstring(Spec 109 FR-010) The one status vocabulary every surface renders as text: ready, connecting, sign_in_required, needs_review, needs_secret, needs_config, error, disabled
usableboolean(Spec 109 FR-010) True only when status == "ready" — whether the server can currently serve tool calls
actionsstring[](Spec 109 FR-012) Every applicable next step, in priority order: login > set_secret > configure > edit_url > approve > restart > view_logs > enable

PATCH /api/v1/servers/{name}​

Partial update of an existing upstream server. All request fields are optional; omitted fields are preserved as-is.

The map-typed fields headers and env follow JSON Merge Patch (RFC 7396) semantics:

Value in patch bodyEffect on stored map
key present with a non-null string valueupsert (add or replace that key)
key present with JSON nulldelete that key
key absent from the patch bodypreserve as-is

This is the same convention the MCP upstream_servers patch tool uses. It lets the Web UI / macOS tray / CLI send a minimal diff — keys that match the server's current masked view (••••<last2> (<N> chars) — see Header redaction below) simply stay out of the patch body, so the real stored value is never overwritten by the mask string.

Request body (AddServerRequest — all fields optional):

{
"url": "https://api.example.com/mcp",
"command": "uvx",
"args": ["mcp-server-foo"],
"env": {"API_KEY": "new-value", "OLD_VAR": null},
"headers": {"X-Trace": "on", "X-Stale": null},
"working_dir": "/path/to/dir",
"protocol": "http",
"enabled": true,
"quarantined": false,
"auto_approve_tool_changes": true,
"isolation": {"enabled_override": true, "image": "node:20"}
}

Examples:

# Rotate a Bearer token without touching anything else on the server
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"headers":{"Authorization":"Bearer new-token"}}' \
http://127.0.0.1:8080/api/v1/servers/synapbus

# Remove a stale header (the JSON null is the delete signal)
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"headers":{"X-Stale":null}}' \
http://127.0.0.1:8080/api/v1/servers/synapbus

# Upsert one env var and delete another in a single round-trip
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"env":{"LOG_LEVEL":"debug","OBSOLETE":null}}' \
http://127.0.0.1:8080/api/v1/servers/obsidian-pilot

Notes:

  • Empty string "" is set-to-empty, NOT delete. JSON Merge Patch is explicit about this — only the JSON null token deletes.
  • Boolean fields (enabled, quarantined, reconnect_on_use, auto_approve_tool_changes) use pointer-style semantics: absent = preserve, present = explicit value. auto_approve_tool_changes is tri-state — it is omitted entirely from GET /api/v1/servers responses when never set, so a client can distinguish "unset" from an explicit false.

POST /api/v1/servers/{name}/config-to-secret​

Atomically move a header or env value out of mcp_config.json and into the OS keyring. The backend reads the real value from the loaded config, stores it in the keyring under secret_name, and rewrites the config field with ${keyring:<secret_name>}. The client never needs to possess the plaintext — useful when the API redacts sensitive header values on the read path.

Request body:

{
"scope": "header",
"key": "Authorization",
"secret_name": "synapbus-auth"
}
FieldTypeDescription
scopestringheader or env
keystringThe key on the server's headers / env map
secret_namestringName to store the value under in the OS keyring

Response (200 OK):

{
"success": true,
"data": {
"message": "header \"Authorization\" on \"synapbus\" now references keyring secret \"synapbus-auth\"",
"reference": "${keyring:synapbus-auth}"
}
}

Failure cases:

StatusCause
400Missing scope / key / secret_name, invalid scope, value is already a ${keyring:…} or ${env:…} reference, or value is empty
404Server or key not found
500Secret resolver unavailable, keyring store failed, or config update failed

This endpoint is what the Web UI and macOS tray "Convert to secret" button calls. It works even for headers the API redacts (the backend has the real value on disk).

POST /api/v1/servers/{name}/enable​

Enable a server.

POST /api/v1/servers/{name}/disable​

Disable a server.

POST /api/v1/servers/{name}/quarantine​

Place a server in quarantine to prevent tool execution. No request body required.

POST /api/v1/servers/{name}/unquarantine​

Remove a server from quarantine to allow tool execution. No request body required.

POST /api/v1/servers/{name}/restart​

Restart a server.

POST /api/v1/servers/{name}/login​

Initiate OAuth authentication flow for a server.

Response (200 OK):

{
"success": true,
"data": {
"success": true,
"server_name": "github-server",
"correlation_id": "a1b2c3d4e5f6789012345678",
"browser_opened": true,
"message": "OAuth authentication started for server 'github-server'. Please complete authentication in browser."
}
}

OAuthStartResponse Fields:

FieldTypeDescription
successbooleanAlways true for successful initiation
server_namestringName of the server being authenticated
correlation_idstringUnique ID for tracking this OAuth flow
auth_urlstringAuthorization URL (for manual browser opening)
browser_openedbooleanWhether browser was automatically opened
browser_errorstringError message if browser opening failed
messagestringHuman-readable status message

Error Response (400 Bad Request):

OAuth errors return structured error responses for better debugging:

{
"success": false,
"error_type": "dcr_failed",
"server_name": "github-server",
"message": "Dynamic Client Registration failed: 403 Forbidden",
"suggestion": "Check if the OAuth server requires pre-registered clients",
"correlation_id": "a1b2c3d4e5f6789012345678",
"request_id": "req-xyz-123",
"details": {
"metadata": {
"protected_resource_url": "https://api.example.com/.well-known/oauth-protected-resource",
"authorization_server_url": "https://auth.example.com/.well-known/oauth-authorization-server",
"status": "ok"
},
"dcr": {
"attempted": true,
"status": "failed",
"error": "403 Forbidden"
}
},
"debug_hint": "For logs: mcpproxy upstream logs github-server"
}

OAuthFlowError Fields:

FieldTypeDescription
error_typestringError category: client_id_required, dcr_failed, metadata_discovery_failed, code_flow_failed
server_namestringName of the server
messagestringHuman-readable error description
suggestionstringActionable remediation hint
correlation_idstringFlow tracking ID
request_idstringHTTP request ID for log correlation
detailsobjectDiagnostic details (metadata status, DCR status)
debug_hintstringCLI command for debugging

POST /api/v1/servers/{name}/logout​

Clear OAuth tokens and disconnect a server.

Tool Quarantine​

POST /api/v1/servers/{name}/tools/approve​

Approve pending or changed tools for a server. See Tool Quarantine for details.

Request Body:

{
"tools": ["create_issue", "delete_repo"]
}

Or approve all pending/changed tools:

{
"approve_all": true
}

Response:

{
"success": true,
"data": {
"approved": 2,
"tools": ["create_issue", "delete_repo"],
"message": "Approved 2 tools for server github-server"
}
}

POST /api/v1/servers/{name}/tools/block​

Atomically block tools = approve and disable them in a single server-side operation. Use this to acknowledge a pending/changed tool (clearing its quarantine flag) while keeping it hidden from MCP clients. The approve and disable land in one write per tool, so a tool is never left in the approved+enabled state.

Request Body:

{
"tools": ["create_issue", "delete_repo"]
}

Or block all pending/changed tools:

{
"block_all": true
}

Response:

{
"success": true,
"data": {
"blocked": 2,
"tools": ["create_issue", "delete_repo"],
"message": "Blocked 2 tools for server github-server"
}
}

Returns 400 if neither tools nor block_all is provided.

GET /api/v1/servers/{name}/tools/{tool}/diff​

Get the description/schema diff for a changed tool. The response exposes every field that participates in the approval hash — description, input schema, and output schema — so an operator can see exactly what changed. A change may affect only one of these (for example, an upstream adding a new enum value to the output schema leaves the description byte-identical).

Response:

{
"success": true,
"data": {
"server_name": "github-server",
"tool_name": "delete_repo",
"status": "changed",
"approved_hash": "abc123...",
"current_hash": "def456...",
"previous_description": "Delete a repository",
"current_description": "Delete a repository (modified description)",
"previous_schema": "...",
"current_schema": "...",
"previous_output_schema": "...",
"current_output_schema": "..."
}
}

GET /api/v1/servers/{name}/tools/export​

Export all tool descriptions and schemas for a server. Useful for audit and compliance.

Query Parameters:

  • format - Export format: json (default) or text

Routing​

GET /api/v1/routing​

Get the current routing mode and available MCP endpoints.

Response:

{
"success": true,
"data": {
"routing_mode": "retrieve_tools",
"description": "BM25 search via retrieve_tools + call_tool variants (default)",
"endpoints": {
"default": "/mcp",
"direct": "/mcp/all",
"code_execution": "/mcp/code",
"retrieve_tools": "/mcp/call"
},
"available_modes": ["retrieve_tools", "direct", "code_execution"],
"tool_response_mode": "full",
"direct_tool_response_mode": "full",
"pending_routing_mode": "",
"restart_required": false
}
}
FieldMeaning
routing_modeThe mode /mcp is actually serving — recorded when the server bound it at startup, not read back from the config. A config change (API or hand-edited file) cannot rebind /mcp.
pending_routing_modeThe mode the next start will adopt, when it differs from the served one. Empty when nothing is pending.
restart_requiredTrue exactly when pending_routing_mode is set.
tool_response_modeSpec 085 serialization of retrieve_tools results, resolved (full when unset). Hot-reloadable.
direct_tool_response_modeSpec 102 serialization of direct-surface listings, resolved (full when unset). Hot-reloadable.

A restart-gated change is persisted immediately and reported as pending; later changes to other settings apply hot and leave it pending. Re-applying the mode that is already being served clears it.

See Routing Modes for details on each mode.

MCP tool surface: compact responses and describe_tool (Spec 085)​

The MCP endpoints (not REST) additionally expose progressive-disclosure discovery in the retrieve_tools routing mode:

  • tool_response_mode config (full default | compact, hot-reloadable via POST /api/v1/config/apply) controls retrieve_tools serialization only. In compact mode each entry is {id, score, sig, desc, lossy} — a one-line parameter signature (* = required, ~ = lossy) plus a first-sentence description — instead of full inputSchema, and the response carries one top-level hint line. Ranking is identical between modes.
  • detail — optional per-call retrieve_tools parameter (compact | full) overriding the configured mode for that call.
  • describe_tool — built-in second-stage tool (retrieve_tools mode only): accepts 1–5 server:tool ids and returns full definitions (name, description, inputSchema, server, annotations, call_with) with per-id errors for ids that do not resolve. It applies the same visibility pipeline as search (profile scope, agent-token scope, quarantine, tool approval, disabled) and never returns a definition retrieve_tools could not. Per-id error codes: not_found, quarantined, pending_approval, changed, disabled (Spec 099 retired invisible: an out-of-scope id now reports not_found, indistinguishable from an id that does not exist — see the breaking-change note).
  • describe_tool check mode (Spec 099) — the same built-in with check: true answers availability instead of definitions: up to 50 ids, an optional filters object (read_only_only, exclude_destructive, exclude_open_world — the REST body of POST /api/v1/preflight calls the same object policy), and a response of {verdict, checked_at, request_id, results[]} carrying the same reason codes this endpoint returns. It is the in-band twin of POST /api/v1/preflight, evaluated by the same evaluator, and differs deliberately: always the agent-token disclosure tier (never a hash, never server_not_in_scope), the session's own scope with no profile parameter, no hash pins (expect_hashes is a reserved field name and is rejected), and no wait budget. See Required-Tools Preflight.

Tools​

GET /api/v1/tools​

Global tools overview (spec 050, issue #437): every tool from every configured server — including disabled servers and individually disabled / config-denied tools — enriched with approval state and 30-day usage. Read-only; consumers apply their own search/filter/sort over the full set. For relevance-ranked discovery use GET /api/v1/index/search instead.

Quarantine visibility: GET /api/v1/index/search withholds the tools of quarantined servers — their descriptions/schemas are the Tool Poisoning Attack payload quarantine exists to contain, so the REST search path applies the same server-level visibility gate as MCP retrieve_tools. Response shape is unchanged (bare tool name + separate server_name); only which tools appear is filtered. GET /api/v1/tools (above) is the unfiltered operator overview and still lists quarantined servers' tools with their state.

Query Parameters (view-as):

ParameterTypeDescription
clientstringAdministrator only. Show what this client would see and be able to call. The client's credential, binding and current state are resolved exactly as its connection resolves them. A caller that is not an administrator gets 403 operation requires admin access; an unknown client gets 404 client not found
profilestringShow what this profile would expose. An unknown profile, or one a non-administrator cannot reach, returns the same 404 profile not found

client and profile are mutually exclusive (400 use either client or profile, not both), - is not valid here (400), and token is not a tools filter (400 unsupported_scope_filter).

With either parameter every row gains profile_tier (the tool's tier under the subject's profile; tier stays the tool's intrinsic tier) and access {visible, callable, reason}. visible means the subject's discovery would list the tool; callable means a real call would succeed, and it equals the outcome of one for every row. reason is empty when callable, otherwise it names the first failing step of the access chain (credential, profile, server_in_scope, tool_rule, tier_cap, token_permission, global_gate, server_state, tool_approval), where the profile decision reports its own reasons: server_not_in_profile, denied_by_rule, unannotated_hidden, above_tier_cap. read_only_mode gates management operations only, never an upstream tool, so it is never a reason for an upstream row. Rows of a quarantined server are not listed, with or without view-as.

An administrator gets every row. A non-administrator (profile= only) gets just the rows that are visible under the profile, without a reason, plus counts {visible, hidden} for the rest; excluded rows, their tiers and their reasons are administrator-only. stats are recomputed over the returned rows. Without client or profile the response is unchanged.

Response:

{
"success": true,
"data": {
"tools": [
{
"name": "create_issue",
"server_name": "github",
"description": "Create a new GitHub issue",
"approval_status": "approved",
"disabled": false,
"config_denied": false,
"usage": 42,
"last_used": "2026-05-18T09:30:51Z"
}
],
"stats": { "total": 478, "enabled": 450, "disabled": 28, "pending_approval": 0 },
"partial": false,
"failed_servers": []
}
}

enabled is derived by the consumer as !disabled && !config_denied. When a server cannot be read the endpoint still returns every tool it could gather and sets partial: true with failed_servers (it does not fail the whole request).

Operator-tier callers (admin API key, Unix socket, named pipe) additionally receive each approved tool's current schema-hash pin in hash (sha256/v{N}:{hex}) — the authoring surface for POST /api/v1/preflight pins. Agent tokens never receive hashes.

GET /api/v1/servers/{name}/tools​

List tools for a specific server. Carries the same operator-tier hash pin field as the global listing.

POST /api/v1/preflight​

Required-tools preflight (Spec 098): a deterministic, side-effect-free availability check for a caller-supplied list of tool IDs. It performs zero upstream calls and mutates no runtime state — verdicts are computed from local state only (tool index, approval records, connection-state snapshot, config policy). The HTTP status reports whether the check executed, never what it found: a fully blocked set is still a 200 carrying verdict: "blocked" in the body. See Required-Tools Preflight for the feature guide and mcpproxy tools preflight for the CLI wrapper.

Request Body:

{
"tools": [
{ "id": "gh-ops:sync_issues" },
{ "id": "ctl:echo", "pin_hash": "sha256/v1:9f86d081884c7d65..." }
],
"profile": "work",
"policy": { "read_only_only": true },
"wait_ms": 5000
}
FieldTypeDescription
toolsarrayRequired, 1–100 entries — the limit applies to the raw array, before dedup. Each entry carries id (<server>:<tool>) and optional pin_hash. Duplicate IDs are deduplicated (one result per unique ID); duplicates carrying different pin_hash values are a 400.
profilestringOptional. Evaluate under this profile's server scope so verdicts match a profile-pinned session's view. Unknown profile: 400. Omitted: unscoped operator view.
policyobjectOptional annotation filters, Spec 094 semantics: read_only_only, exclude_destructive, exclude_open_world (evaluated in that fixed order; the first excluding filter owns the verdict).
wait_msintegerOptional, 0–10000. Poll local state while every failure is retryable-class (see below). Values over the cap are a 400, not a silent clamp.

Response (standard APIResponse{data} envelope):

{
"success": true,
"data": {
"verdict": "blocked",
"checked_at": "2026-08-15T06:00:00Z",
"waited_ms": 0,
"tools": [
{
"id": "gh-ops:sync_issues",
"status": "ready",
"hash": "sha256/v1:9f86d081884c7d65..."
},
{
"id": "slack:post_message",
"status": "unavailable",
"reason": "server_disabled",
"retryable": false,
"action": "enable",
"detail": "Server \"slack\" is disabled.",
"remediation": "Enable the server (mcpproxy upstream enable <server>)."
}
]
}
}

Results are ordered by first occurrence of each unique ID in the request. A ready result omits all failure fields — ready is a status, not a reason. An action with no value is omitted, not "none" (matching the health-action vocabulary). A malformed ID (missing the : separator) gets a per-ID not_found with a format hint in detail, never a request-level error — one bad entry cannot mask verdicts for the rest. not_found results may carry did_you_mean (up to 3 nearest caller-visible IDs). waited_ms is present whenever wait_ms was requested, including as 0 (see wait semantics).

Failure reasons (closed enum, Spec 098 FR-003). Evolution is additive-only; treat unknown codes as non-retryable. server_saturated is reserved and never emitted. When multiple states co-occur for one ID, exactly one reason is reported per the fixed precedence order (server-level states before tool-level; see the feature page).

reasonretryableDefault actionSet verdictCLI exit
server_initializingtrue— (omitted)degraded_retryable10
server_unhealthytruebest-effort from diagnostics (restart/login/view_logs; default view_logs)degraded_retryable10
server_disabledfalseenableblocked11
server_quarantinedfalseapproveblocked11
tool_pending_approvalfalseapproveblocked11
tool_changedfalseapproveblocked11
tool_blocked_by_userfalseenableblocked11
oauth_requiredfalseloginblocked11
hash_mismatchfalseconfigureblocked11
server_not_in_scope (operator tier only)falseconfigureblocked11
tool_denied_by_configfalseconfigureblocked11
missing_annotationfalseconfigureblocked11
policy_filteredfalse— (omitted)blocked11
not_foundfalseconfigureunknown_ids12
server_not_configuredfalseconfigureunknown_ids12

A tool_pending_approval occurrence for a tool the server's discovery snapshot contains but that has no stored approval record yet carries action: "restart" instead of approve, with a detail/remediation that points at re-discovering the server (upstream_servers operation="refresh" or mcpproxy upstream restart <server>): nothing is listed to approve until the server's next discovery pass files the record. The reason code, exit code and telemetry counter are unchanged.

The set-level verdict is the worst class present: unknown_ids > blocked > degraded_retryable > ready.

Status codes:

  • 200 — the check executed; the availability verdict is data in the body.
  • 400 — validation error: invalid JSON, empty or oversized (>100 raw entries) tools, a duplicate ID with conflicting pin_hash values, wait_ms out of range, or an unknown profile. The body is read strictly — at most 1 MiB, exactly one JSON object, and no unknown fields — so a mistyped key (wait for wait_ms, pin for pin_hash) fails loudly instead of silently weakening the check a pipeline then trusts.
  • 401 — missing or invalid credentials.
  • 503 — the check could not run honestly: the runtime is unavailable, an index/storage/snapshot read failed (reduced-fidelity verdicts are never emitted), or the activity record could not be persisted.

A request rejected with 400/503 executed no preflight and writes no activity record.

wait_ms semantics: polling happens only while every current failure is retryable-class (server_initializing / server_unhealthy). The endpoint re-evaluates local state on a floor interval of ≥250 ms until every tool is ready, a non-retryable failure appears (waiting cannot help, so it resolves immediately), or the deadline passes; it always resolves with current reasons — never hangs. Waiting capacity is a small fixed semaphore (4 slots) dedicated to preflight; when it is exhausted the request degrades gracefully — it resolves immediately with current verdicts and waited_ms: 0 instead of queuing or failing.

Disclosure tiers:

  • Operator tier (admin API key, Unix socket, Windows named pipe — plus the server edition's OAuth admin): full results — hash pins on ready results, did_you_mean suggestions, and the server_not_in_scope diagnosis when a supplied profile excludes an existing server (with a detail noting that a session under that profile sees not_found).
  • Agent-token tier (agent tokens, the server edition's ordinary OAuth users, and the whole in-band describe_tool check surface): scope-silence — an out-of-scope ID's entire result is byte-indistinguishable from an ordinary not_found (same wording; no hashes; no did_you_mean crossing the scope boundary). did_you_mean is computed over the caller-visible index only and never suggests a quarantined server's tools.

Activity-record guarantee: every request answered 200 writes an activity record synchronously, before the response is returned — request ID, requested-ID count (unique IDs, after dedup), set verdict, and per-tool reason codes (tool IDs and enum codes only; no descriptions, no arguments, no hashes; local-only, never telemetry). Correlate via the X-Request-Id response header and mcpproxy activity list --request-id <id>.

Hash pins (pin_hash): format sha256/v{N}:{hex}. The hash schema version is embedded so a proxy-side hash-algorithm bump is distinguishable from genuine upstream drift (both report hash_mismatch, with different detail). Current pins are discoverable on ready preflight results and on the operator-tier tool listings above.

Attention​

GET /api/v1/attention​

The one needs-attention list every surface renders (the Web UI Home page, header pill and sidebar badge, the macOS tray and Home section, mcpproxy attention, the first line of mcpproxy status and the first section of mcpproxy doctor). Both editions. See Needs Attention for the kinds, ranks and fixes.

Administrators see every item. A scoped caller (an agent token, or a non-admin user session) sees only the items whose server it may enumerate, never a client or setting item, with count recomputed from the narrowed list.

{
"success": true,
"data": {
"count": 2,
"generated_at": "2026-09-25T06:12:03Z",
"items": [
{
"id": "sign_in_required:server:github",
"kind": "sign_in_required",
"rank": 10,
"subject": {"type": "server", "id": "github", "name": "github"},
"summary": "github: sign in required",
"detail": "OAuth · api.githubcopilot.com",
"fix": {"verb": "login", "label": "Sign in", "target": "/servers/github"},
"since": "2026-09-25T06:02:11Z"
},
{
"id": "server_review:server:github",
"kind": "server_review",
"rank": 50,
"subject": {"type": "server", "id": "github", "name": "github"},
"summary": "github: waiting for review",
"fix": {"verb": "review", "label": "Review", "target": "/review/github"},
"since": "2026-09-25T06:01:40Z"
}
]
}
}

Items are sorted by rank ascending, then by subject.name; id (kind:type:subject[:state]) is stable, so a client can diff successive lists. fix.target is a Web UI route, and a fix never approves anything by itself. The SSE event attention.changed ({count, ids}) is emitted when the set of ids changes; see Real-time Updates.

Review​

GET /api/v1/review​

The review queue: one row per server awaiting review, either a quarantined server (kind: server_review) or a trusted server with new or changed tools (kind: tool_review, with pending and changed counts). count is the number of rows, the number the Review queue badge shows. For a scoped caller (an agent token or a non-admin user session) the queue lists only the servers that caller may see.

{
"success": true,
"data": {
"count": 2,
"servers": [
{"server": "filesystem", "kind": "server_review", "quarantined": true, "tools_captured": 14,
"tier_counts": {"read": 9, "write": 3, "destructive": 2, "unannotated": 0, "unknown": 0},
"scan": {"verdict": "clean", "risk_score": 0}, "since": "2026-09-25T06:01:40Z"},
{"server": "github", "kind": "tool_review", "quarantined": false, "pending": 0, "changed": 1,
"since": "2026-09-25T06:10:00Z"}
]
}
}

GET /api/v1/servers/{id}/review​

The review payload of one server: a summary of the server (secrets in the URL, headers, environment and command line are always redacted) and every tool with its captured definition. A server the caller cannot see answers the same 404 as a missing one.

{
"success": true,
"data": {
"server": {"name": "filesystem", "transport": "stdio", "quarantined": true, "trust_mode": "manual",
"scan": {"verdict": "clean", "risk_score": 0, "coverage": "current", "tools_scanned": 2},
"definitions_captured": true},
"tools": [
{"name": "edit_file", "description": "Make line-based edits to a text file", "input_schema": {"type": "object"},
"annotations": {"destructiveHint": true}, "tier": "destructive", "approval_status": "pending",
"disabled": false, "scan_verdict": "clean"},
{"name": "search_code", "description": "Search the code", "annotations": null, "tier": "unknown",
"approval_status": "changed", "scan_verdict": "warnings",
"previous": {"description": "Search files", "input_schema": {}, "annotations": null},
"diff": {"description": "@@ -1 +1 @@\n-Search files\n+Search the code", "input_schema": "", "annotations": ""}}
]
}
}
  • tier is read, write, destructive, unannotated (annotations captured, no hints) or unknown (nothing captured, a record from before the review screen). It comes from one function, so the Web UI, macOS, mcpproxy tools list --tier and the MCP quarantine_security inspect operations show the same value.
  • approval_status is approved, pending (shown as "New, needs review") or changed ("Changed, needs review").
  • scan.coverage says whether the scan verdict describes the definitions in the payload: current (the latest completed scan analysed every captured definition as it is now), stale (some definitions were added or changed after that scan; scan.unscanned_tools lists them), not_captured (no definitions captured), tools_not_scanned (the scan completed but exported no tool definitions), scanning (a scan is running) or none (no completed scan). Show risk_score only for current. scan.tools_scanned is the number of definitions that scan exported.
  • scan_verdict is dangerous, warnings, clean or not_scanned. clean means the latest scan covered this tool's current definition and found nothing; a tool whose definition changed after the scan is not_scanned (or carries its held verdict).
  • definitions_captured: false returns tools: []; POST /api/v1/servers/{id}/discover-tools captures the definitions without indexing them. After a baseline scan has listed a still-quarantined server's tools, MCPProxy runs the same capture itself.
  • Descriptions are returned verbatim and must be rendered as inert text.

The review decisions use these routes (all existing):

DecisionRoute
Approve serverPOST /api/v1/servers/{id}/security/approve with an optional {"force": true, "block": ["tool", ...]}; the block tools are blocked in the same transaction that records the integrity baseline, before the server is unquarantined
Reject serverPOST /api/v1/servers/{id}/security/reject
Approve toolsPOST /api/v1/servers/{id}/tools/approve
Reject (block) toolsPOST /api/v1/servers/{id}/tools/block

POST /api/v1/servers/{id}/unquarantine is kept for API compatibility only; no first-party surface calls it. See Review Commands.

Catalog​

GET /api/v1/catalog/search​

Search every enabled catalog source (registry) at once. Both editions; open to any authenticated caller, with added the only field that depends on the caller's scope.

ParameterDescription
qFree-text query. Empty returns results: [] and fills sections (official and popular, up to 12 each)
sourceNarrow to one catalog source id, applied before ranking and the limit
limitMaximum results (default 20, maximum 50)
tagNot supported: catalog entries carry no tags, so a non-empty value returns 400
{
"success": true,
"data": {
"query": "github",
"results": [
{"source": "official", "id": "io.github.github/github-mcp-server", "title": "GitHub",
"publisher": "github", "verified": true, "official": true, "popularity": {"stars": 21000},
"description": "GitHub's official MCP server", "transport": "http",
"install": {"url": "https://api.githubcopilot.com/mcp/"},
"required_inputs": [{"name": "GITHUB_TOKEN", "secret_like": true}], "added": false}
],
"sections": null,
"unavailable": [{"source": "community", "reason": "timeout after 5s"}]
}
}

Results are ranked by how well the name matches the query first (the publisher equals the query, then an exact name, a name prefix, a name word, a substring or description, and last a match through the namespace alone, which is how io.github.* entries match), then official source, verified publisher, popularity (a missing value counts as zero), title and id. verified means the publisher owns the source repository, official means the entry comes from a built-in source, and title is the server's own title when it has one. The order is identical on the Web UI, macOS, the CLI (mcpproxy catalog search) and the MCP search_servers tool. A source that fails or times out is listed in unavailable and the other sources' results are still returned. When the daemon has a recent listing of that source (at most 24 hours old, kept in memory and filled by every successful fetch), the matches come from it instead: those results carry from_cache: true and the unavailable entry gains fallback: "cached_listing" and cached_at, so the source still reads as unavailable. An empty q lists popular before official in every surface, and official starts with the curated reference servers. added is true when a configured server visible to the caller has the same source and install target, and then added_server_name names it. Adding an entry stays POST /api/v1/registries/{id}/servers/{serverId}/add, which always quarantines the new server; see Registry Add.

Registries​

Discover MCP servers in known registries and add them as quarantined upstreams. The daemon re-derives the runnable config server-side — the client never sends a config blob. See Adding servers from registries for the full feature guide (CLI, REST, MCP).

GET /api/v1/registries​

List configured registries.

POST /api/v1/registries​

Add a user-supplied custom registry source. JSON body: { "url": "https://…", "protocol": "…", "id": "…", "name": "…" } (only url required). The source is always tagged custom. Errors share a stable code: invalid_registry_url (400), registries_locked (403), registry_shadows_builtin / duplicate_registry (409).

PUT /api/v1/registries/{id}​

Edit a user-added custom registry source. JSON body: { "name": "…", "url": "https://…", "servers_url": "https://…" } (all optional; an omitted/empty field is left unchanged). Returns data.registry echoing the updated entry. Built-in registries are refused with registry_shadows_builtin (409); an unknown id returns registry_not_found (404); a non-https url returns invalid_registry_url (400); a registries_locked policy returns 403.

DELETE /api/v1/registries/{id}​

Remove a user-added custom registry source. Returns data.registry echoing the removed entry. Built-in registries are refused with registry_shadows_builtin (409); an unknown id returns registry_not_found (404); a registries_locked policy returns 403.

GET /api/v1/registries/{id}/servers​

Search a registry's servers (?search=, ?tag=, ?limit=).

POST /api/v1/registries/{id}/servers/{serverId}/add​

Add a server from a registry as an upstream (quarantined per the global default). Optional JSON body carries only overrides (never a config blob):

{ "name": "github-mcp", "env": { "GITHUB_TOKEN": "…" }, "enabled": true }

Success returns data.server (name, protocol, command, args, url, enabled, quarantined). A missing required input returns {"success": false, "code": "missing_required_input", "missing_inputs": [...]} — the same cross-surface code emitted by the CLI and MCP surfaces.

POST /api/v1/registries/{id}/refresh​

Drop a registry's cached server lists. Returns { "registry_id": "...", "cleared": <n> }.

Connect (client wizard)​

GET /api/v1/connect​

Lists the connection status of every known MCP client (Claude Desktop, Cursor, VS Code, Codex, Gemini, OpenCode, ZCode, …).

As of Spec 075, the overall listing determines each client's installed state using file-existence metadata only (os.Stat) and performs zero config content reads — so simply viewing status never triggers the macOS "wants to access data from other apps" privacy prompt. Each per-client object is additive-compatible and gains two fields:

FieldTypeMeaning
existsboolConfig file present (metadata only).
connectedboolmcpproxy registered in the config. Authoritative only when access_state == "accessible"; false/unresolved in the overall listing.
access_statestring"unknown" in the overall listing (not content-checked); resolved to "accessible", "absent", "malformed", or "denied" by an on-demand single-client read.
remediationstringPresent only when access_state == "denied"; carries the actionable fix text (App Data toggle + tccutil reset command).
proxy_urlstringThis instance's MCP endpoint. Config-derived (no file read), so it is present on the overall listing too.
registered_urlstringThe endpoint the client's existing entry actually points at, projected through the same sanitizer as a connect preview's existing_entry_summary.endpoint: scheme, host and path only — query (the ?apikey= carrier), userinfo and fragment are dropped, and a value that is not an absolute URL is not echoed at all. Resolved only by an on-demand read.
endpoint_matchstringHow registered_url relates to proxy_url: "this" (the entry addresses this instance), "other" (it addresses a different one), "unknown" (the entry has no comparable endpoint, e.g. a stdio command). Empty when connected is false or nothing was read.

A client that is installed but not yet content-checked reads as exists=true, connected=false, access_state="unknown". Resolving connected requires an explicit per-client read (the per-client status route below, connect/disconnect, or the CLI mcpproxy connect command), which is where a privacy prompt may legitimately appear.

connected alone is not "connected to this instance." It means an mcpproxy-shaped entry is present in that client's config — and an entry merely named mcpproxy counts, even when its URL addresses another instance on another port. Consumers that need the stronger claim must check endpoint_match == "this"; a UI showing a bare "Connected" on endpoint_match == "other" is reporting someone else's link as its own.

GET /api/v1/connect/{client}​

On-demand single-client status. Reads the one client's config at request time and returns a full ClientStatus with access_state resolved to accessible | absent | malformed | denied and connected set accordingly. This — like the other per-client routes below (preview, connect/disconnect, undo) — opens the client's config file at request time, so on macOS an App-Data privacy prompt may legitimately appear here (scoped to this user action), never from the overall listing. Unknown client → 404. A denial is reported in-band (200 with access_state="denied" + remediation), not as an HTTP error.

curl "http://127.0.0.1:8080/api/v1/connect/claude-desktop?apikey=your-api-key"

POST/DELETE /api/v1/connect/{client}​

Connect/disconnect are unchanged except that a permission-denied config access now returns 403 Forbidden whose error body carries the remediation text (distinct from a generic 400 or a 404 not-found).

Every connect/disconnect that modifies an existing config file first writes a timestamped backup next to it (<config>.bak.<YYYYMMDD-HHMMSS>, same directory and file mode) and returns its path as backup_path in the result. When two operations land in the same second, a numeric suffix keeps every backup distinct (<config>.bak.<YYYYMMDD-HHMMSS>-1, -2, …) — a backup is never overwritten. Backups accumulate one per operation and are never deleted automatically; there is no retention bound, so an undo (below) can always find its backup.

POST optionally accepts precondition_token (Spec 091) — the opaque token from the preview this write was confirmed against:

{ "server_name": "mcpproxy", "force": true, "precondition_token": "…" }

When supplied, the core recomputes the token at write time and, if it no longer matches, responds 409 Conflict having written nothing (the check runs before any backup or write). force=true does not override a stale token. When omitted, behavior is exactly as before.

The 409 body carries a top-level action discriminating the two conflict kinds:

actionMeaningCaller should
precondition_failedThe preview is stale — the config file, the existing entry, or the entry mcpproxy would now write has changed.Re-fetch the preview; do not blindly retry.
already_existsPre-existing semantics: an entry with that name is present and force was not set.Confirm with the user, then retry with force=true (and a fresh token).

See Connect Clients for the token's contents and threat model.

Client credential (Spec 108). The body also accepts profile (a profile name; "" is All servers; omitted means All servers for a fresh credential and the existing binding on a reconnect, including over an expired credential; a revoked one restarts at All servers), mode (locked or switchable) and keyless. The write embeds a per-client mcp_cli_ credential, never the admin API key, and the result carries credential (masked), token_name, profile, mode, keyless and, for a reconnect over an active credential, rotation (finalized). Refusals write nothing: 400 {error, field} for an unknown profile, an invalid mode, or keyless with require_mcp_auth on or with a profile; 409 binding_bypassable_without_auth (bindings, fixes) when the binding would be bypassable while require_mcp_auth is off; 409 with conflicting_token when client-<id> is held by a regular agent token.

PUT /api/v1/clients/{client}/binding​

Reassigns a client's profile and/or mode (personal edition, administrator only). Body {"profile": "<name or empty for All servers>", "mode": "locked|switchable"} (profile required; mode optional — omitted keeps the current mode, except that an empty profile means switchable). Applies only to a client that holds an active client credential; otherwise 409 {code: "no_client_credential"} and nothing is minted. Updates the credential in the token store, clears the stored set_profile selection of every live session of that credential, sends notifications/tools/list_changed to each and writes one profile_change activity record; the client's config file is never touched. A reassignment that would leave the binding bypassable is refused with 409 binding_bypassable_without_auth. PATCH /api/v1/config, POST /api/v1/config/apply and PATCH /api/v1/config/docker-isolation answer the same 409 when the write would create that condition. The response is {client, warnings}: the full client row (see GET /api/v1/clients) and the warnings about it.

GET /api/v1/connect/{client}/preview​

Returns the exact change a subsequent connect would make — target config path, format (json/toml), server key, entry name, and the exact entry contents — without modifying the file or creating a backup (Spec 078 US1). An embedded client credential is masked in the payload (credential, always mcp_cli_••••; contains_api_key is always false since connect never writes the admin API key); profile, mode and keyless echo the requested intent (?profile=&mode=&keyless=); entry_exists distinguishes a create from an overwrite of a same-named entry. Reads the config on demand to classify create-vs-overwrite, so on macOS this may raise an App-Data prompt; a denial returns 403 + remediation. Optional ?server_name= mirrors the name a subsequent connect would use.

Spec 091 adds three response fields:

FieldTypeMeaning
existing_entry_summaryobject, present only when entry_exists=trueSanitized description of the entry that would be replaced: entry_name (the key it actually lives under, which may differ from server_name when the write adopts an endpoint-equivalent entry), type, endpoint (query string, user:pass@ userinfo and fragment stripped), command, header_names and env_names — names only, never values. Built by whitelist projection, so no other config content can reach the response.
precondition_tokenstring, always presentOpaque keyed HMAC binding this preview to the exact pre-write state. Pass it to POST (above) to make a stale preview unwritable. Per-core-instance key, never persisted.
connect_refusalstring, optionalThe verbatim reason a subsequent connect would refuse regardless of user intent (today: a non-create-capable client such as OpenCode with no config present). Treat as "Connect unavailable".

The preview evaluates the refusal with the write's own guard, so the two cannot drift. Full semantics: Connect Clients.

POST /api/v1/connect/{client}/undo​

One-click undo of the immediately-preceding connect (Spec 078 US3). Body:

{ "server_name": "mcpproxy", "backup_name": "<basename of backup_path from the connect result>" }

backup_name is the bare filename of the backup the connect returned in backup_path — a name, never a path. The server resolves the full path itself inside that client's own config directory (derived from the client registry, not the request), so a caller-supplied value can never contribute a directory component and cannot escape the config dir (defense against path injection).

  • backup_name set — restores the config byte-for-byte from that backup. This is the only revert that can bring back a pre-existing same-named entry that a force=true connect overwrote (surgical DELETE /connect/{client} cannot).
  • backup_name empty — the connect created the file (its result carried no backup_path); undo deletes the created file, restoring the "no file" state.

Safety semantics:

  • Undo refuses with 409 Conflict when the config changed since the connect (it verifies the current file is byte-identical to what that connect wrote) — it never clobbers later edits. Fall back to DELETE /connect/{client} for a surgical entry removal.
  • A vanished backup returns 404; a backup_name that is a path (contains a directory separator) or does not match <config>.bak.* for that client returns 400.
  • Undo takes its own safety backup of the current file before restoring or deleting, returned as backup_path in the result (action = restored or deleted).
  • A macOS App-Data denial returns 403 + remediation, like the other per-client routes.
macOS App Data privacy & Connect​

On macOS, client configs (Claude Desktop, Cursor, VS Code, …) live under another app's container, gated by the Privacy & Security ▸ App Data TCC permission. If mcpproxy is denied, an on-demand read returns access_state="denied" with remediation. Fix it by enabling mcpproxy under System Settings ▸ Privacy & Security ▸ App Data, or reset the decision and retry:

tccutil reset SystemPolicyAppData com.smartmcpproxy.mcpproxy
# dev builds: com.smartmcpproxy.mcpproxy.dev

The overall GET /api/v1/connect listing never triggers this prompt (it is content-read-free); only the per-client routes above (status, preview, connect/disconnect, undo) can.

Clients (personal edition)​

Client routes live under /api/v1/clients and are administrator-only. They do not exist in the server edition, which has no per-client credentials.

GET /api/v1/clients​

Every client row (kind is supported, other or custom) and response-level warnings[]. The Spec 109 presence fields (id, display_name, kind, icon, state, installed, connected, config_path, display_path, last_seen, active_sessions, calls_24h, reload_hint) are unchanged; Profiles v3 adds:

FieldMeaning
credential_stateclient, admin_key, none, revoked, expired or unknown
credential_checked_atWhen the classification was last read from the client's config (present when it came from an observation)
token_name, profile, profile_title, profile_mode, profile_sourceThe binding. profile_source is pin (locked) or binding (switchable) and is empty unless credential_state is client
profile_missingThe bound profile no longer exists: the client is denied everything
expires_at, rotation_pending, blocked_24hCredential expiry, an unfinished rotation, blocked calls in the last 24 hours

The list never reads a client config (macOS shows no App-Data prompt for it). credential_state comes from the token store, else from the last on-demand classification (GET /clients/{client}, GET /connect/{client}, the admin-key upgrade preview), else it is unknown. A custom row is a credential added with POST /clients; a revoked custom row is omitted here and still served by the detail route.

warnings[] items are {code, severity, client_id?, message, action?, bindings?, fixes?}. Codes: anonymous_denied_by_binding_guard, client_holds_admin_key (action upgrade_admin_key_holders), client_credential_expiring, client_rotation_pending (severity info), profile_missing and client_token_name_conflict.

QueryMeaning
profileRows whose active credential is bound to this profile (a dangling pin included); - = bound to All servers. A row with no active credential matches no profile
clientExact client id (also other:<id> and custom ids)

The filters apply after warnings are computed over the full set. An unknown value is not an error. token is not a clients filter (400 unsupported_scope_filter).

GET /api/v1/clients/{client}​

The row plus sessions[] (the latest 20, each with its profile and profile_source). This is the one read that may open the client's config: it runs the full rotation reconciler first, classifies the credential the config holds and records the observation. No scope filter is honoured here.

POST /api/v1/clients​

Adds a custom client (one not in the connect registry). Body {id, display_name?, profile?, mode?, expires_in?}; expires_in defaults to and is capped at 365 days. 201 {client, credential, snippet}: credential is the mcp_cli_ secret, shown once; snippet.generic_http is a paste-ready JSON config that carries it in the X-API-Key header. Refusals: 400 {error, field} (the id rule, a supported client's id, profile, mode, expires_in, display_name), 409 binding_bypassable_without_auth, 409 with conflicting_token.

POST /api/v1/clients/{client}/rotate​

Replaces the secret without invalidating the old one mid-flight. For a supported client it rewrites the entry through connect (staged rotation, the binding is kept, finalized when the write succeeds, rolled back when it fails); body {precondition_token?} binds it to a connect preview, and a mismatch is the connect 409 with action: "precondition_failed". Response {client, connect, rotation: {state: "finalized"}}. For a custom client the response is {client, credential, snippet, rotation: {state: "pending"}}; both secrets authenticate until POST /clients/{client}/rotate/finalize or 24 hours. A client with no active credential is 409 no_client_credential.

POST /api/v1/clients/{client}/rotate/finalize​

Promotes the pending secret; the old one stops authenticating. Idempotent. {client, rotation: {state: "finalized"}}.

DELETE /api/v1/clients/{client}​

Revokes the client's credential. With disconnect=true a supported client's config entry is removed first, but the credential is revoked either way: revocation never waits for a file write. Response {revoked, disconnected, disconnect_error?}. No credential record: 409 no_client_credential.

POST /api/v1/clients/bulk-assign​

Body {from_profile, to_profile, mode?} (both required; "" = All servers). Moves every client bound to from_profile. The FR-008a guard and the credential precondition apply per client: {moved: [ids], skipped: [{client_id, code, error}]}. Each moved client writes an assign record and emits client.binding_changed.

POST /api/v1/clients/upgrade-admin-key-holders​

Moves every supported client whose config still holds the admin API key onto a per-client credential. Body {profile?, mode?, apply?, precondition_token?}. Without apply it returns {preview: [{client_id, display_name, display_path, diff, credential: "mcp_cli_••••", profile, mode, precondition_token}], precondition_token, guard?, next_step?}; nothing is written or minted, guard reports the refusal an apply would get, and next_step is rotate_admin_api_key when nothing holds the key. With apply: true a sent precondition_token that no longer matches is 409 with code: "precondition_failed"; a named profile that would make the bindings bypassable is 409 binding_bypassable_without_auth (nothing minted, no file written); otherwise {upgraded, failed: [{client_id, error}], next_step?}. The guard applies only when a named profile is given.

Profiles​

Profile routes exist in both editions. Every mutating route is administrator only and writes one profile_change record; none is gated by read_only_mode. A write that would let a bound client escape its profile by omitting its credential while require_mcp_auth is off is refused 409 binding_bypassable_without_auth (personal edition) and writes nothing.

GET /api/v1/profiles​

{profiles: ProfileView[], anonymous_profile?}. A ProfileView has the config fields as stored (name, title, description, servers, max_tier, unannotated, tools {allow, deny, classify}, code_execution, management_tools, switchable_to), the derived effective_servers, effective_unannotated, effective_code_execution, is_legacy, tool_counts {read, write, destructive, unannotated_hidden} (visible tools by the tier the profile gives them), the deprecated v2 tool_count, calls_24h, blocked_24h, and used_by {clients, tokens, anonymous_profile}.

A caller that is not an administrator sees only profiles its entitlement reaches (an unreachable one is omitted, never shown empty), with servers, rule entries and switchable_to narrowed to what it may see. used_by and anonymous_profile are administrator-only and omitted, not emptied, otherwise.

GET /api/v1/profiles/{name}​

One ProfileView. A non-administrator gets the same 404 profile not found for an unreachable profile as for an unknown one.

POST /api/v1/profiles, PUT /api/v1/profiles/{name}​

Body is a ProfileConfig. POST answers 201 {profile, warnings}; PUT answers 200 with the same shape and requires the body's name, when it is sent, to equal the path (409 name_mismatch). A PUT body whose name is omitted or an empty string takes the path's name (accepted by the spec as a convenience for the Web UI and CLI); a different non-empty name is always refused. 409 profile_exists; 400 {error, field} names the offending field with the unchanged validator text. active and try are reserved by the REST API. A PUT whose only change is tools.classify is recorded as classify.

POST /api/v1/profiles/{name}/rename​

Body {new_name}. Moves token pins, client bindings, other profiles' switchable_to and the anonymous_profile in one write; tokens move first, so a failure never widens a scope. {profile, moved: {clients, tokens}}.

DELETE /api/v1/profiles/{name}​

Query reassign_to and force. 409 profile_in_use (with used_by) while clients or tokens point at it, unless reassign_to names another existing profile (every pin moves there) or force leaves them dangling (deny-all). 409 profile_is_anonymous_profile whenever it is the anonymous_profile and reassign_to is absent, even with force. Both paths remove the name from every switchable_to. {deleted, moved, anonymous_profile_moved_to?}.

GET /api/v1/profiles/{name}/effective-tools​

Query client, server, reason. One row per catalog tool: server, tool, intrinsic_tier, profile_tier, access {visible, callable, reason}, classification_stale. With client= the client's credential is evaluated under this profile. An administrator also gets counts.callable, counts.by_reason and stale_classifications; every other caller gets only visible rows and counts {visible, hidden}, and client= / reason= are 403.

POST /api/v1/profiles/try​

Body {profile, query, limit?} (default 10, maximum 50). Evaluates a draft profile as retrieve_tools would, with policy applied before the limit, and returns the hits, hidden_by_profile and up to 100 hidden {server, tool, reason}. Nothing is persisted, no record is written, the guard does not run.

GET /api/v1/profiles/active, PUT /api/v1/profiles/active​

Deprecated. Both send Deprecation: true and a Link header whose successor-version is /api/v1/profiles. Behaviour and active_profile.changed are unchanged.

Access explain​

GET /api/v1/access/explain​

Query tool (an upstream server:tool) and exactly one of client, token, profile or anonymous=true. Administrator only. Returns the ordered chain of gates a real call would meet (credential, profile, server_in_scope, tool_rule, tier_cap, token_permission, global_gate, server_state, tool_approval), each pass, fail or skip; the verdict (allowed = callable, hidden = not visible, blocked = visible but not callable); the first_failure; and fixes[] for it in preference order, each {step, action, target, label}. The chain is the one every discovery and dispatch path walks, so allowed equals what a real call does. Errors: 400 exactly one of client, token, profile, anonymous is required, 400 use client=<id> for a client credential (a client- token name), 400 access/explain covers upstream tools (server:tool) only, 404 for an unknown client, token or profile.

Tokens​

GET /api/v1/tokens rows add kind (agent or client), client_id, profile_mode and legacy_scope (true when allowed_servers is not ["*"] or the permissions are not all three). ?profile=<name> matches the current profile_pin of either kind (- = unpinned) and ?token=<name> is an exact name; an unknown value returns no rows. POST /api/v1/tokens accepts profile (the Profiles v3 spelling of profile_pin; with it, omitted allowed_servers and permissions default to ["*"] and all three). A name starting with client- is 400 {error, field: "name"}.

Real-time Updates​

GET /events​

Server-Sent Events (SSE) stream for live updates.

curl "http://127.0.0.1:8080/events?apikey=your-api-key"

Events include:

  • servers.changed - Server status changed
  • config.reloaded - Configuration reloaded
  • tools.indexed - Tool index updated
  • activity.tool_call.started - Tool call initiated
  • activity.tool_call.completed - Tool call finished
  • activity.policy_decision - Tool call blocked by policy
  • profiles.changed - A profile was created, updated, renamed, deleted or the anonymous_profile changed (an invalidation: refetch GET /api/v1/profiles)
  • client.binding_changed - A client's profile or mode was reassigned (an invalidation: refetch GET /api/v1/clients)
  • attention.changed - The Needs attention list changed ({count, ids}, narrowed per subscriber; refetch GET /api/v1/attention).

The stream is rendered per connection. An admin subscriber (API key, Web UI, tray over the unix socket) receives every event exactly as the event bus published it. For an agent token limited by allowed_servers (issue #1166):

EventDelivered to a scoped subscriber
Names a server outside the scope, through server_name, server, target_server or affected_entity — every activity.*, oauth.* and security.* eventNo. The whole frame is dropped: blanking the name still discloses the mutation, its timing, and how many servers are hidden.
servers.changedYes, always — it is coalesced last-write-wins and carries renderable state. The embedded server list is narrowed, stats recomputed, and a coalescer extra naming an out-of-scope server is removed.
config.reloaded, config.saved, secrets.changedNo. They announce mutations of the admin config document, which GET /api/v1/config already answers 403 for this caller.
profiles.changed, client.binding_changedNo. They name profiles, clients and bindings a scoped caller may not reach. `profiles.changed {name, change: create
Everything else (active_profile.changed, activity.system.*, sensitive_data.detected, security.scanner_changed, …)Yes, unchanged: no server identity to scope.

Error Responses​

{
"error": "error message",
"code": "ERROR_CODE"
}
CodeDescription
401Unauthorized - Invalid or missing API key
404Not Found - Server or resource not found
500Internal Server Error

Configuration​

GET /api/v1/config​

Get current configuration.

POST /api/v1/config/apply​

Apply configuration changes.

POST /api/v1/config/validate​

Validate configuration without applying.

Diagnostics​

GET /api/v1/diagnostics​

Get system diagnostics.

GET /api/v1/doctor​

Run health checks (same as mcpproxy doctor CLI).

GET /api/v1/info​

Get application info, version, and update availability.

Response:

{
"success": true,
"data": {
"version": "v1.2.3",
"web_ui_url": "http://127.0.0.1:8080/?apikey=xxx",
"listen_addr": "127.0.0.1:8080",
"endpoints": {
"http": "127.0.0.1:8080",
"socket": "/Users/user/.mcpproxy/mcpproxy.sock"
},
"launched_by": "tray",
"pid": 4711,
"update_policy": {
"enabled": true,
"channel": "stable",
"nudges_suppressed": false
},
"update": {
"available": true,
"latest_version": "v1.3.0",
"release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0",
"checked_at": "2025-01-15T10:30:00Z",
"is_prerelease": false,
"install_channel": "homebrew",
"update_command": "brew upgrade mcpproxy",
"behind_summary": "8 releases / ~14 weeks behind",
"releases_behind": 8,
"weeks_behind": 14
}
}
}

Response Fields:

FieldTypeDescription
versionstringCurrent MCPProxy version
web_ui_urlstringURL to access the web control panel
listen_addrstringServer listen address
endpoints.httpstringHTTP API endpoint address
endpoints.socketstringUnix socket path (empty if disabled)
launched_bystringDurable launch provenance of the running core (Spec 092 FR-001a): tray when a tray spawned it, installer when the macOS PKG postinstall did, "" when user-launched or unknown. Always present. A tray uses this to decide whether it may stop and respawn a stale core it did not itself start — an empty value means consent is required.
pidintegerOS process id of the running core (Spec 092 FR-002). A tray that only attached to a core holds no process handle for it and the core exposes no shutdown endpoint, so this is the mechanism behind the consent-gated "restart the stale core" action.
update_policyobjectEffective, hot-reloadable update policy (Spec 092 FR-015). Always present, including every field, because the update object below is absent both when checking is disabled and when no check has produced a result yet — its absence cannot tell a client whether it is allowed to check.
update_policy.enabledbooleanWhether automatic update checks are allowed: update_check.enabled, with MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated "Check for Updates" stays available even when this is false. The macOS tray gates its Sparkle feed checks on this field.
update_policy.channelstringTracked release channel: stable or rc. Derived from the running build's own version — a released stable build always reports stable (never RC) and a released RC build always reports rc, regardless of update_check.channel / MCPPROXY_ALLOW_PRERELEASE_UPDATES (those only affect dev/unstamped builds). The tray maps rc onto the Sparkle beta feed channel, and additionally clamps to stable when its own app bundle is a stable release.
update_policy.nudges_suppressedbooleanThe core runs in a CI / non-interactive context: UI surfaces must stay quiet while machine-readable fields keep reporting the facts.
updateobjectUpdate information (may be null if not checked yet; omitted entirely when update checking is disabled via update_check.enabled: false or MCPPROXY_DISABLE_AUTO_UPDATE=true)
update.availablebooleanWhether a newer version is available
update.latest_versionstringLatest version available on GitHub
update.release_urlstringURL to the GitHub release page
update.checked_atstringISO 8601 timestamp of last update check
update.is_prereleasebooleanWhether the latest version is a prerelease
update.check_errorstringError message if update check failed
update.install_channelstringDetected install channel: homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, or unknown. Always present once detected, even when no update is available. See Version Updates for how detection works.
update.update_commandstringExact one-line update command for the detected channel. Only present when an update is available and the channel has a safe command (homebrew, deb, rpm, go-install); omitted for dmg/windows-installer/tarball/docker/unknown so a possibly-wrong command is never suggested.
update.behind_summarystringHuman-readable delta clause, e.g. 8 releases / ~14 weeks behind (Spec 079 FR-002). Render this verbatim rather than rebuilding it from the numbers below — it is authored once in the core so the CLI, Web UI banner and both trays cannot word it differently. Only present when an update is available and the delta could be resolved.
update.releases_behindintegerReleases on the offered channel between the running version and the offered one. Absent when unknown.
update.releases_behind_saturatedbooleanreleases_behind is a lower bound: the running build predates the scanned release window, so older releases were never counted. Clients render N+. Omitted when false.
update.weeks_behindintegerWhole weeks between the two releases' publish dates. 0 is a real value (a same-week release); absent means unknown, so do not conflate them.
Delta fields degrade silently

The four behind_* / *_behind fields are best-effort enrichment: resolving them needs the release list and publish dates, which is a second GitHub request. When that request fails, is rate-limited, or the running build has no release record, the fields are simply absent and every surface renders exactly the message it rendered before the delta existed. A missing delta never sets check_error and never suppresses the nudge.

Update Checking

MCPProxy automatically checks for updates every 4 hours. The update information is exposed via this endpoint and used by the tray application and web UI to show update notifications. Use ?refresh=true to force an immediate re-check. Checking is controlled by the update_check config block (enabled, channel) — see Version Updates; when disabled, ?refresh=true performs no check and the update object is omitted.

Docker​

GET /api/v1/docker/status​

Get Docker isolation status.

Response fields:

FieldTypeDescription
docker_availableboolGenuine Docker daemon reachability (result of a real docker info probe).
isolation_enabledboolWhether docker_isolation.enabled is set in config. The UI treats isolation as "active" only when both this and docker_available are true.
recovery_modeboolWhether the Docker recovery monitor is actively retrying.
failure_countintConsecutive recovery failures.
attempts_since_upintRecovery attempts since the daemon was last seen available.
last_attemptstringTimestamp of the last recovery attempt.
last_errorstringLast recovery error message, if any.
last_successful_atstringTimestamp of the last successful daemon contact.

Secrets​

GET /api/v1/secrets​

List stored secrets.

GET /api/v1/secrets/{name}​

Get secret metadata (not the value).

Sessions​

GET /api/v1/sessions​

List recent MCP sessions.

Query Parameters:

ParameterTypeDescription
limitintegerMax sessions (1-100, default: 10)
offsetintegerPagination offset (default: 0)
parent_idstringReturn only the sub-calls of one code_execution (value = the parent record's request_id)
statusstringFilter by session status: active, closed. Any other value returns 400.
profilestringSessions whose latest effective profile is this name; - selects sessions with none
clientstringSessions whose credential is bound to this client id; - selects sessions with none
tokenstringSessions that initialized with this token name; - selects sessions with none. agent is an alias of token; naming two different tokens returns 400

Each row carries client_id, token_name, profile and profile_source (pin, binding, url, session, anonymous or none); they are empty on sessions recorded before Profiles v3. client_name is not a sessions filter and returns 400 (client_name is not supported on this endpoint; filter by client). The scope filters are applied before the limit truncation, so total is the filtered count.

The status filter is applied during the storage walk, before the limit truncation, so a long-running session that is still active is returned even when newer sessions would otherwise fill the page. When status is set, total counts the matching sessions rather than every stored session.

Caveat (spec 082): handshake-only sessions are not persisted, so a connected but idle client does not appear until its first tool call.

GET /api/v1/sessions/{id}​

Get session details.

Activity​

Track and audit AI agent tool calls. See Activity Log for detailed documentation.

GET /api/v1/activity​

List activity records with filtering and pagination.

Query Parameters:

ParameterTypeDescription
typestringFilter by type: tool_call, policy_decision, quarantine_change, server_change, preflight
serverstringFilter by server name
toolstringFilter by tool name
session_idstringFilter by MCP session ID
statusstringFilter by status: success, error, blocked
profilestringFilter by the profile in effect when the call ran (also matches the legacy metadata.profile); - selects records with none
clientstringFilter by client id (the client's binding); - selects records with none
tokenstringFilter by the token name in effect (also matches records written before Profiles v3 through their stored agent name); - selects records with none. agent is an alias of token; naming two different tokens returns 400
client_namestringFilter by the client's self-reported clientInfo.name. Advisory, never authoritative: a client can claim any name. Accepted by GET /activity and GET /activity/export only
start_timestringFilter after this time (RFC3339)
end_timestringFilter before this time (RFC3339)
limitintegerMax records (1-100, default: 50)
offsetintegerPagination offset (default: 0)

Scope attribution (Profiles v3). Every record written for an MCP or REST request carries the values in effect when the call ran: profile, profile_source, client_id, client_name, token_name, and block_reason for a blocked call. They are never rewritten, so reassigning a client later does not change history. Records with no request context (system events, configuration changes, concurrency-limiter rejections) are unattributed, and a profile_change record carries the new profile, the client id and the token name.

A non-administrator caller sees another token's profile, profile_source, client_id and token_name blanked, and its own profile, client and token filters evaluate that same view, so a filter cannot be used to learn what another token did.

The same profile, client and token filters (and the agent alias) apply to GET /api/v1/activity/summary, GET /api/v1/activity/usage and GET /api/v1/activity/export; client_name is rejected on /activity/summary and /activity/usage with 400. Under a scope filter /activity/usage is computed from the matching records of the requested window (per-tool figures are window-bounded rather than lifetime, and the global tokens-saved headline is omitted). Export CSV appends profile,profile_source,client_id,client_name,token_name,block_reason after parent_id.

Response:

{
"success": true,
"data": {
"activities": [
{
"id": "01JFXYZ123ABC",
"type": "tool_call",
"server_name": "github-server",
"tool_name": "create_issue",
"status": "success",
"duration_ms": 245,
"timestamp": "2025-01-15T10:30:00Z"
}
],
"total": 150,
"limit": 50,
"offset": 0
}
}

GET /api/v1/activity/{id}​

Get full activity record details including request arguments and response data.

GET /api/v1/activity/export​

Export activity records for compliance and auditing.

Query Parameters:

ParameterTypeDescription
formatstringExport format: json (JSON Lines) or csv
(filters)Same filters as list endpoint

Example:

# Export as JSON Lines
curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity/export?format=json"

# Export as CSV
curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity/export?format=csv"

Bulk Operations​

POST /api/v1/servers/enable_all​

Enable all servers.

POST /api/v1/servers/disable_all​

Disable all servers.

POST /api/v1/servers/restart_all​

Restart all servers.

POST /api/v1/servers/reconnect​

Reconnect all servers.

OpenAPI Specification​

The complete OpenAPI 3.1 specification is available at:

  • /swagger/ - Interactive Swagger UI
  • /swagger/swagger.yaml - Raw specification

See oas/swagger.yaml in the repository for the complete API reference.