Custom Rule Engine Overview
A custom rule engine is a webhook on your own server that MCP Manager calls on every tool response. Your server inspects the response and tells the Gateway one of four things: pass it through, modify it, block it, or signal that you couldn't decide. This guide walks you through the request/response contract so you can stand up an engine in your stack of choice.
How it works
- You configure a rule engine in MCP Manager under Settings → Integrations → Rule Engines. Pick Custom as the Provider, point the URL at your webhook, and optionally add request headers (for example, a bearer token for your own auth).
- You attach that rule engine to a gateway rule under Settings → Gateways → <Gateway> → Rules. Each rule can fire the engine on tool responses produced by the MCP servers behind that gateway.
- When a tool runs and produces a response, the Gateway POSTs the response (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 response to the user's MCP client.
The Gateway uses HTTPS only. Customer URLs that resolve to private/loopback IP ranges are rejected up-front — your engine must be reachable on a public network.
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 for you, this is the source of truth — feed it these types.
// 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 response. 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 that produced the response. May be null in edge cases. */
serverGuid: string | null;
/** Correlation ID — matches the value in Settings → Logging and Settings → Alerts. */
sessionId: string;
/** ISO 8601. */
timestamp: string;
/** Currently always 'response' (post-tool-call hook). Future versions may add 'request'. */
direction: 'response';
/** Tool that was called, when known. */
toolName: string | null;
/** MCP JSON-RPC method, currently always 'tools/call' for response-direction hooks. */
method: string;
/** JSON-RPC id of the in-flight request — must be echoed back unchanged in any modify response. */
requestId: string | number;
}
// The MCP tool response your engine inspects. JSON-RPC 2.0.
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number | null;
/** Present on success. Either 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: {
/** Must be 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 the Gateway uses is whatever you set on the rule engine (defaults to POST; you can change it under the rule engine modal's HTTP method dropdown if your stack requires something else).
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" }],
},
},
}Variations you'll see in body.result
MCP servers don't all produce the same result shape. Your engine should handle these three:
1. OpenAI-shaped content envelope (the 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, your engine usually has 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 response through unchanged
Minimum:
{ "type": "pass" }With a comment for log readability:
{ "type": "pass", "comment": "no PII detected" }The Gateway forwards the original response to the user with no modification. comment lands in the rule_engine_comment column under Settings → Logging.
block — reject the response
{ "type": "block", "comment": "credit card detected: 4111-XXXX-XXXX-XXXX" }The Gateway replaces the upstream response with a JSON-RPC error so the user's client knows the call was blocked. If the rule was configured to send alerts, an alert appears under Settings → Alerts with your comment attached. Keep comment short and human-readable — it's what the alert renderer displays in the alert subject.
modify — rewrite the response
The modifiedPayload.body you return is what the Gateway forwards to the user in place of the upstream response. 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.
Replacing text in an OpenAI-shaped content envelope (the common case):
{
"type": "modify",
"comment": "redacted email",
"modifiedPayload": {
"body": {
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [{ "type": "text", "text": "Customer email: [REDACTED]" }],
},
},
},
}Replacing a plain-string result:
{
"type": "modify",
"comment": "redacted email",
"modifiedPayload": {
"body": {
"jsonrpc": "2.0",
"id": 7,
"result": "Customer email: [REDACTED]",
},
},
}Replacing multiple content items:
{
"type": "modify",
"comment": "redacted email and address",
"modifiedPayload": {
"body": {
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{ "type": "text", "text": "Customer email: [REDACTED]" },
{ "type": "text", "text": "Address: [REDACTED]" },
],
},
},
},
}Replacing the whole response with a JSON-RPC error (useful when you want to surface a tailored error to the user instead of block's generic "Response blocked by policy" message):
{
"type": "modify",
"comment": "PII present, returning sanitized error",
"modifiedPayload": {
"body": {
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32603,
"message": "Your account contains sensitive data that can't be returned through this channel.",
},
},
},
}If modifiedPayload.body isn't a valid JSON-RPC envelope (missing jsonrpc, missing id, both result and error missing) the Gateway treats it as malformed and falls through to the rule's failure mode. Do not include extra top-level fields outside 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 — for example, a downstream classifier was unavailable. The Gateway falls through to the rule's failure mode setting:
- Allow (failure mode = allow): the original response passes through unchanged.
- Block (failure mode = block): the response is blocked, same as if you had returned
block.
Failure mode is set on the gateway rule, not on the engine, so the same engine can be wired to different rules with different failure-mode policies.
What gets rejected as malformed
The Gateway treats any of these as error and routes through the rule's failure mode:
- Response is not valid JSON
- HTTP status not in
200-299(after retries are exhausted) - Body missing the
typefield typenot one ofpass | block | modify | errortype: "modify"without amodifiedPayload.bodythat is a complete JSON-RPC response- Response body larger than 16 MiB
When this happens the rule-engine row in Settings → Logging is logged with the specific reason (for example invalid_json, http_error, connection_error) so you can debug from the dashboard.
Operational notes
A few things worth knowing before you write your webhook:
- Retries. The Gateway retries transient failures (timeouts, 5xx) with exponential backoff up to a small number of attempts. 4xx responses are deterministic failures and aren't retried. Your webhook should be idempotent: receiving the same envelope twice must produce the same result. Use the
metadata.sessionIdif you need a dedupe key. - Timeouts. The Gateway times out at 10 seconds per attempt. Aim for sub-second latency in practice — your engine sits inline on every tool response.
- Concurrency. Multiple tool calls can be in flight simultaneously. Each fires an independent POST to your webhook. Don't assume one-call-at-a-time.
- TLS. All calls go over HTTPS. There is no way to disable TLS for the rule-engine call. Self-signed certs are not supported — use a public CA or your own internal CA if your deployment trusts one.
- No streaming. The webhook is a single request/response — no SSE, no chunked streaming. The envelope arrives fully buffered; your response is read in one shot.
- HTTP status codes. Return
200 OKfor every shape (includingblockanderror— those describe an outcome, not an HTTP failure). Reserve non-2xx for actual webhook failures (your service is down, request was malformed, etc.).
Auth and headers
Anything you add in the Headers section of the rule engine modal is sent on every request to your webhook, encrypted at rest until call time. Common patterns:
- Bearer token:
Authorization: Bearer <your-token> - Custom API key:
X-Api-Key: <your-key>or whatever your server expects - Signing: add a static secret in headers; verify request integrity on your end any way you like
Headers are sent over HTTPS only.
Helpers — TypeScript
These three helpers handle the body-shape variations described above. Drop them into your project and your route handler stays a clean four-branch switch.
import type { JsonRpcResponse, WebhookResponse } 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.
*
* - String result → replaces with the new string
* - OpenAI-shaped content → replaces each text item with the new string (use replaceContentItems
* below for per-item rewriting)
* - Anything else → wraps the new text as a plain string result
*/
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 rewrittenContent = envelope.content.map((item) => (item?.type === 'text' ? { ...item, text: replacement } : item));
return { jsonrpc: '2.0', id: original.id, result: { ...envelope, content: rewrittenContent } };
}
}
return { jsonrpc: '2.0', id: original.id, result: replacement };
}
/**
* Per-item rewrite for OpenAI-shaped responses with multiple text items. The mapper receives the
* original text for each item and returns the replacement. Items that aren't text are passed
* through untouched.
*/
export function replaceContentItems(original: JsonRpcResponse, mapper: (text: string) => string): JsonRpcResponse {
const result = original.result;
if (!result || typeof result !== 'object') return original;
const envelope = result as { content?: Array<{ type?: string; text?: string }> };
if (!Array.isArray(envelope.content)) return original;
return {
jsonrpc: '2.0',
id: original.id,
result: {
...envelope,
content: envelope.content.map((item) => (item?.type === 'text' && typeof item.text === 'string' ? { ...item, text: mapper(item.text) } : item)),
},
};
}End-to-end example: Express + TypeScript
A complete working webhook covering all four response shapes. Drop into a Node 20+ project with express and @types/express installed.
import type { Request, Response } from 'express';
import express from 'express';
import { buildResponseWithReplacedText, extractResponseText } from './helpers';
import type { JsonRpcResponse, 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. upstream tool returned a 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;
}
// Nothing matched — pass through.
response.json({ type: 'pass' } satisfies WebhookResponse);
});
// Optional health endpoint for your load balancer; the Gateway never calls this.
app.get('/healthz', (_request, response) => {
response.json({ ok: true });
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Rule engine listening on :${port}`);
});A couple of things this example demonstrates that you'll want in your own version:
- Auth before parsing. Verify the shared secret before doing any work; that protects you from unauthenticated POSTs running your classifier code.
- Idempotency. The handler reads
request.bodyonce and returns synchronously. If the Gateway retries, the same input deterministically produces the same output. If your real classifier has side effects (logging, billing), de-dupe onmetadata.sessionId. - The
satisfiesoperator. Usingsatisfies WebhookResponseat the return site catches typos intypevalues at compile time without forcing TypeScript to widen your return type toany.
Common pitfalls
- Returning
blockwith a non-200 status. Don't.blockis an outcome, not an HTTP failure. Non-2xx responses are treated ashttp_errorand routed through the failure mode. - Returning the original body inside
modifiedPayload. The Gateway will forward whatever you return, so make sure you're actually modifying something. If there's nothing to change, returnpass. - Forgetting to echo
metadata.requestIdasidin a modify response. MCP clients use theidto correlate requests to responses; a mismatch causes the client to wait forever and then time out. - Inserting extra fields into
modifiedPayload.body. Onlyjsonrpc,id,result, anderrorare accepted. Anything else gets the response rejected as malformed. - Side-effects in the handler. Retries mean the same envelope can arrive 2-3 times. If you insert a billing row, log to an immutable audit store, or fire an alert from inside the handler, do it idempotently — keyed on
metadata.sessionId.
When to use 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 that translate to/from specific vendor APIs (AWS Bedrock Guardrails, Lakera Guard, and others). Pick those from the same Provider dropdown — you only configure the auth and (for some providers) a few identifying fields, and we handle the request/response translation.
Comments
0 comments
Article is closed for comments.