Fluent CLI & Fluent MCP - PII Scrubbing
Essential knowledge
Intended Audience:
Technical User
Author:
Marco Heuer
Changed on:
14 Oct 2026
Overview
How the Fluent CLI and the Fluent MCP server replace personal and sensitive values in live platform data with placeholder tokens such as`[REDACTED_EMAIL]` before the data is printed or handed to an AI assistant.Key points
- Automatic: scrubbing is always on. No flag turns it on or off.
- Output only: nothing sent to the platform is modified.
- Where it applies:
`fluent gql query`,`fluent event list`,`fluent event send`responses, and the MCP`graphql_execute`tool — both table and`--json`output. - Diagnostics stay intact: entity IDs, refs, statuses, location refs, tracking numbers, country codes and business address fields come through unchanged.
- Setting
`lobValue`is exempt so manifests and other JSON settings can be read, edited and written back without saving redaction tokens. - Best-effort and rule-based: it matches known field names and a few value patterns. It does not guarantee that all PII is removed.
Overview
Some Fluent CLI commands, and the Fluent MCP server's GraphQL execute tool, return live platform data such as GraphQL query results and orchestration events. Before that data is printed or handed to an AI assistant, it goes through a PII scrubber that replaces personal and sensitive values with fixed placeholder tokens such as`[REDACTED_EMAIL]`.This matters most when an AI assistant drives the CLI or calls the MCP server. Claude Code, Codex, Gemini CLI and similar tools read command and tool output into their context, so scrubbing keeps customer names, contact details, addresses and credentials out of the AI session. Business identifiers stay intact, so you can still debug: entity IDs, refs, statuses, location refs, tracking numbers and country codes all come through unchanged.Where Scrubbing Is Applied
| Command / tool | What is scrubbed |
`fluent gql query` | The whole GraphQL response, before it is shown as a table or as `--json`. |
`fluent event list` | Every event record returned by the Event API (`GET /api/v4.1/event`), including `context` and `attributes`. |
`fluent event send` | The Event API response (`POST /api/v4.1/event/async` or `/sync`). The request payload is sent as-is. |
MCP tool `graphql_execute` | The whole GraphQL response, including any `errors[]` returned with it, before it is returned to the AI assistant. If the request fails, the error payload (message and query echo) is also scrubbed. |
`fluent-trace` skill depends on this behaviour. It tells the AI to read redaction tokens as expected values rather than errors, and not to try to recover the original data.Not Scrubbed
These paths do not go through the scrubber at present:| Path | Notes |
| Other MCP server tools | Only `graphql_execute` is scrubbed. Other MCP tools (workflow, schema, health and vector tools) return their output unscrubbed. |
| Error messages from the Event API | When the API returns HTTP ≥ 400, the error message contains the raw response data without scrubbing. |
| Debug and error logging | With debug logging on, GraphQL request payloads and unsuccessful responses are logged raw. |
| Unexpected event response shapes | `fluent event list` logs the raw response at debug level when it can't find a records array. |
| Every other CLI command | Workflow, setting, module and profile commands, and so on, print their output unscrubbed. |
`graphql_execute` error path is scrubbed defensively. Today a failed request only returns the HTTP status line (for example `GraphQL HTTP 400: Bad Request`) and the first 200 characters of the query; the raw response body is never returned to the AI.How It Works
After the CLI or MCP server receives a response from the platform, and before anything is displayed or returned to the AI assistant, it checks each text value in the response, including values in nested objects and lists. A value is replaced with a placeholder token in any of these cases:- Its field name identifies personal or sensitive data. Examples are
`email`,`primaryPhone`,`firstName`,`postcode`and`password`. The value is redacted whatever it contains. - Its content is recognisably sensitive. Email addresses and
`Bearer`tokens are redacted wherever they appear, even under a field name that isn't on the list. Emails embedded in a`message`string are replaced in place. - It is the
`value`of a name/value attribute whose`name`identifies personal data, for example`{ "name": "email", "value": "..." }`.
`[REDACTED_NAME]`), so you can see what was there without seeing the value itself.Field-Name Rules
Field names are matched case-insensitively and exactly. A key must equal one of the names below; containing one is not enough.| Placeholder | Field names |
`[REDACTED_EMAIL]` | `email`, `emailAddress`, `email_address`, `e_mail`, `primaryEmail`, `deliveryEmail` |
`[REDACTED_PHONE]` | `phone`, `primaryPhone`, `phoneNumber`, `phone_number`, `mobile`, `mobileNumber`, `mobile_number`, `telephone`, `tel` |
`[REDACTED_PASSWORD]` | `password`, `passwd`, `pwd`, `secret` |
`[REDACTED_USERNAME]` | `username`, `user_name`, `login`, `loginName` |
`[REDACTED_ADDRESS]` | `address`, `streetAddress`, `street_address`, `addressLine1`, `addressLine2`, `address_line1`, `address_line2`, `street`, `suburb`, `postcode`, `postalCode`, `postal_code`, `zipcode`, `zip_code`, `zip` |
`[REDACTED_NAME]` | `firstName`, `first_name`, `lastName`, `last_name`, `fullName`, `full_name`, `deliveryFirstName`, `deliveryLastName`, `deliveryContact` |
`[REDACTED_AUTH]` | `authorization`, `x-api-key`, `x-token`, `x-auth-token` |
Value-Pattern Rules
These apply to string values under any key, including keys that are not in the list above.| Placeholder | Pattern | Matches | Does not match |
`[REDACTED_EMAIL]` | `^[^\s@]+@[^\s@]+\.[^\s@]{2,}$` | `bob@corp.io`, `a+b@x.io` | `Contact bob@corp.io` (the whole value must be an email), `@nodomain` |
`[REDACTED_AUTH]` | `^Bearer\s+\S+$` (case-insensitive) | `Bearer eyJhbGci...`, `bearer abc` | `Basic dXNlcjpwYXNz`, a bare JWT with no `Bearer` prefix |
`message` (for example GraphQL `errors[].message`), email addresses inside the text are also replaced: `"No customer found for alice@example.com"` becomes `"No customer found for [REDACTED_EMAIL]"`.Attribute Rule
Fluent entities carry attributes as`{ name, type, value }` objects, so the PII signal is in the attribute's `name`, not in a key. When an object has a string `name` that equals one of the field names above (case-insensitive) and a non-null `value`, the whole `value` is replaced with `[REDACTED]`, whatever its type.| Input | Output |
`{ "name": "email", "type": "STRING", "value": "alice@example.com" }` | `{ "name": "email", "type": "STRING", "value": "[REDACTED]" }` |
`{ "name": "address", "type": "JSON", "value": { "street": "1 Main St" } }` | `{ "name": "address", "type": "JSON", "value": "[REDACTED]" }` |
`{ "name": "loyaltyTier", "type": "STRING", "value": "GOLD" }` | unchanged |
Preserved Fields
Fields not matched by any rule pass through. Diagnostic identifiers are deliberately kept. These, for example, are not scrubbed:`id`, `ref`, `customer.id`, `status`, `type`, `category`, `trackingNumber`, `carrierRef`, `fromLocationRef`, `country`, `state`, `name`.`name` is kept on purpose. Across the Fluent domain it usually holds an event, ruleset, setting or entity name rather than a person's name.Business address and contact fields are also kept on purpose, because rules match by field name only and cannot tell a customer address from a store or warehouse address. Keeping them preserves sourcing and location diagnosis: `city`, `street2`, `companyName`, `latitude`, `longitude`, `supportEmail`, `supportPhone`, `supportPhoneNumber`, `supportContactName`.Exempt Fields
Some fields are passed through as a whole: the scrubber does not look inside them at all, so no rule above applies to their contents. Field names are matched case-insensitively.| Field | Why |
`lobValue` | Holds the body of JSON Settings such as Mystique manifests (`fc.mystique.manifest.*`) and security headers (`fc.mystique.security.headers`). These are read, edited and written back with `updateSetting`. Scrubbing them would persist redaction tokens into the setting — for example, manifest templates like `"street": "{{orderById.fulfilmentChoice.deliveryAddress.street}}"` would be saved as `[REDACTED_ADDRESS]`. |
`fluent-graphql-query`, `fluent-mystique-manifest-load-and-save`, `fluent-mystique-security`) also tell the AI never to send a value containing `[REDACTED` in a mutation.Example
Raw GraphQL Response
1{
2 "orders": {
3 "edges": [
4 {
5 "node": {
6 "id": "1234",
7 "ref": "HD-001",
8 "status": "BOOKED",
9 "customer": {
10 "id": "42",
11 "ref": "CUST-001",
12 "firstName": "Alice",
13 "lastName": "Wong",
14 "email": "alice@example.com",
15 "primaryPhone": "0412345678"
16 },
17 "fulfilmentChoice": {
18 "deliveryAddress": {
19 "street": "1 Main St",
20 "city": "Sydney",
21 "postcode": "2000",
22 "country": "AU"
23 }
24 }
25 }
26 }
27 ]
28 }
29}Scrubbed Output
1{
2 "orders": {
3 "edges": [
4 {
5 "node": {
6 "id": "1234",
7 "ref": "HD-001",
8 "status": "BOOKED",
9 "customer": {
10 "id": "42",
11 "ref": "CUST-001",
12 "firstName": "[REDACTED_NAME]",
13 "lastName": "[REDACTED_NAME]",
14 "email": "[REDACTED_EMAIL]",
15 "primaryPhone": "[REDACTED_PHONE]"
16 },
17 "fulfilmentChoice": {
18 "deliveryAddress": {
19 "street": "[REDACTED_ADDRESS]",
20 "city": "Sydney",
21 "postcode": "[REDACTED_ADDRESS]",
22 "country": "AU"
23 }
24 }
25 }
26 }
27 ]
28 }
29}Redaction Tokens Reference
When output contains one of these tokens, the original value was present and has been removed. Read them as expected values, not as data errors.| Token | Meaning |
`[REDACTED_EMAIL]` | Email field, a value shaped like an email address, or an email inside a `message` string |
`[REDACTED_PHONE]` | Phone, mobile or telephone field |
`[REDACTED_ADDRESS]` | Address, street, suburb, postcode or zip field |
`[REDACTED_PASSWORD]` | Password or secret field |
`[REDACTED_USERNAME]` | Username or login field |
`[REDACTED_NAME]` | First, last, full or delivery contact name field |
`[REDACTED_AUTH]` | Auth header field, or a `Bearer` token value |
`[REDACTED]` | Value of a name/value attribute whose name is a PII field |
Limitations
The scrubber is rule-based. It lowers the chance of exposing PII but does not guarantee that all PII is removed. Known gaps:| Gap | Example that is not redacted |
| Exact field-name match only: prefixed or compound names are missed | `customerEmail`, `billingAddress` (string), `contactName` |
| Business address fields are kept, so customer values in them pass through | Customer `city`, `street2`, `companyName`, `latitude`, `longitude` |
| Field-name rules apply to every entity, so business values in matching fields are redacted too | Store and warehouse `street` and `postcode` |
| Location fields outside the list | `state`, `region`, `country` |
| Only string values are checked | `"phone": 61412345678`, `"postcode": 2000` (numbers) |
| Strings directly inside arrays have no key, so only nested objects are checked | `"emails": ["alice@example.com"]` |
| Attribute names must match a field name exactly | `{ "name": "customerPhone", "value": "0412345678" }` |
Embedded matching only covers emails in `message` strings | Free text such as `"note": "Call Alice on 0412 345 678"` or `"note": "alice@example.com, VIP"` |
| Common credential key names outside the list | `token`, `accessToken`, `access_token`, `apiKey`, `client_secret`, `refreshToken` |
| Unprefixed tokens | A raw JWT without the `Bearer` prefix |
Object-valued `address` is not redacted as a whole | `"address": { ... }` is recursed into, and only matching child keys are redacted |
| Nesting deeper than 32 levels is returned as-is | Values below the 32nd nested object or array |
Setting `lobValue` is exempt, so anything stored inside it is shown | Credentials or contact details in JSON settings, e.g. an SMS service `username` or `fromEmail` |
The attribute rule applies to any object with `name` and `value` | A Setting literally named `email` or `phone` has its short `value` redacted |
