MCP Gateway Registry provides two external integration points for registration lifecycle events: notification webhooks that fire after a registration or deletion, and a registration gate (admission control) that can approve or deny registrations and updates before they are persisted.
MCP Gateway Registry can send HTTP webhook notifications when servers, agents, or skills are registered (added) or deleted (removed). This enables external systems to react to registry changes in real time, for example updating a CMDB, triggering a CI/CD pipeline, sending a Slack notification, or syncing with a third-party inventory.
Registration webhooks are fire-and-forget: the registry sends an async POST to a configurable URL after a successful registration or deletion, logs the result, and moves on. A webhook failure never blocks or rolls back the operation that triggered it.
| Event Type | Trigger | Asset Types |
|---|---|---|
registration |
A new asset is added to the registry | server, agent, skill |
update |
An existing asset's metadata is edited (PUT/PATCH) | server, agent, skill |
deletion |
An existing asset is removed from the registry | server, agent, skill |
scan_complete |
The async security scan finishes after registration | server, agent, skill |
The scan_complete event (Issue #1330) is the registry's second outbound webhook in the
lifecycle workflow: the first integration point is the synchronous registration gate
("quick checks"), and scan_complete reports the asynchronous security-scan result once it
finishes. Lifecycle status transitions (draft to beta to active to deprecated) are driven by
the operator via PATCH and deliberately emit no webhook.
| Decision | Choice | Rationale |
|---|---|---|
| Delivery model | Fire-and-forget | Registry availability is never affected by webhook failures |
| Failure handling | Log at WARNING level | Operators can monitor via CloudWatch or log aggregation |
| Auth header handling | Auto-prefix Bearer for Authorization header | Follows RFC 6750 convention without extra config |
| HTTPS enforcement | Warn but allow HTTP | Avoids breaking dev/test setups while flagging insecure production use |
| Variable | Type | Default | Description |
|---|---|---|---|
REGISTRATION_WEBHOOK_URL |
string | "" (disabled) |
Full URL to POST to. Only http:// and https:// schemes are accepted. Leave empty to disable. |
REGISTRATION_WEBHOOK_AUTH_HEADER |
string | Authorization |
Name of the HTTP header used for authentication. If set to Authorization, the token is auto-prefixed with Bearer . For any other header (e.g. X-API-Key), the token is sent as-is. |
REGISTRATION_WEBHOOK_AUTH_TOKEN |
string | "" |
Auth token value. Leave empty for unauthenticated webhooks. |
REGISTRATION_WEBHOOK_TIMEOUT_SECONDS |
int | 10 |
HTTP timeout per request in seconds. |
REGISTRATION_WEBHOOK_SIGNING_SECRET |
string | "" (disabled) |
Shared secret for HMAC-SHA256 signing of the payload. When set, an X-Registry-Signature: sha256=<hex> header is added over the exact transmitted body. |
REGISTRATION_ENFORCED_STATUS |
string | "" (disabled) |
When set (e.g. draft), mandates the initial lifecycle status for new registrations; a mismatched status fails with 4xx. |
Unauthenticated webhook (dev/test):
REGISTRATION_WEBHOOK_URL=https://hooks.example.com/registry
REGISTRATION_WEBHOOK_AUTH_HEADER=Authorization
REGISTRATION_WEBHOOK_AUTH_TOKEN=
REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=10Bearer token authentication:
REGISTRATION_WEBHOOK_URL=https://hooks.example.com/registry
REGISTRATION_WEBHOOK_AUTH_HEADER=Authorization
REGISTRATION_WEBHOOK_AUTH_TOKEN=my-secret-bearer-token
REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=10The request will include Authorization: Bearer my-secret-bearer-token.
Custom API key header:
REGISTRATION_WEBHOOK_URL=https://hooks.example.com/registry
REGISTRATION_WEBHOOK_AUTH_HEADER=X-API-Key
REGISTRATION_WEBHOOK_AUTH_TOKEN=my-api-key-value
REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=5The request will include X-API-Key: my-api-key-value.
Every webhook POST sends a JSON body with the following structure:
{
"event_type": "registration",
"registration_type": "agent",
"timestamp": "2026-04-23T14:30:00.000000+00:00",
"performed_by": "admin@example.com",
"card": {
"name": "My Agent",
"path": "/agents/my-agent",
"description": "An example A2A agent",
"...": "full card data as stored in the registry"
}
}| Field | Type | Description |
|---|---|---|
event_type |
string | "registration" (asset added) or "deletion" (asset removed) |
registration_type |
string | "server", "agent", or "skill" |
timestamp |
string | ISO 8601 timestamp in UTC |
performed_by |
string or null | Username of the operator who performed the action (null if unknown) |
card |
object | The full card JSON as stored in the registry |
| Aspect | Value |
|---|---|
| Method | POST |
| Content-Type | application/json |
| Timeout | Configurable via REGISTRATION_WEBHOOK_TIMEOUT_SECONDS |
| Retries | None (fire-and-forget) |
| TLS verification | Enabled by default (httpx default behavior) |
The webhook environment variables must be set on the registry service (not the auth server).
All three Compose files (docker-compose.yml, docker-compose.podman.yml, docker-compose.prebuilt.yml) pass the variables to the mcp-gateway-registry service:
services:
mcp-gateway-registry:
environment:
- REGISTRATION_WEBHOOK_URL=${REGISTRATION_WEBHOOK_URL:-}
- REGISTRATION_WEBHOOK_AUTH_HEADER=${REGISTRATION_WEBHOOK_AUTH_HEADER:-Authorization}
- REGISTRATION_WEBHOOK_AUTH_TOKEN=${REGISTRATION_WEBHOOK_AUTH_TOKEN:-}
- REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=${REGISTRATION_WEBHOOK_TIMEOUT_SECONDS:-10}The variables are defined in terraform/aws-ecs/variables.tf and wired into the registry ECS task definition via terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf (inside module "ecs_service_registry").
Set values in terraform.tfvars:
registration_webhook_url = "https://hooks.example.com/registry"
registration_webhook_auth_header = "X-API-Key"
registration_webhook_auth_token = "my-api-key"
registration_webhook_timeout_seconds = 10For sensitive values (tokens), use AWS Secrets Manager references instead of plaintext in tfvars.
The variables are defined in charts/registry/values.yaml and mapped in the deployment template and secret:
# charts/registry/values.yaml
registrationWebhook:
url: ""
authHeader: "Authorization"
authToken: ""
timeoutSeconds: 10Sensitive values (auth tokens) are stored in the Kubernetes secret (charts/registry/templates/secret.yaml) and injected via secretKeyRef.
The webhook service logs at three levels:
| Level | Condition | Example Message |
|---|---|---|
| INFO | Webhook sent successfully | Registration webhook sent: event=registration, type=agent, status=200, url=https://... |
| WARNING | Timeout or connection failure | Registration webhook timed out after 10s: event=registration, type=agent, url=https://... |
| WARNING | HTTP (not HTTPS) URL configured | Registration webhook URL uses HTTP (not HTTPS). Credential data may be transmitted insecurely. |
| ERROR | Invalid URL scheme | Invalid webhook URL scheme: ftp://... |
In ECS deployments, these log messages appear in the registry task's CloudWatch Log Group.
A minimal webhook receiver only needs to accept a POST with a JSON body and return a 2xx status code. Here is a Python example:
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhook")
async def handle_webhook(request: Request):
payload = await request.json()
event = payload.get("event_type")
asset_type = payload.get("registration_type")
card = payload.get("card", {})
name = card.get("name") or card.get("display_name", "unknown")
print(f"Received {event} event for {asset_type}: {name}")
# Your custom logic here:
# - Send a Slack notification
# - Update a CMDB
# - Trigger a CI/CD pipeline
# - Sync with an external inventory
return {"status": "ok"}Run with: uvicorn receiver:app --host 0.0.0.0 --port 6789
| Symptom | Cause | Fix |
|---|---|---|
| No webhook logs at all | REGISTRATION_WEBHOOK_URL is empty or not set |
Set the variable in the correct service |
| Webhook env vars set but no calls | Variables on the wrong ECS service | Ensure they are on the registry service, not the auth server |
| Timeout warnings | Receiver too slow or unreachable | Increase REGISTRATION_WEBHOOK_TIMEOUT_SECONDS or check network connectivity |
| HTTP warning in logs | URL uses http:// instead of https:// |
Switch to HTTPS for production |
This section covers the additions that let an external orchestrator drive the asset lifecycle (draft to beta to active to deprecated) entirely on registry events and APIs.
After an asset is registered, the registry runs an asynchronous security scan. When the
scan finishes (whether the asset is safe, unsafe, or the scan errored), the registry fires a
scan_complete webhook. This is the signal an external CI/CD pipeline should act on, rather
than polling GET /api/servers/{path}/security-scan.
{
"event_type": "scan_complete",
"registration_type": "server",
"timestamp": "2026-06-25T14:30:00Z",
"performed_by": "alice",
"card": { "...sanitized card..." },
"scan": {
"is_safe": true,
"severity_counts": { "critical": 0, "high": 0, "medium": 2, "low": 5 },
"tags_applied": [],
"auto_disabled": false,
"scan_error": null
}
}is_safe:true/false, ornullwhen the scan itself errored.tags_applied: e.g.["security-pending"]when the scan flagged the asset.auto_disabled:truewhen the asset was disabled because it failed the scan andSECURITY_BLOCK_UNSAFE_SERVERS(or the agent/skill equivalent) is enabled.scan_error: a short, sanitized message when the scan raised (never a stack trace).
When REGISTRATION_WEBHOOK_SIGNING_SECRET is set, every outbound webhook carries an
X-Registry-Signature: sha256=<hex> header computed as HMAC-SHA256 over the exact bytes of
the request body. Verify with a constant-time compare:
import hmac
import hashlib
def verify(raw_body: bytes, header_sig: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_sig) # constant-timeCompute the signature over the raw request body (the exact bytes received), not a re-serialized copy, or the digest will not match.
- Not ordered. Events are fire-and-forget with no retry. A consumer may receive
scan_completebeforeregistration. Key offcard.path+event_typeand be idempotent. Treat the registry as the source of truth and reconcile viaGET /api/servers?status=...rather than relying on delivery. - No durability. A dropped delivery is not retried. The reconciliation poll above is the backstop; do not treat a missing event as "nothing happened".
- Replay protection. The signature covers the body, which includes
timestamp. Consumers should reject events whosetimestampis outside a small skew window (e.g. 5 minutes) to limit replay of captured signed events. - Secret rotation. Rotating
REGISTRATION_WEBHOOK_SIGNING_SECRETis a hard cutover. To rotate without dropped events, have the consumer temporarily accept both the old and new secret during the rotation window.
Set REGISTRATION_ENFORCED_STATUS=draft so that every new asset must start as draft. A
registration with no status is forced to draft; a registration with a different explicit
status fails with HTTP 400. This lets an operator guarantee nothing becomes active without
going through an external promotion pipeline. The default (unset) preserves current behavior
(new assets default to active, except skills which already default to draft).
Changing an asset's lifecycle status via PUT/PATCH requires a dedicated
change_lifecycle_status UI permission, separate from modify_service. This lets a scope
grant "register and edit metadata" without granting "promote/deprecate":
- A scope with
register_servicebut withoutchange_lifecycle_statuscan register and edit an asset, but receives 403 when a request changes thestatusfield. - Admins always pass (existing admin scopes predate this permission).
- Ordinary metadata edits (description, tags, URL) are unaffected and stay under
modify_service.
GET /api/servers?status=draft (or status=beta) returns only assets at that lifecycle
status, for an admin "review queue". status=active also matches legacy documents that have
no status field. The filter is applied for both unrestricted and permission-restricted
users.
webhook_send_total{event_type, outcome} counts every outbound webhook by event type and
outcome (success, timeout, error, skipped_no_url) so operators can detect a silently
unreachable consumer endpoint. registration_status_rejected_total{registration_type} counts
registrations rejected by the enforced-status policy.
The registration gate is an admission control webhook called before a registration or update is persisted. Unlike the notification webhook above (which fires after the fact and cannot block the operation), the registration gate can approve or deny a request based on custom business logic such as naming conventions, compliance rules, or approval workflows.
| Aspect | Notification Webhook | Registration Gate |
|---|---|---|
| Timing | After the registration is persisted | Before the registration is persisted |
| Can block registration | No (fire-and-forget) | Yes (approve/deny) |
| Failure behavior | Logged, never blocks caller | Fail-closed: blocks registration if gate is unavailable |
| Retries | None | Configurable with exponential backoff |
| Applies to | Registration and deletion events | Registration and update events |
| Credential handling | Full card data sent | Credentials stripped from payload |
- Approve or deny registrations and updates for servers, agents, and skills
- Configurable authentication: none, API key, or Bearer token
- Fail-closed design: if the gate is unreachable after retries, registration is blocked
- Custom denial messages returned to the caller as HTTP 403
- Sensitive fields (credentials, tokens, passwords) are automatically stripped from the payload sent to the gate
- Exponential backoff retries (0.5s, 1s, 2s, ...)
- Startup connectivity check (non-blocking, logs warnings if gate is unreachable)
The registry sends a POST request to the gate URL with the following JSON body:
{
"asset_type": "agent",
"operation": "register",
"source_api": "/api/agents/register",
"registration_payload": { ... },
"request_headers": { "host": "...", "content-type": "..." }
}Fields:
| Field | Description |
|---|---|
asset_type |
"agent", "server", or "skill" |
operation |
"register" or "update" |
source_api |
The API path that triggered the request |
registration_payload |
The registration data with sensitive fields removed |
request_headers |
HTTP headers from the original request (sensitive headers excluded) |
Gate Response Codes:
| Status Code | Meaning |
|---|---|
200 |
Registration allowed |
403 |
Registration denied. Response body may include {"error": "reason"} |
| Any other | Triggers retry (unexpected status) |
The following fields are automatically removed from registration_payload before sending to the gate:
- Fields named:
auth_credential,auth_credential_encrypted,auth_header_name - Fields containing:
credential,secret,token,password,api_key
Sensitive request headers are also excluded: authorization, cookie, x-csrf-token.
| Variable | Default | Description |
|---|---|---|
REGISTRATION_GATE_ENABLED |
false |
Enable/disable the gate |
REGISTRATION_GATE_URL |
(empty) | URL of the gate endpoint. Must be set when enabled |
REGISTRATION_GATE_AUTH_TYPE |
none |
Auth type: none, api_key, bearer, or oauth2_client_credentials |
REGISTRATION_GATE_AUTH_CREDENTIAL |
(empty) | API key or Bearer token value (for api_key or bearer auth) |
REGISTRATION_GATE_AUTH_HEADER_NAME |
X-Api-Key |
Header name for api_key auth type |
REGISTRATION_GATE_TIMEOUT_SECONDS |
5 |
HTTP timeout per attempt (seconds) |
REGISTRATION_GATE_MAX_RETRIES |
2 |
Retry attempts after first failure (exponential backoff) |
REGISTRATION_GATE_OAUTH2_TOKEN_URL |
(empty) | OAuth2 token endpoint URL (required for oauth2_client_credentials) |
REGISTRATION_GATE_OAUTH2_CLIENT_ID |
(empty) | OAuth2 client ID (required for oauth2_client_credentials) |
REGISTRATION_GATE_OAUTH2_CLIENT_SECRET |
(empty) | OAuth2 client secret (required for oauth2_client_credentials) |
REGISTRATION_GATE_OAUTH2_SCOPE |
(empty) | OAuth2 scope parameter (optional, e.g. api://app-id/.default for Entra) |
When the gate endpoint is protected by an OAuth2 identity provider (e.g., Microsoft Entra ID, Okta, Auth0, Keycloak, or Cognito), set REGISTRATION_GATE_AUTH_TYPE=oauth2_client_credentials and configure the token endpoint credentials. The registry acquires a fresh access token via the OAuth2 Client Credentials flow (RFC 6749 Section 4.4) before each gate call.
How it works:
- Before calling the gate endpoint, the registry POSTs to the configured token URL with
grant_type=client_credentials,client_id,client_secret, and optionallyscope - If the token endpoint returns a valid
access_token, the registry sends it asAuthorization: Bearer <token>to the gate - If token acquisition fails (timeout, invalid credentials, network error), the registration is blocked immediately (fail-closed). No gate call is attempted.
OAuth2 Configuration Parameters:
| Variable | Default | Required | Description |
|---|---|---|---|
REGISTRATION_GATE_OAUTH2_TOKEN_URL |
(empty) | Yes | OAuth2 token endpoint URL. This is the IdP endpoint that issues access tokens via the client credentials grant. Example (Entra): https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token |
REGISTRATION_GATE_OAUTH2_CLIENT_ID |
(empty) | Yes | OAuth2 client ID (also called "application ID" in Entra). The service principal identity used to authenticate with the token endpoint. |
REGISTRATION_GATE_OAUTH2_CLIENT_SECRET |
(empty) | Yes | OAuth2 client secret. The credential paired with the client ID. This value is sensitive and is masked on the System Config page. Never logged. |
REGISTRATION_GATE_OAUTH2_SCOPE |
(empty) | No | OAuth2 scope or resource parameter sent in the token request. Some IdPs require this (e.g., Entra requires api://{app-id}/.default), others use it optionally or not at all. Leave empty if your IdP does not require a scope for client credentials grants. |
All four parameters are available in Docker (.env / docker-compose.yml), Terraform/ECS (variables.tf / ecs-services.tf), Helm/EKS (values.yaml / secret.yaml), and the System Config page in the UI.
Example configuration (Entra ID):
REGISTRATION_GATE_ENABLED=true
REGISTRATION_GATE_URL=https://gate.example.com/check
REGISTRATION_GATE_AUTH_TYPE=oauth2_client_credentials
REGISTRATION_GATE_OAUTH2_TOKEN_URL=https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
REGISTRATION_GATE_OAUTH2_CLIENT_ID=your-client-id
REGISTRATION_GATE_OAUTH2_CLIENT_SECRET=your-client-secret
REGISTRATION_GATE_OAUTH2_SCOPE=api://your-app-id/.defaultExample configuration (Okta):
REGISTRATION_GATE_AUTH_TYPE=oauth2_client_credentials
REGISTRATION_GATE_OAUTH2_TOKEN_URL=https://dev-123456.okta.com/oauth2/default/v1/token
REGISTRATION_GATE_OAUTH2_CLIENT_ID=your-client-id
REGISTRATION_GATE_OAUTH2_CLIENT_SECRET=your-client-secret
REGISTRATION_GATE_OAUTH2_SCOPE=api://gateExample configuration (Keycloak):
REGISTRATION_GATE_AUTH_TYPE=oauth2_client_credentials
REGISTRATION_GATE_OAUTH2_TOKEN_URL=https://keycloak.example.com/realms/mcp-gateway/protocol/openid-connect/token
REGISTRATION_GATE_OAUTH2_CLIENT_ID=your-client-id
REGISTRATION_GATE_OAUTH2_CLIENT_SECRET=your-client-secret
REGISTRATION_GATE_OAUTH2_SCOPE=Startup validation: At startup, the registry verifies that all required OAuth2 fields (token_url, client_id, client_secret) are set when auth_type is oauth2_client_credentials, and attempts a test token acquisition. Warnings are logged if the token URL uses HTTP instead of HTTPS, or if the test token acquisition fails.
See issue #917 for the full design specification.
The gate is checked on the following operations:
| Asset Type | Operation | Endpoint |
|---|---|---|
| Agent | Register | POST /api/agents/register |
| Agent | Update | PUT /api/agents/{path} |
| Server | Register | POST /servers/register, POST /internal/register, POST /api/servers/register |
| Server | Update | POST /edit/{path} |
| Skill | Register | POST /api/skills |
| Skill | Update | PUT /api/skills/{path} |
A minimal Python gate endpoint that approves all registrations:
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/gate")
async def gate(request: Request):
body = await request.json()
# Implement your approval logic here
return {"status": "allowed"}To deny a registration, return HTTP 403 with an error message:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/gate")
async def gate(request: Request):
body = await request.json()
name = body.get("registration_payload", {}).get("name", "")
if not name.startswith("prod-"):
return JSONResponse(
status_code=403,
content={"error": "All production assets must start with 'prod-'"},
)
return {"status": "allowed"}See issue #809 for the full design specification.
