> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mcpmanager.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Building a Custom Rule Engine

> The developer reference for building a custom rule-engine webhook for MCP Manager: the request envelope, the pass/block/modify/error response shapes, modifiedPayload.body validation, the 30-second timeout, retries, the 16 MiB cap, and a complete Express example.

A **custom rule engine** is a webhook on your own server that MCP Manager calls when a [gateway rule](/features/gateway-rules/overview) using the **Custom** provider fires. Your server inspects the message and tells the gateway one of four things: **pass** it through, **modify** it, **block** it, or signal that it **couldn't decide**. This page is the developer reference for that webhook — the full request/response contract, so you can stand up an engine in any stack. For a guided build that takes you from an empty project to a working engine, see [Build and connect your first custom rule engine](/tutorials/custom-rule-engine).

This is the build-it-yourself path. To register an engine in the UI and understand the surrounding settings — URL, HTTP method, headers, header forwarding, IP allowlisting, testing, and deletion — see [Custom Rule Engines](/features/gateway-rules/custom-rules-engines). For managed alternatives that need no code, see [Amazon Bedrock](/features/amazon-bedrock) and [Lakera Guard](/features/lakera-guard).

## How it works

1. Register a rule engine under **Rule Engines** with **Custom** as the provider, point the URL at your webhook, and optionally add request headers (for example, a bearer token for your own auth).
2. Attach that engine to a [gateway rule](/features/gateway-rules/overview#add-a-new-rule) by selecting it as the rule's **Detection method**.
3. When a tool message reaches the rule's [detection hook](/features/gateway-rules/overview#detection-hook-when-a-rule-fires), the gateway POSTs the message (wrapped in a small metadata envelope) to your webhook. Your webhook returns one of four shapes. The gateway acts on that shape before forwarding the message.

<Note>
  The gateway calls your engine over **HTTPS only**, and URLs that resolve to private or loopback IP ranges are rejected up front — your engine must be reachable on a public network. See [Only HTTPS, public endpoints](/features/gateway-rules/custom-rules-engines#only-https-public-endpoints).
</Note>

## The contract, as TypeScript types

Paste this block into your project verbatim. Every field below is exactly what the gateway sends and expects in return. If a coding agent is generating your webhook, this is the source of truth — feed it these types.

```ts webhook-types.ts theme={null}
// What the gateway POSTs to your webhook.
export interface WebhookRequest {
  metadata: WebhookMetadata;
  body: JsonRpcResponse;
}

export interface WebhookMetadata {
  /** Engine's ID in MCP Manager (CSO guid). */
  ruleEngineId: string;
  /** User whose tool call triggered this message. May be null for service-to-service traffic. */
  userGuid: string | null;
  /** Email of the user whose tool call triggered this message. May be null for service-to-service traffic. */
  userEmail: string | null;
  /** Gateway that fired the rule. May be null in edge cases. */
  gatewayGuid: string | null;
  /** MCP server involved in the message. May be null in edge cases. */
  serverGuid: string | null;
  /** Correlation ID — matches the value shown in your logs and alerts. */
  sessionId: string;
  /** ISO 8601. */
  timestamp: string;
  /**
   * Which leg of the tool call this is.
   *  - 'request'  → body is the upstream tool call (body.params.arguments carries the tool args)
   *  - 'response' → body is the tool result (body.result carries the returned content)
   */
  direction: 'request' | 'response';
  /** Tool that was called, when known. */
  toolName: string | null;
  /** MCP JSON-RPC method — 'tools/call' (gateway rules apply to tools only). */
  method: string;
  /** JSON-RPC id of the in-flight request — echo it back unchanged in any modify response. */
  requestId: string | number;
}

// The MCP message your engine inspects. JSON-RPC 2.0.
export interface JsonRpcResponse {
  jsonrpc: '2.0';
  id: string | number | null;
  /** Present on a result. A plain string, an OpenAI-shaped content envelope, or arbitrary JSON. */
  result?: unknown;
  /** Present on error responses from the upstream MCP server. */
  error?: { code: number; message: string; data?: unknown };
}

// What your webhook MUST return. Pick exactly one shape.
export type WebhookResponse = PassResponse | BlockResponse | ModifyResponse | ErrorResponse;

export interface PassResponse {
  type: 'pass';
  comment?: string;
}

export interface BlockResponse {
  type: 'block';
  comment?: string;
}

export interface ModifyResponse {
  type: 'modify';
  comment?: string;
  modifiedPayload: {
    /** A COMPLETE JSON-RPC response, not a partial. Same id as the inbound request. */
    body: JsonRpcResponse;
  };
}

export interface ErrorResponse {
  type: 'error';
  comment?: string;
}
```

## What the gateway sends

A single JSON object, posted with `Content-Type: application/json` plus any headers you configured. The HTTP method is whatever you set on the rule engine (defaults to POST). The shape is stable across all custom engines:

```json theme={null}
{
  "metadata": {
    "ruleEngineId": "MRE-3a2f1d7b-8c4e-49ee-b1a5-2f9c0d6e80a1",
    "userGuid": "USR-…",
    "userEmail": "alice@example.com",
    "gatewayGuid": "GWY-…",
    "serverGuid": "MIS-…",
    "sessionId": "ckyxxxxxxxxxxxxxxxxx",
    "timestamp": "2026-05-08T15:00:00.000Z",
    "direction": "response",
    "toolName": "lookup_customer",
    "method": "tools/call",
    "requestId": 7
  },
  "body": {
    "jsonrpc": "2.0",
    "id": 7,
    "result": {
      "content": [{ "type": "text", "text": "Customer email: alice@example.com" }]
    }
  }
}
```

On a **request**-direction rule, `body` is the tool call and the arguments live under `body.params.arguments`. On a **response**-direction rule, `body` is the tool result, shown above.

### Variations you'll see in `body.result`

MCP servers don't all produce the same result shape. A response-direction engine should handle these:

```json theme={null}
// 1. OpenAI-shaped content envelope (most common)
{ "result": { "content": [{ "type": "text", "text": "Customer email: alice@example.com" }] } }

// 2. Multiple content items (rare but legal)
{ "result": { "content": [
  { "type": "text", "text": "Customer email: alice@example.com" },
  { "type": "text", "text": "Address: 123 Main St" }
] } }

// 3. Plain string result (older MCP servers)
{ "result": "Customer email: alice@example.com" }

// 4. Tool-call error from the upstream MCP server
{ "error": { "code": -32603, "message": "Internal error" } }
```

When `error` is present and `result` is missing, there's usually nothing to inspect — the right move is to return `pass`, since blocking an error response just compounds the failure.

## The four responses

Return one of these JSON shapes with HTTP `200 OK`.

### pass — let the message through unchanged

```json theme={null}
{ "type": "pass", "comment": "no PII detected" }
```

The gateway forwards the original message with no modification. `comment` lands in the `rule_engine_comment` column in your [logs](/features/viewing-logs).

### block — reject the message

```json theme={null}
{ "type": "block", "comment": "credit card detected: 4111-XXXX-XXXX-XXXX" }
```

The gateway replaces the upstream message with a JSON-RPC error so the client knows the call was blocked. If the rule has [alerts](/features/alerts) enabled, an alert appears with your comment attached. Keep `comment` short and human-readable — it's what the alert renderer displays.

### modify — rewrite the message

The `modifiedPayload.body` you return is what the gateway forwards in place of the original. It must be a **complete, valid JSON-RPC response**: `jsonrpc: "2.0"`, the **same `id`** as the inbound request (echo `metadata.requestId`), and either a `result` or an `error` field.

```json theme={null}
{
  "type": "modify",
  "comment": "redacted email",
  "modifiedPayload": {
    "body": {
      "jsonrpc": "2.0",
      "id": 7,
      "result": { "content": [{ "type": "text", "text": "Customer email: [REDACTED]" }] }
    }
  }
}
```

To replace a plain-string result, return `"result": "Customer email: [REDACTED]"` instead of the content envelope. To surface a tailored error to the client rather than the generic block message, return an `error` object in place of `result`:

```json theme={null}
{
  "type": "modify",
  "comment": "PII present, returning sanitized error",
  "modifiedPayload": {
    "body": {
      "jsonrpc": "2.0",
      "id": 7,
      "error": { "code": -32603, "message": "Sensitive data can't be returned through this channel." }
    }
  }
}
```

If `modifiedPayload.body` isn't a valid JSON-RPC envelope — missing `jsonrpc`, missing `id`, or neither `result` nor `error` — the gateway treats it as malformed and falls through to the rule's [failure mode](/features/gateway-rules/overview#failure-mode-what-happens-when-a-detection-method-fails). Don't include extra top-level fields beyond `jsonrpc` / `id` / `result` / `error`.

### error — you can't decide

```json theme={null}
{ "type": "error", "comment": "upstream classifier timed out" }
```

Use this when your engine ran but couldn't reach a verdict. The gateway falls through to the rule's **failure mode**: with failure mode **Allow** the original message passes through unchanged; with failure mode **Block** the message is blocked. Failure mode is set on the gateway rule, not the engine, so the same engine can be wired to different rules with different failure-mode policies. The **default failure mode for custom engines is Block.**

## What gets rejected as malformed

The gateway treats any of these as an `error` outcome and routes through the rule's failure mode:

* Response is not valid JSON
* HTTP status not in the 200–299 range (after retries are exhausted)
* Body missing the `type` field
* `type` not one of `pass` / `block` / `modify` / `error`
* `type: "modify"` without a `modifiedPayload.body` that is a complete JSON-RPC response
* Response body larger than **16 MiB**

When this happens the rule-engine row in your [logs](/features/viewing-logs) records the specific reason (for example `invalid_json`, `http_error`, or `connection_error`) so you can debug from the dashboard. MCP Manager does **not** attempt to repair malformed JSON — quietly fixing a response that should have failed is treated as a security risk, so a broken response always falls through to the failure mode.

## Operational notes

A few things worth knowing before you write your webhook:

* **Timeout.** The gateway times out at **30 seconds per attempt**. Your engine sits inline on tool traffic, so aim for sub-second latency in practice; the generous ceiling exists for engines that make their own downstream calls.
* **Retries.** Transient failures (timeouts, 5xx) are retried with exponential backoff, up to **3 attempts** (1 initial + 2 retries). 4xx responses are deterministic and aren't retried. Make your webhook **idempotent** — receiving the same envelope twice must produce the same result. Use `metadata.sessionId` as a dedupe key if you have side effects.
* **Concurrency.** Multiple tool calls can be in flight at once; each fires an independent POST. Don't assume one call at a time.
* **TLS.** All calls go over HTTPS. There's no way to disable it, and self-signed certs aren't supported — use a public CA.
* **No streaming.** The webhook is a single request/response — no SSE, no chunked streaming. The envelope arrives fully buffered.
* **HTTP status codes.** Return `200 OK` for every shape, including `block` and `error` — those describe an outcome, not an HTTP failure. Reserve non-2xx for actual webhook failures (your service is down, the request was malformed).

## Auth and headers

Anything you add in the **Headers** section of the rule engine is sent on every request, encrypted at rest until call time. Common patterns: a bearer token (`Authorization: Bearer <your-token>`), a custom API key (`X-Api-Key: <your-key>`), or a static signing secret you verify on your end. For defense in depth, you can also [allowlist MCP Manager's static IP](/features/gateway-rules/custom-rules-engines#defense-in-depth-allowlist-mcp-managers-ip). See [Authenticating your engine](/features/gateway-rules/custom-rules-engines#authenticating-your-engine).

## Helpers — TypeScript

These helpers handle the body-shape variations above, so your route handler stays a clean four-branch switch.

```ts helpers.ts theme={null}
import type { JsonRpcResponse } from './webhook-types';

/**
 * Pulls the user-visible text out of a tool response. Handles all three body shapes:
 *  - OpenAI-style { content: [{ type: 'text', text }] } → joins all text items with newlines
 *  - Plain string result → returns it
 *  - Anything else → JSON-stringifies it so a regex / classifier still has something to scan
 * Returns null when the response has no result at all (e.g. a JSON-RPC error response).
 */
export function extractResponseText(body: JsonRpcResponse): string | null {
  if (body.result == null) return null;
  if (typeof body.result === 'string') return body.result;
  if (typeof body.result === 'object') {
    const envelope = body.result as { content?: Array<{ type?: string; text?: string }> };
    if (Array.isArray(envelope.content)) {
      const texts = envelope.content
        .filter((item) => item?.type === 'text' && typeof item.text === 'string')
        .map((item) => item.text as string);
      if (texts.length > 0) return texts.join('\n');
    }
    return JSON.stringify(body.result);
  }
  return String(body.result);
}

/**
 * Builds a new JsonRpcResponse with the given text substituted back into the same slot the
 * original came from. Echoes the inbound id so the modify response is JSON-RPC-valid.
 */
export function buildResponseWithReplacedText(original: JsonRpcResponse, replacement: string): JsonRpcResponse {
  if (typeof original.result === 'string') {
    return { jsonrpc: '2.0', id: original.id, result: replacement };
  }
  if (original.result && typeof original.result === 'object') {
    const envelope = original.result as { content?: Array<{ type?: string; text?: string }> };
    if (Array.isArray(envelope.content)) {
      const rewritten = envelope.content.map((item) => (item?.type === 'text' ? { ...item, text: replacement } : item));
      return { jsonrpc: '2.0', id: original.id, result: { ...envelope, content: rewritten } };
    }
  }
  return { jsonrpc: '2.0', id: original.id, result: replacement };
}
```

## End-to-end example: Express + TypeScript

A complete webhook covering all four response shapes. Drop into a Node 20+ project with `express` and `@types/express` installed.

```ts server.ts theme={null}
import type { Request, Response } from 'express';
import express from 'express';
import { buildResponseWithReplacedText, extractResponseText } from './helpers';
import type { WebhookRequest, WebhookResponse } from './webhook-types';

const app = express();
app.use(express.json({ limit: '16mb' })); // matches the gateway's body cap

const SHARED_SECRET = process.env.MCP_RULE_ENGINE_SECRET ?? '';
const CREDIT_CARD_PATTERN = /\b(?:\d[ -]*?){13,19}\b/;
const EMAIL_PATTERN = /\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/g;

app.post('/inspect', (request: Request, response: Response) => {
  // Verify the shared secret you configured in the rule engine's Headers section.
  if (request.headers['x-api-key'] !== SHARED_SECRET) {
    response.status(401).json({ error: 'Unauthorized' });
    return;
  }

  const envelope = request.body as WebhookRequest;
  const text = extractResponseText(envelope.body);

  // No usable text (e.g. an upstream JSON-RPC error) — let it through.
  if (text == null) {
    response.json({ type: 'pass', comment: 'no text to inspect' } satisfies WebhookResponse);
    return;
  }

  // Hard fail: credit card detected → block.
  if (CREDIT_CARD_PATTERN.test(text)) {
    response.json({ type: 'block', comment: 'credit card number detected' } satisfies WebhookResponse);
    return;
  }

  // Soft fail: emails → redact in place and forward.
  if (EMAIL_PATTERN.test(text)) {
    const redacted = text.replace(EMAIL_PATTERN, '[REDACTED EMAIL]');
    const modifiedBody = buildResponseWithReplacedText(envelope.body, redacted);
    response.json({
      type: 'modify',
      comment: 'redacted email address(es)',
      modifiedPayload: { body: modifiedBody },
    } satisfies WebhookResponse);
    return;
  }

  response.json({ type: 'pass' } satisfies WebhookResponse);
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Rule engine listening on :${port}`));
```

Two things this demonstrates that you'll want in your own version: **authenticate before parsing** (verify the secret before running your classifier), and **stay idempotent** (read the body once and return deterministically, so a retry of the same envelope produces the same result).

## Common pitfalls

<AccordionGroup>
  <Accordion title="Returning block or error with a non-200 status">
    `block` and `error` are outcomes, not HTTP failures. Return `200 OK` and put the outcome in the JSON `type`. A non-2xx status is treated as `http_error` and routed through the rule's failure mode instead.
  </Accordion>

  <Accordion title="Forgetting to echo metadata.requestId as the id in a modify response">
    MCP clients correlate requests to responses by `id`. A mismatch makes the client wait forever and then time out. Always set `modifiedPayload.body.id` to the inbound `metadata.requestId`.
  </Accordion>

  <Accordion title="Inserting extra fields into modifiedPayload.body">
    Only `jsonrpc`, `id`, `result`, and `error` are accepted. Anything else gets the response rejected as malformed and falls through to the failure mode.
  </Accordion>

  <Accordion title="Side effects in the handler">
    Retries mean the same envelope can arrive more than once. If you log to an immutable audit store, insert a billing row, or fire an alert from inside the handler, key it on `metadata.sessionId` so a retry doesn't double-count.
  </Accordion>
</AccordionGroup>

## When to use a custom engine vs a built-in provider

Use **Custom** when you want full control: your own classifier, your own retraining pipeline, your own audit trail. If you'd rather drop in a managed service, MCP Manager has built-in providers — [Amazon Bedrock Guardrails](/features/amazon-bedrock) and [Lakera Guard](/features/lakera-guard) — that translate to and from a specific vendor's API for you. You pick those from the same provider dropdown and only configure auth (and, for Bedrock, a couple of identifying fields); MCP Manager handles the request/response translation.

## Further reading

<CardGroup cols={2}>
  <Card title="Custom Rule Engines" icon="plug" href="/features/gateway-rules/custom-rules-engines">
    Registering, testing, and managing the engine you build here.
  </Card>

  <Card title="Gateway Rules Overview" icon="shield-halved" href="/features/gateway-rules/overview">
    Detection methods, hooks, failure modes, actions, and rule ordering.
  </Card>

  <Card title="Amazon Bedrock Guardrails" icon="aws" iconType="brands" href="/features/amazon-bedrock">
    A managed alternative to building your own engine.
  </Card>

  <Card title="Lakera Guard" icon="shield-check" href="/features/lakera-guard">
    A security-first managed alternative to a custom engine.
  </Card>
</CardGroup>
