Skip to main content
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 make possible, each with a runnable handler and before/after responses. This is the applied companion to 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 (WebhookRequest, WebhookResponse) and helpers (extractResponseText, buildResponseWithReplacedText).
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.
Every recipe below is a function you’d call from the /inspect route handler in that page’s Express example. They sort into three motivations:
MotivationRecipes
Cost — fewer tokens for the model to readSlim verbose responses, Summarize long fields
Data governance — control what data can ever reach the modelStrip or redact fields by name, Block on policy violation
Identity — scope data to who’s actually askingIdentity-scoped filtering

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.
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 for the shapes you might encounter.
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.

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.
slim-fields.ts
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 },
  };
}
Raw Tool Response
{
  "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"
}
Modified Tool Response
{ "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.
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.

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.
strip-fields.ts
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 },
  };
}
Raw Tool Response
{
  "id": "003ABC",
  "name": "Jane Doe",
  "ssn": "123-45-6789",
  "creditScore": 740,
  "email": "jane@example.com"
}
Modified Tool Response — Redact
{
  "id": "003ABC",
  "name": "Jane Doe",
  "ssn": "{{REDACTED}}",
  "creditScore": "{{REDACTED}}",
  "email": "jane@example.com"
}
Modified Tool Response — Delete
{
  "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.
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.

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.
summarize-field.ts
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 },
  };
}
Raw Tool Response
{
  "id": "TICK-91",
  "subject": "Login fails after SSO migration",
  "description": "…1,800 words of back-and-forth troubleshooting, stack traces, and reply chains…"
}
Modified Tool Response
{
  "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: 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.
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.

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 to receive the inbound connection’s identity headers and map them to your own access model.
identity-filter.ts
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 },
  };
}
Raw Tool Response
[
  { "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" }
]
Modified Tool Response — Caller USR-jane
[
  { "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.
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.

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: the gateway replaces the result with a JSON-RPC error, and if the rule has alerts enabled, your comment is what the alert renders. The same comment lands in the rule_engine_comment column in your logs, giving you an audit trail of every blocked call.
block-on-classification.ts
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' };
}
Raw Tool Response
{
  "id": "DOC-44",
  "title": "FY27 acquisition shortlist",
  "classification": "restricted",
  "body": "…"
}
Engine Verdict — sent back to the gateway
{ "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 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.
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.
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.

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

Building a Custom Rule Engine

The full webhook contract these recipes build on: envelope, response shapes, validation, and limits.

Custom Rule Engines

Registering, testing, header forwarding, and managing the engine in the UI.

Gateway Rules Overview

Detection methods, hooks, failure modes, and how rules compose in order.

Viewing Logs

Where rule-engine comments and outcomes land for auditing.