> ## 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.

# Custom Rule Engine Examples

> Five recipes for what a custom rule-engine webhook can do with the modify and block verdicts: slim verbose responses to cut tokens, strip or redact fields by name, summarize long text, scope data to the caller's identity, and block on policy violations.

Webhooks can rewrite tool responses in flight, offering powerful capabilities — from PII redaction to cost savings to general-purpose transforms on the data your agents see. This page is a cookbook of five recipes showing what the [**modify** and **block** verdicts](/advanced/building-a-custom-rule-engine#the-four-responses) make possible, each with a runnable handler and before/after responses.

This is the applied companion to [Building a Custom Rule Engine](/advanced/building-a-custom-rule-engine), which is the authoritative reference for the request envelope, the four response shapes, `modifiedPayload.body` validation, timeouts, and retries. Read that first — everything here builds on its contract and reuses its [shared types](/advanced/building-a-custom-rule-engine#the-contract-as-typescript-types) (`WebhookRequest`, `WebhookResponse`) and [helpers](/advanced/building-a-custom-rule-engine#helpers-—-typescript) (`extractResponseText`, `buildResponseWithReplacedText`).

<Warning>
  These recipes are **instructional**. They illustrate the contract and the shape of each transform — not production-hardened code. Before relying on one, add the input validation, error handling, authentication, observability, and tests your environment requires. Treat them as starting points, not drop-in implementations.
</Warning>

Every recipe below is a function you'd call from the `/inspect` route handler in that page's [Express example](/advanced/building-a-custom-rule-engine#end-to-end-example-express-+-typescript). They sort into three motivations:

| Motivation                                                       | Recipes                                                                                                                                                   |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Cost** — fewer tokens for the model to read                    | [Slim verbose responses](#slim-verbose-responses-to-cut-token-cost), [Summarize long fields](#summarize-long-fields-in-place)                             |
| **Data governance** — control what data can ever reach the model | [Strip or redact fields by name](#strip-or-redact-fields-the-model-should-never-see), [Block on policy violation](#block-and-audit-on-a-policy-violation) |
| **Identity** — scope data to who's actually asking               | [Identity-scoped filtering](#scope-results-to-the-calling-identity)                                                                                       |

## How the transform fits in

All five recipes fire on a **response**-direction rule, inspect `body.result`, and hand the gateway a rewritten (or rejected) result. The agent only ever sees what your webhook returns.

```mermaid theme={null}
flowchart LR
  A[MCP server<br/>tool result] --> B[MCP Manager gateway]
  B -->|POST envelope| C[Your webhook]
  C --> D{Transform<br/>body.result}
  D -->|modifiedPayload.body| B
  B -->|slimmed / redacted / scoped result| E[Agent / LLM]
```

<Note>
  These examples assume the tool returns its structured data as a JSON string inside `body.result.content[0].text` — the most common shape, and the one `extractResponseText` and `buildResponseWithReplacedText` are built for. If your server populates `result.structuredContent` instead, apply the same parse → transform → re-serialize logic to that object and set it back on `result`. See [the result variations](/advanced/building-a-custom-rule-engine#variations-you’ll-see-in-body-result) for the shapes you might encounter.
</Note>

<Note>
  **A rule engine runs on every `tools/call` flowing through the gateway.** There is no per-server or per-tool selector in the rule UI, so your webhook sees every tool from every server on that gateway — including tools that legitimately return non-JSON. Each recipe therefore checks `metadata.serverGuid` and `metadata.toolName` first and **returns `pass` immediately** for anything it wasn't written for, so unrelated traffic is waved through untouched and cheaply. Only after confirming it's the target server and tool do we parse the result; because that tool is contracted to return JSON, a result that *isn't* parseable JSON is a real anomaly and gets **blocked** rather than passed.
</Note>

## Slim verbose responses to cut token cost

*Goal: cut token cost.* MCP servers tend to return everything they know about an object — twenty-plus fields when your agent needs three. Every unused field is input tokens the model pays to read on every call. An **allowlist** keeps only the fields a given agent or gateway actually uses and drops the rest, including expensive rich-text fields like `description`.

```ts slim-fields.ts theme={null}
import { buildResponseWithReplacedText, extractResponseText } from './helpers';
import type { WebhookRequest, WebhookResponse } from './webhook-types';

// Scope: only transform responses from this one tool on this one server.
const TARGET_SERVER = 'MIS-7c2a4e91'; // the McpInboundServer guid this rule is scoped to
const TARGET_TOOL = 'get_account';
// The only fields the agent needs from this tool's records.
const ALLOWED_FIELDS = ['id', 'name', 'status'] as const;

export function slimResponse(envelope: WebhookRequest): WebhookResponse {
  // The engine sees every tool on the gateway. Act only on our server + tool;
  // pass everything else straight through.
  if (envelope.metadata.serverGuid !== TARGET_SERVER || envelope.metadata.toolName !== TARGET_TOOL) {
    return { type: 'pass' };
  }

  const text = extractResponseText(envelope.body);
  if (text == null) return { type: 'pass', comment: 'no result present to transform' };

  let record: Record<string, unknown>;
  try {
    record = JSON.parse(text);
  } catch {
    // Our tool is supposed to return JSON. If it didn't, something is wrong — fail loudly.
    return { type: 'block', comment: `expected JSON from '${TARGET_TOOL}' but the response was not parseable` };
  }

  const slimmed: Record<string, unknown> = {};
  for (const field of ALLOWED_FIELDS) {
    if (field in record) slimmed[field] = record[field];
  }

  const modifiedBody = buildResponseWithReplacedText(envelope.body, JSON.stringify(slimmed));
  return {
    type: 'modify',
    comment: `slimmed ${Object.keys(record).length} fields to ${Object.keys(slimmed).length}`,
    modifiedPayload: { body: modifiedBody },
  };
}
```

```json Raw Tool Response theme={null}
{
  "id": "003ABC",
  "name": "Acme Corp",
  "status": "active",
  "description": "A 600-word company profile the agent never reads…",
  "annualRevenue": 4200000,
  "billingAddress": { "street": "1 Market St", "city": "SF" },
  "lastModifiedBy": "ops@example.com",
  "createdDate": "2021-04-02T10:00:00Z"
}
```

```json Modified Tool Response theme={null}
{ "id": "003ABC", "name": "Acme Corp", "status": "active" }
```

An allowlist (keep these) is safer than a denylist (drop these) for cost trimming: when the upstream server adds a new field next quarter, an allowlist silently ignores it instead of leaking it into every prompt.

<Tip>
  **Value — roughly a 90% token cut on every read of this object.** The raw record above is about 600 input tokens; the slimmed version is under 40. For a tool an agent calls hundreds of times a day, that difference recurs on every prompt that reads the result — a large, compounding saving with no change to the agent or the upstream server.
</Tip>

## Strip or redact fields the model should never see

*Goal: data governance.* Some fields must never reach the model, and you know them by name — an `ssn`, an internal `creditScore`, a `compensation` figure. Because the field name is a stable, unchanging identifier, a **denylist by key** is exact and predictable. You have two strategies:

* **Delete** the key entirely — the model never knows it existed.
* **Redact** — keep the key but replace its value with a placeholder like `{{REDACTED}}`. The model can see that the field was present but withheld, which stops it from assuming the data is simply missing and retrying the call a different way.

```ts strip-fields.ts theme={null}
import { buildResponseWithReplacedText, extractResponseText } from './helpers';
import type { WebhookRequest, WebhookResponse } from './webhook-types';

const TARGET_SERVER = 'MIS-7c2a4e91'; // the McpInboundServer guid this rule is scoped to
const TARGET_TOOL = 'get_contact';
// Fields that must never reach the model, by name. Stable identifiers, so a denylist is exact.
const BLOCKED_FIELDS = new Set(['ssn', 'creditScore', 'compensation', 'internalNotes']);

const STRATEGY: 'delete' | 'redact' = 'redact';
const REDACTED = '{{REDACTED}}';

export function stripFields(envelope: WebhookRequest): WebhookResponse {
  // The engine sees every tool on the gateway. Act only on our server + tool;
  // pass everything else straight through.
  if (envelope.metadata.serverGuid !== TARGET_SERVER || envelope.metadata.toolName !== TARGET_TOOL) {
    return { type: 'pass' };
  }

  const text = extractResponseText(envelope.body);
  if (text == null) return { type: 'pass', comment: 'no result present to inspect' };

  let record: Record<string, unknown>;
  try {
    record = JSON.parse(text);
  } catch {
    return { type: 'block', comment: `expected JSON from '${TARGET_TOOL}' but the response was not parseable` };
  }

  let affected = 0;
  for (const field of Object.keys(record)) {
    if (!BLOCKED_FIELDS.has(field)) continue;
    affected++;
    if (STRATEGY === 'delete') delete record[field];
    else record[field] = REDACTED;
  }

  if (affected === 0) return { type: 'pass', comment: 'no blocked fields present' };

  const modifiedBody = buildResponseWithReplacedText(envelope.body, JSON.stringify(record));
  return {
    type: 'modify',
    comment: `${STRATEGY === 'delete' ? 'removed' : 'redacted'} ${affected} field(s)`,
    modifiedPayload: { body: modifiedBody },
  };
}
```

```json Raw Tool Response theme={null}
{
  "id": "003ABC",
  "name": "Jane Doe",
  "ssn": "123-45-6789",
  "creditScore": 740,
  "email": "jane@example.com"
}
```

```json Modified Tool Response — Redact theme={null}
{
  "id": "003ABC",
  "name": "Jane Doe",
  "ssn": "{{REDACTED}}",
  "creditScore": "{{REDACTED}}",
  "email": "jane@example.com"
}
```

```json Modified Tool Response — Delete theme={null}
{
  "id": "003ABC",
  "name": "Jane Doe",
  "email": "jane@example.com"
}
```

This differs from a regex or Presidio rule, which matches on the *value* (anything that looks like an SSN). Matching on the *key* is the right tool when sensitivity is a property of the field, not its contents — a salary is just a number until you know which column it came from.

<Tip>
  **Value — a hard guarantee, not a best-effort filter.** Named sensitive fields never enter a prompt, never reach the model provider, and never land in your model-side logs. The `comment` on each `modify` gives you an auditable record of exactly which fields were withheld and how often — the kind of evidence a data-governance or compliance review asks for.
</Tip>

## Summarize long fields in place

*Goal: cut token cost while keeping the gist.* Sometimes the model doesn't need a field gone — it needs it shorter. A 4,000-token `description`, `body`, or `notes` field where the agent only needs the gist is pure waste. Call your own model to summarize the value and splice the summary back into the response.

```ts summarize-field.ts theme={null}
import { createHash } from 'node:crypto';
import { buildResponseWithReplacedText, extractResponseText } from './helpers';
import type { WebhookRequest, WebhookResponse } from './webhook-types';

const TARGET_SERVER = 'MIS-2b81f0d4'; // the McpInboundServer guid this rule is scoped to
const TARGET_TOOL = 'get_ticket';
const FIELD_TO_SUMMARIZE = 'description';
const MIN_CHARS_TO_SUMMARIZE = 1_000; // leave short values untouched
const summaryCache = new Map<string, string>(); // swap for a shared store (Redis, etc.) in production

async function summarize(text: string): Promise<string> {
  // Call your own model however you like. Stay well inside the 30s tool-call budget.
  // return await myModel.summarize(text);
  return text; // placeholder
}

export async function summarizeLongField(envelope: WebhookRequest): Promise<WebhookResponse> {
  // The engine sees every tool on the gateway. Act only on our server + tool;
  // pass everything else straight through.
  if (envelope.metadata.serverGuid !== TARGET_SERVER || envelope.metadata.toolName !== TARGET_TOOL) {
    return { type: 'pass' };
  }

  const text = extractResponseText(envelope.body);
  if (text == null) return { type: 'pass', comment: 'no result present to summarize' };

  let record: Record<string, unknown>;
  try {
    record = JSON.parse(text);
  } catch {
    return { type: 'block', comment: `expected JSON from '${TARGET_TOOL}' but the response was not parseable` };
  }

  const value = record[FIELD_TO_SUMMARIZE];
  if (typeof value !== 'string' || value.length < MIN_CHARS_TO_SUMMARIZE) {
    return { type: 'pass', comment: 'nothing long enough to summarize' };
  }

  // Cache by content hash so a retried envelope returns the same summary (idempotency).
  const cacheKey = createHash('sha256').update(value).digest('hex');
  let summary = summaryCache.get(cacheKey);
  if (summary == null) {
    summary = await summarize(value);
    summaryCache.set(cacheKey, summary);
  }

  record[FIELD_TO_SUMMARIZE] = summary;
  const modifiedBody = buildResponseWithReplacedText(envelope.body, JSON.stringify(record));
  return {
    type: 'modify',
    comment: `summarized '${FIELD_TO_SUMMARIZE}' (${value.length} → ${summary.length} chars)`,
    modifiedPayload: { body: modifiedBody },
  };
}
```

```json Raw Tool Response theme={null}
{
  "id": "TICK-91",
  "subject": "Login fails after SSO migration",
  "description": "…1,800 words of back-and-forth troubleshooting, stack traces, and reply chains…"
}
```

```json Modified Tool Response theme={null}
{
  "id": "TICK-91",
  "subject": "Login fails after SSO migration",
  "description": "User can't log in after the SSO migration; SAML assertion is rejected as expired. Unresolved."
}
```

This recipe is the one that does real work per call, so mind the [operational limits](/advanced/building-a-custom-rule-engine#operational-notes): you're inline on the tool path with a 30-second ceiling, and the gateway can retry the same envelope. Caching by content hash keeps retries cheap and deterministic, and means two calls returning the same long text only pay for one summarization.

<Tip>
  **Value — roughly 99% off the cost of one bloated field, gist intact.** An 1,800-word `description` is about 2,400 input tokens; a one-line summary is around 25. You trade a single summarization call (cached, so retries are free) for a permanent per-read saving on a field the agent only ever skims.
</Tip>

## Scope results to the calling identity

*Goal: identity-aware data governance.* A shared tool — `list_opportunities`, `search_documents` — often returns everything, regardless of who asked. The envelope's `metadata.userGuid` tells you which user triggered the call, so you can filter the result down to the records that user is allowed to see. The envelope also carries `metadata.userEmail` — the same caller's email address — which is often a more convenient join key than the GUID when your access model is keyed on email (an identity provider, a CRM `owner` field, a directory lookup). This recipe resolves on `userGuid`; swap in `userEmail` wherever you'd resolve the owner if that maps more cleanly to your data. Combine either with [runtime header forwarding](/features/gateway-rules/custom-rules-engines) to receive the inbound connection's identity headers and map them to your own access model.

```ts identity-filter.ts theme={null}
import { buildResponseWithReplacedText, extractResponseText } from './helpers';
import type { WebhookRequest, WebhookResponse } from './webhook-types';

const TARGET_SERVER = 'MIS-9a3e5c20'; // the McpInboundServer guid this rule is scoped to
const TARGET_TOOL = 'list_opportunities';

// Map MCP Manager's userGuid to the owner key your data uses. In practice you'd look this up,
// or read a forwarded identity header off the request. If your directory is keyed on email,
// resolve on envelope.metadata.userEmail instead — the same null-handling applies.
async function resolveOwnerId(userGuid: string | null): Promise<string | null> {
  if (userGuid == null) return null;
  // return await myDirectory.ownerIdFor(userGuid);
  return userGuid;
}

export async function filterToCaller(envelope: WebhookRequest): Promise<WebhookResponse> {
  // The engine sees every tool on the gateway. Act only on our server + tool;
  // pass everything else straight through.
  if (envelope.metadata.serverGuid !== TARGET_SERVER || envelope.metadata.toolName !== TARGET_TOOL) {
    return { type: 'pass' };
  }

  const text = extractResponseText(envelope.body);
  if (text == null) return { type: 'pass', comment: 'no result present to scope' };

  let records: unknown;
  try {
    records = JSON.parse(text);
  } catch {
    return { type: 'block', comment: `expected JSON from '${TARGET_TOOL}' but the response was not parseable` };
  }
  if (!Array.isArray(records)) {
    return { type: 'block', comment: `expected a list from '${TARGET_TOOL}' but got something else` };
  }

  const ownerId = await resolveOwnerId(envelope.metadata.userGuid);
  if (ownerId == null) {
    // Can't establish who's asking — fail closed rather than over-share.
    return { type: 'block', comment: 'no caller identity to scope results to' };
  }

  const visible = records.filter((row) => (row as { ownerId?: string }).ownerId === ownerId);
  const modifiedBody = buildResponseWithReplacedText(envelope.body, JSON.stringify(visible));
  return {
    type: 'modify',
    comment: `scoped ${records.length} → ${visible.length} record(s) for caller`,
    modifiedPayload: { body: modifiedBody },
  };
}
```

```json Raw Tool Response theme={null}
[
  { "id": "OPP-1", "name": "Acme renewal", "ownerId": "USR-jane" },
  { "id": "OPP-2", "name": "Globex expansion", "ownerId": "USR-raj" },
  { "id": "OPP-3", "name": "Initech pilot", "ownerId": "USR-jane" }
]
```

```json Modified Tool Response — Caller USR-jane theme={null}
[
  { "id": "OPP-1", "name": "Acme renewal", "ownerId": "USR-jane" },
  { "id": "OPP-3", "name": "Initech pilot", "ownerId": "USR-jane" }
]
```

Note the **fail-closed** choices: when the engine can't establish who's calling, it blocks rather than returning the full, unfiltered list. Returning an empty array is the gentler alternative when an unidentified caller should simply see nothing.

<Tip>
  **Value — one rule enforces per-user scoping everywhere the tool is used.** Instead of teaching every agent prompt and every upstream MCP server about row-level access, a single webhook trims each response to the caller's own records. That closes the over-exposure gap on shared, broadly-scoped tools and gives you one auditable place where the access rule lives.
</Tip>

## Block and audit on a policy violation

*Goal: data governance and compliance.* Some responses must never pass at all — a document classified above the caller's clearance, or one carrying a restricted data class. Detect the condition and return [**block**](/advanced/building-a-custom-rule-engine#block-—-reject-the-message): the gateway replaces the result with a JSON-RPC error, and if the rule has [alerts](/features/alerts) enabled, your `comment` is what the alert renders. The same comment lands in the `rule_engine_comment` column in your [logs](/features/viewing-logs), giving you an audit trail of every blocked call.

```ts block-on-classification.ts theme={null}
import { extractResponseText } from './helpers';
import type { WebhookRequest, WebhookResponse } from './webhook-types';

const TARGET_SERVER = 'MIS-4d7b1f88'; // the McpInboundServer guid this rule is scoped to
const TARGET_TOOL = 'get_document';
// Upstream stamps documents with a classification marker. Anything restricted or above
// must never reach the model through this gateway.
const BLOCKED_CLASSIFICATIONS = new Set(['restricted', 'secret']);

export function blockOnClassification(envelope: WebhookRequest): WebhookResponse {
  // The engine sees every tool on the gateway. Act only on our server + tool;
  // pass everything else straight through.
  if (envelope.metadata.serverGuid !== TARGET_SERVER || envelope.metadata.toolName !== TARGET_TOOL) {
    return { type: 'pass' };
  }

  const text = extractResponseText(envelope.body);
  if (text == null) return { type: 'pass', comment: 'no result present to classify' };

  let record: { classification?: string };
  try {
    record = JSON.parse(text);
  } catch {
    // For a governance gate, an unreadable response is exactly when to fail closed.
    return { type: 'block', comment: `expected JSON from '${TARGET_TOOL}' but the response was not parseable` };
  }

  const classification = record.classification?.toLowerCase();
  if (classification && BLOCKED_CLASSIFICATIONS.has(classification)) {
    return {
      type: 'block',
      comment: `blocked ${classification} document from tool '${envelope.metadata.toolName}'`,
    };
  }

  return { type: 'pass', comment: 'classification within policy' };
}
```

```json Raw Tool Response theme={null}
{
  "id": "DOC-44",
  "title": "FY27 acquisition shortlist",
  "classification": "restricted",
  "body": "…"
}
```

```json Engine Verdict — sent back to the gateway theme={null}
{ "type": "block", "comment": "blocked restricted document from tool 'get_document'" }
```

The gateway then replaces the tool result with a JSON-RPC error, so the client sees a clean failure rather than the protected content. If you'd rather hand the agent a graceful, on-brand message than the generic block error, return a [`modify` with an `error` body](/advanced/building-a-custom-rule-engine#modify-—-rewrite-the-message) instead of `block` — for example, an error whose message reads "This document can't be accessed through this assistant." Either way the data never leaves the gateway.

<Note>
  The **custom** provider's response contract is `type` + `comment` + (for modify) `modifiedPayload`. There's no structured `detections` field to return — that's reserved for the built-in Presidio and Lakera providers. Put what a human needs to know into `comment`.
</Note>

<Tip>
  **Value — a hard compliance stop with a built-in audit trail.** Restricted data never leaves the gateway, regardless of how the agent phrased the request. Every block writes its `comment` to the alert and the log, so you get a per-incident record of what was withheld and from which tool — the difference between a control you can attest to and a filter you hope is working.
</Tip>

## Combining recipes

These aren't mutually exclusive. A single webhook can run several in sequence — block on classification first, then strip fields by name, then slim and summarize what's left — returning a single `modify` with the cumulative result. You can also split them across [several gateway rules](/features/gateway-rules/overview) on the same gateway, each pointed at the same engine or different ones; rules fire in order, and the first `block` short-circuits the rest. Keep each transform small and idempotent, and let the rule ordering compose them.

## Further reading

<CardGroup cols={2}>
  <Card title="Building a Custom Rule Engine" icon="code" href="/advanced/building-a-custom-rule-engine">
    The full webhook contract these recipes build on: envelope, response shapes, validation, and limits.
  </Card>

  <Card title="Custom Rule Engines" icon="plug" href="/features/gateway-rules/custom-rules-engines">
    Registering, testing, header forwarding, and managing the engine in the UI.
  </Card>

  <Card title="Gateway Rules Overview" icon="shield-halved" href="/features/gateway-rules/overview">
    Detection methods, hooks, failure modes, and how rules compose in order.
  </Card>

  <Card title="Viewing Logs" icon="list" href="/features/viewing-logs">
    Where rule-engine comments and outcomes land for auditing.
  </Card>
</CardGroup>
