# Use Event Gateway as a Forward Proxy for MCP Events

A forward proxy for MCP Events is a service that receives webhook deliveries on your agent's behalf and passes the events on to it. Your agent subscribes with the proxy's URL as its callback URL, so the MCP server delivers to the proxy instead of to the agent.

[MCP Events](/webhooks/guides/what-are-mcp-events) is a draft extension to the Model Context Protocol, proposed in [SEP-3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415). SEP-3415 describes a forward proxy as a common deployment for webhook delivery. This guide follows SEP-3415 as of commit `c47fd24`, and details may change before the proposal is accepted.

Hookdeck Event Gateway can act as the forward proxy. An MCP Events source is the callback URL the MCP server delivers to, and a [connection](/docs/connections) delivers each event to your agent, over HTTP or through the [Hookdeck CLI](/docs/cli).

> "Forward proxy" is SEP-3415's term for a service that receives webhooks for your agent. It isn't an outbound HTTP proxy that your services send their requests through.

## When your agent needs a forward proxy

Put a forward proxy in front of your agent when one of these applies:

* Your agent can be down for longer than the MCP server retries. SEP-3415 suggests that servers cap delivery at, for example, 3 to 5 attempts over no more than 10 to 15 minutes. Event Gateway accepts and stores each delivery whether or not your agent is up, and your connection's [retry rule](/docs/retries) retries delivery to your agent.
* Your agent has no public URL. For example, it runs on your machine, or on a host that doesn't accept inbound connections. The MCP server delivers to the source URL, and `hookdeck listen`, running next to your agent, forwards each event to it.
* Your agent sits behind infrastructure that needs its own authentication. SEP-3415 defines HMAC signatures as the only authentication between the MCP server and the callback URL. For an endpoint behind an API gateway that requires a bearer token, it recommends a forward proxy that authenticates to your services itself. Event Gateway can authenticate to a destination with a [bearer token, an API key, basic auth or a signature](/docs/authentication#destination-authentication).
* You want the receiver's duties handled before a request reaches your agent. Event Gateway answers the verification handshake, checks signatures and drops duplicates.
* You want to see, filter and route the events. Event Gateway logs every delivery and attempt, and can filter events, send them to more than one destination, and control how fast they reach your agent. See [Monitor, filter and route events](#monitor-filter-and-route-events).

## How Event Gateway covers the receiver's duties

SEP-3415 gives whatever receives the webhook a set of duties. With Event Gateway as the forward proxy, the MCP Events source and its connection carry them out:

| SEP-3415 duty | Event Gateway |
| --- | --- |
| Answer the verification handshake | The MCP Events source answers it when the signature verifies and the `webhook-timestamp` is within 5 minutes. The handshake isn't delivered to your agent. |
| Verify the Standard Webhooks signature (required) | The source verifies every delivery with the subscription's secret. A delivery that fails is rejected with the cause `VERIFICATION_FAILED` and never delivered. |
| Reject a `webhook-timestamp` more than 5 minutes old (recommended) | Checked on the handshake only. See [Limits](#limits). |
| Deduplicate on `webhook-id` (recommended) | A [deduplicate rule](/docs/deduplication) on `headers.webhook-id`, with a window of up to 1 hour. |
| Answer `2xx` only once the event is stored or forwarded (recommended) | Event Gateway stores each request before it answers the MCP server, then retries delivery to your agent. Your agent's downtime doesn't show up as failed deliveries. |
| Pass `cursor`, `eventId`, `gap` and `terminated` on to your agent | Event Gateway [preserves the original request's](/docs/destinations#handling-events-from-hookdeck) headers, body, query string and path, so events and control envelopes reach your agent with their bodies unchanged. |

## Set up the forward proxy

Your agent sets up the proxy itself, once per subscription. SEP-3415 says the client registers its secret with the proxy but doesn't say how, so the agent does it with the [Event Gateway API](/docs/api). The requests below are the ones the [forward proxy demo](https://github.com/hookdeck/hookdeck-demos/tree/main/hookdeck/mcp-events-forward-proxy) makes.

The agent needs an Event Gateway API key. The key can create and delete any source and connection in the project, so use a project dedicated to the agent.

### 1. Generate the subscription's secret

The client supplies the signing secret when it subscribes. SEP-3415 requires `whsec_` followed by base64 that decodes to 24 to 64 bytes:

```bash
SECRET="whsec_$(openssl rand -base64 32)"

```

### 2. Create an MCP Events source

Use `POST /sources` to create a source of type `MCP_EVENTS` that holds the secret:

```bash
curl -X POST "https://api.hookdeck.com/2026-09-01/sources" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "mcp-events-agent-1",
    "type": "MCP_EVENTS",
    "config": {
      "auth": {
        "webhook_secret_key": "whsec_..."
      }
    }
  }'

```

The response, trimmed to the fields this guide uses:

```json
{
  "id": "src_123456789",
  "name": "mcp-events-agent-1",
  "type": "MCP_EVENTS",
  "config": {
    "allowed_http_methods": ["POST"],
    "custom_response": null,
    "auth": {
      "webhook_secret_key": "whsec_..."
    }
  },
  "url": "https://hkdk.events/abcdefghijklmn",
  "authenticated": true
}

```

The `url` is the callback URL you give the MCP server. A source holds one secret, so create one source for each subscription.

### 3. Connect the source to your agent {#connect-the-source}

Use `POST /connections` to connect the source to your agent's event endpoint, with a deduplicate rule on `webhook-id` and a retry rule:

```bash
curl -X POST "https://api.hookdeck.com/2026-09-01/connections" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "mcp-events-agent-1",
    "source_id": "src_...",
    "destination": {
      "name": "mcp-events-agent-1",
      "type": "HTTP",
      "config": {
        "url": "https://agent.example.com/events"
      }
    },
    "rules": [
      {
        "type": "deduplicate",
        "window": 3600000,
        "include_fields": ["headers.webhook-id"]
      },
      {
        "type": "retry",
        "strategy": "linear",
        "interval": 15000,
        "count": 10
      }
    ]
  }'

```

The response, trimmed:

```json
{
  "id": "web_123456789",
  "name": "mcp-events-agent-1",
  "rules": [
    {
      "type": "deduplicate",
      "window": 3600000,
      "include_fields": ["headers.webhook-id"]
    },
    {
      "type": "retry",
      "count": 10,
      "interval": 15000,
      "strategy": "linear",
      "response_status_codes": null
    }
  ],
  "destination": {
    "id": "des_123456789",
    "name": "mcp-events-agent-1",
    "type": "HTTP",
    "config": {
      "url": "https://agent.example.com/events",
      "delivery_timeout": 60,
      "auth_type": "HOOKDECK_SIGNATURE",
      "auth": {}
    }
  },
  "source": {
    "id": "src_123456789",
    "type": "MCP_EVENTS",
    "url": "https://hkdk.events/abcdefghijklmn"
  }
}

```

The destination's `auth_type` is `HOOKDECK_SIGNATURE`, the default, so each delivery carries the `x-hookdeck-signature` header your agent checks in [step 5](#check-each-delivery).

The deduplicate rule drops a delivery whose `webhook-id` it has seen within the last hour, which is the longest window available. SEP-3415 sets `webhook-id` to the event's `eventId`, so a delivery the MCP server sends again is dropped. Deduplication is best-effort, so your agent still needs to handle each `eventId` once.

The retry rule tries again every 15 seconds, up to 10 times. Events can have up to 50 automatic retries.

If your agent has no public URL, replace the HTTP destination with a CLI destination, and run [`hookdeck listen`](/docs/cli) for the source on the agent's host:

```json
{
  "name": "mcp-events-agent-1",
  "type": "CLI",
  "config": {
    "path": "/events"
  }
}

```

Create the connection before you start `hookdeck listen`. Started first, `listen` creates its own connection, with a destination named `cli-<source>`.

While a listener is connected, delivery through `hookdeck listen` is at least once, with the same retry behavior as an HTTP destination. Three things work differently:

* Requests that arrive while no listener is connected are stored, but no event is created. Once a listener is connected again, [retry those requests](/docs/requests#retrying-a-request) to create and deliver the events. Retry rather than replay: a second retry of the same request is refused, so recovering twice can't deliver an event twice, while every [replay](/docs/requests#replay-a-request) creates a new request. The demo's [`recover.ts`](https://github.com/hookdeck/hookdeck-demos/blob/main/hookdeck/mcp-events-forward-proxy/src/agent/recover.ts) does this whenever `listen` starts.
* Pausing holds events, not requests that arrive after `listen` stops. On a [paused connection](/docs/connections#pause-a-connection), Event Gateway holds the events it has created and delivers them when you unpause, to a new `listen` session if the old one has gone. A request that arrives after `listen` stops cleanly still creates no event, paused or not, so retry it as above.
* Every `hookdeck listen` process gets its own copy of each event. Two instances of your agent listening to the same connection both receive every event, so handle each `eventId` once. See [multiple CLI listeners](/docs/cli#multiple-cli-listeners).

### 4. Subscribe with the source URL

Call `events/subscribe` on the MCP server, with the source URL as `delivery.url` and the secret from step 1 as `delivery.secret`. These are the request's `params`:

```json
{
  "name": "incident.created",
  "arguments": {},
  "delivery": {
    "mode": "webhook",
    "url": "https://hkdk.events/abcdefghijklmn",
    "secret": "whsec_..."
  },
  "cursor": null
}

```

The MCP server doesn't deliver to a callback URL it hasn't verified, so it first sends a signed verification handshake to the source URL:

```http
POST https://hkdk.events/abcdefghijklmn
Content-Type: application/json
webhook-id: msg_verification_ceab3474830d8da49155c65a
webhook-timestamp: 1792099374
webhook-signature: v1,wwpAvTxO7lnOM3kxwKfY24pUE25imlqqGYLaoDn3TO4=
X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e

{"type":"verification","challenge":"3doNfac2wOZYK4KEzPAcMhx-BlyWxeBM"}

```

The source checks the signature with the subscription's secret, and checks that `webhook-timestamp` is within 5 minutes. It answers `200` with the challenge:

```json
{"challenge":"3doNfac2wOZYK4KEzPAcMhx-BlyWxeBM"}

```

A handshake signed with a different secret, or with a `webhook-timestamp` more than 5 minutes old, gets `401` and `{"error":"Invalid signature"}`, so the subscribe fails.

The MCP server then returns the subscription's `id`, `refreshBefore` and `cursor`. Unless `refreshBefore` is `null`, call `events/subscribe` again with the same `name`, `arguments` and `delivery.url` before that time, or the subscription expires and the MCP server stops sending.

On every refresh, pass the latest `cursor` you've received, from an event or a subscribe response. A `null` cursor means "start from now", so after the subscription lapses, the MCP server can only replay what you missed if you pass a cursor.

### 5. Check each delivery in your agent {#check-each-delivery}

Event Gateway has already verified the MCP server's signature, but your agent's event endpoint should still check each request:

* Check that it came from Event Gateway. Over HTTP, verify the [`x-hookdeck-signature` header](/docs/authentication#hookdeck-webhook-signature-verification) with your project's signing secret. The demo skips this check for `hookdeck listen`, where requests arrive on localhost from the CLI.
* Check the MCP server's signature again, if you want to confirm the body is the one it signed. Use the subscription's secret, but skip the 5-minute window: Event Gateway delivers the stored request, so a retry carries the original `webhook-timestamp`.
* Handle each `eventId` once. Store the `eventId`s you've handled, because deduplication is best-effort and neither signature check rejects a copy of a delivery sent again.
* Answer `503` for a subscription you don't know yet. A delivery can arrive before your agent has the subscription's `id` from `events/subscribe`. SEP-3415 says to answer with a retryable status, and Event Gateway's retry rule delivers it again.

## Monitor, filter and route events {#monitor-filter-and-route-events}

The connection that delivers to your agent is an ordinary Event Gateway connection, so it has the same monitoring and rules as any other:

* Monitor deliveries. Event Gateway logs each [request](/docs/requests) the MCP server sends and each [event](/docs/events) it delivers, with the headers and body, and the status and body of your agent's response to every attempt. [Metrics](/docs/metrics) show the delivery success rate, retry rate and latency, and [issues](/docs/issues) can notify your team when deliveries fail.
* Filter events. A [filter rule](/docs/filters) delivers only the events your agent acts on. Let `gap` and `terminated` envelopes through as well: SEP-3415 requires the receiver to pass them on, and they carry a `type` field instead of `name` and `data`. Added to the connection's `rules`, this filter delivers `P1` incidents and every control envelope:
  
  
  ```json
  {
    "type": "filter",
    "body": {
      "$or": [
        { "type": { "$exist": true } },
        { "name": "incident.created", "data": { "severity": "P1" } }
      ]
    }
  }
  
  ```
  
  
* Fan out to more destinations. Each [connection](/docs/connections) on a source gets its own copy of every request, so add connections to deliver the same events to more than one destination, such as your agent and an audit log. Each connection has its own rules and retries.
* Transform events. A [transformation](/docs/transformations) can reshape an event before it reaches your agent. It changes the body, so your agent can't check the MCP server's signature against the transformed body.
* Limit and pause delivery. A [max delivery rate](/docs/destinations#delivery-rate) on an HTTP destination limits how fast events reach your agent during a burst. [Pause the connection](/docs/connections#pause-a-connection) while you deploy your agent: Event Gateway keeps accepting deliveries and queues the events until you unpause. With a CLI destination, events are only created while `listen` is connected, as [step 3](#connect-the-source) describes.

## Run the demo

The [forward proxy demo](https://github.com/hookdeck/hookdeck-demos/tree/main/hookdeck/mcp-events-forward-proxy) is an agent that sets up Event Gateway this way for each subscription, plus a stand-in MCP server that sends `incident.created` events. It needs Node.js 22 or later and a dedicated Event Gateway project:

```bash
# Install the dependencies
npm install

# Add HOOKDECK_API_KEY, and set SENDER_TOKEN and AGENT_TOKEN to random strings
cp .env.example .env

# Start the stand-in MCP server
npm run sender

# In a second terminal: create the source and connection, then subscribe
npm run agent

# In a third terminal: open an incident, which the MCP server sends as an event
npm run emit -- --severity P1 --title "Database connection pool exhausted"

```

With no `AGENT_PUBLIC_URL` set, the agent receives through `hookdeck listen`. `npm run scenarios` runs the demo's scenarios, including a duplicate delivery, a badly signed delivery and the agent answering `503`, and reports pass or fail with evidence for each.

## Limits {#limits}

* A badly signed delivery is answered `200`. Event Gateway stores each request before it verifies the signature, so the MCP server can't tell a rejected delivery from an accepted one. The rejection shows in the [request log](/docs/requests#rejected-requests) as `VERIFICATION_FAILED`.
* Event deliveries aren't checked for freshness. The source checks `webhook-timestamp` on the handshake only. Stored deliveries and their retries keep the original `webhook-timestamp`, so your agent can't apply the 5-minute window either. A copy of a correctly signed delivery sent after the deduplicate window is delivered, and only your agent's record of handled `eventId`s stops it.
* One secret per source. Each subscription needs its own source, so every subscribe and exit makes Event Gateway API calls. A source can't hold two secrets, so there's no overlap when you rotate a subscription's secret.
* Through `hookdeck listen`, missed requests need a retry. When a listener connects, Event Gateway doesn't yet deliver the requests that arrived while none was connected. You retry them, as [step 3](#connect-the-source) describes. A CLI destination also doesn't support a max delivery rate. See [events received while you are not listening](/docs/cli#events-received-while-you-are-not-listening).

## Related resources

* [Receiving events reliably](/webhooks/guides/what-are-mcp-events#receiving-events-reliably): what happens to events while your agent is down, and the rules a forward proxy should apply.
* [MCP Events resources](/webhooks/guides/mcp-events): guides, demos and specifications.
* [Deduplication](/docs/deduplication) and [Retries](/docs/retries): the connection rules the proxy uses.
* [Requests](/docs/requests): rejected requests, ignored events and retrying requests.