Skip to main content

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.

A custom rule engine is a webhook on your own server that MCP Manager calls when a gateway rule 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. 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. For managed alternatives that need no code, see Amazon Bedrock and 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 by selecting it as the rule’s Detection method.
  3. When a tool message reaches the rule’s detection hook, 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.
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.

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.
webhook-types.ts
// 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;
  /** 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:
{
  "metadata": {
    "ruleEngineId": "MRE-3a2f1d7b-8c4e-49ee-b1a5-2f9c0d6e80a1",
    "userGuid": "USR-…",
    "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:
// 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

{ "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.

block — reject the message

{ "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 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.
{
  "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:
{
  "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. Don’t include extra top-level fields beyond jsonrpc / id / result / error.

error — you can’t decide

{ "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 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. See Authenticating your engine.

Helpers — TypeScript

These helpers handle the body-shape variations above, so your route handler stays a clean four-branch switch.
helpers.ts
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.
server.ts
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

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.
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.
Only jsonrpc, id, result, and error are accepted. Anything else gets the response rejected as malformed and falls through to the failure mode.
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.

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 and 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

Custom Rule Engines

Registering, testing, and managing the engine you build here.

Gateway Rules Overview

Detection methods, hooks, failure modes, actions, and rule ordering.

Amazon Bedrock Guardrails

A managed alternative to building your own engine.

Lakera Guard

A security-first managed alternative to a custom engine.