# Failure modes and script rules

The failures that break marketing MCP setups in production, how to detect each and how to fix it, followed by what every rule in the three scripts checks: `scripts/mcp_inventory.py` (S rules), `scripts/mcp_tools_diff.py` (D rules) and `scripts/mcp_inventory_check.py` (C rules), with the regression fixtures that pin their behaviour.

## Failure modes

F01 Wrong account, portal, property or workspace

- Detect: T1 ID mismatch; answers match another account; the Braze workspace or Klaviyo account choice was skipped at consent.
- Fix: reconnect with the right account selected; one profile or project per account; put the account in the server name.

F02 Wrong regional endpoint

- Detect: an empty project list or a 401 or 403 after a good sign-in; scanner S07 with `--expect-region`.
- Fix: use the region's endpoint (Mixpanel mcp-eu or mcp-in, Amplitude mcp.eu, Braze .eu, Intercom mcp.eu). Amplitude's chat-app directory connector uses the US server, so EU projects need a custom connection to the EU endpoint (see analytics-servers.md).

F03 Read-only control applied where it does nothing

- Detect: T2 still lists write tools; the auth-status tool shows broader scopes than intended.
- Fix: Klaviyo parameters need a custom connector, not the listed app. `GSC_ENABLE_WRITES=0` does not narrow an existing full-scope token: revoke it and log in with `--read-only`. A prompt saying "do not change anything" is not a control.

F04 Write tools exposed by default

- Detect: T2 lists `manage_`, `create_`, `update_`, `delete_`, `send_`, `share_`, `use_` or `launch_` tools: the HubSpot connector, a Meta grant that includes `ads_management`, Mixpanel roles, Amplitude Member, Manager and Admin roles (and Amplitude's dashboard, notebook, cohort and experiment tools, which Use MCP (read) alone is not proven to close), Google Ads on its bundled tools config, GA4 with `--allow-write` or `ANALYTICS_MCP_ALLOW_WRITE`.
- Fix: apply the per-tool layer from step 6, then allow the read tools in the client, or block the rest with a glob on the write verb where the client accepts one.

F05 Lookalike or community server where an official one exists

- Detect: scanner S01; the endpoint host is not on the vendor's domain list.
- Fix: replace it with the official server, or run the publisher review and record why the official server cannot do the job. A package inside a vendor's own npm scope or GitHub organisation that the registry does not list is a WARN to verify, not a lookalike.

F06 Retired, deprecated or legacy server still configured

- Detect: scanner S02 (the Braze local beta, Salesforce DX after 2026-11-02, Intercom `/sse`, Salesforce hosted `v1-beta` URLs, `Snowflake-Labs/mcp`, archived GTM servers, and HubSpot's legacy beta local server `@hubspot/mcp-server` as a WARN).
- Fix: migrate to the named replacement and retest; remove the old entry and revoke its credential.

F07 Secrets in configs, URLs or repositories

- Detect: scanner S03 (a literal value in a sensitive env, header or argument: names with token, secret, password, key, PK, SK, PAT or auth; any `Bearer` or `Basic` value; a key-like positional argument or a value after `--key`, `--pat`, `-k` or `--auth`; a password in a connection string, a package URL, a quoted command or a JSON argument; a client secret in an `auth` or `oauth` block), S04 (a credential or key-like value in a URL query or fragment, user info, a `;password=` parameter, a random path segment) and S12 (a credential file inside the project).
- Fix: move to OAuth or an environment or input reference; the owner rotates anything that was written down or committed.

F08 Shadowed or misplaced definitions

- Detect: scanner S09 (wrong top-level key for the client) and S10 (the same name in several scopes); a chat-app connector listed as hidden.
- Fix: one definition per server per client; VS Code workspace files use `servers`; remove duplicates.

F09 Unattended runs and auto-approved project servers

- Detect: a routine's call log lists servers nobody approved; auth-needed errors in non-interactive mode; cloud routines that carry every connected connector by default; scanner S14 (`enableAllProjectMcpServers` or `enabledMcpjsonServers` in a settings file) and S15 (a mode or setting that skips a person).
- Fix: sign in interactively first; give routines their own read-only identity; keep write-capable servers out of project files; remove the auto-approval keys where marketing servers live; block project servers with `disabledMcpjsonServers` or a strict config. A local server on someone's laptop is not available to a cloud routine.

F10 Tool surface drift

- Detect: tools diff FAIL D01 (a new write, hard-to-reverse or unknown tool on a read-only connection), FAIL D06 (a write tool that lost its block or source denial) or FAIL D04; a vendor changelog entry about MCP tools.
- Fix: block the new tool or prove it absent; restore a lost block and rerun T4; prefer controls that deny new tools by default.

F11 Truncation presented as a total

- Detect: T5; a row count equal to a known cap (BigQuery 3,000 rows, HubSpot 200 per page, Snowflake 250 KB, Funnel 500 rows, a client output cap such as Claude Code's 25,000 tokens per tool result).
- Fix: aggregate at the source; page explicitly; require the answer to state its coverage.

F12 Client parity gaps for the same vendor

- Detect: T2 tool counts differ per client; validation differs (HubSpot pipeline validation applies through one chat app and not the other; Amplitude gives ChatGPT a curated subset).
- Fix: test each client separately, one inventory block per server and client.

F13 Network blocks for remote connectors

- Detect: sign-in works, calls fail; vendor IP allowlisting (Braze); a new Salesforce External Client App not active yet (up to 30 minutes). Snowflake: a network policy fails the token request with `invalid_client`, which looks like a bad client ID or secret.
- Fix: allow the client provider's IP ranges where policy permits, or choose a local or export route.

F14 Over-privileged or shared identity

- Detect: T1 shows an admin role or many accounts; Meta rejects Admin-role system user tokens; one login shared by a team; Snowflake T1 shows a role other than `MCP_READER`, because OAuth sessions run as the user's `DEFAULT_ROLE` and `ALLOWED_ROLES_LIST` only limits which roles may connect.
- Fix: per-user OAuth with the lowest role that passes T3; service identities only for unattended jobs; for Snowflake, set `DEFAULT_ROLE` and `DEFAULT_WAREHOUSE` for the MCP user (draft in `assets/read-only-snippets.md`).

F15 Credential custody and expiry on laptops

- Detect: application default credentials or key files inside repositories, world-readable, or with the `cloud-platform` or `adwords` scope treated as read-only. `invalid_grant` about 7 days after setup: the team's own Google OAuth client has an External consent screen in Testing status. Insufficient-scope errors on GA4 after someone ran `gcloud auth application-default login` for another tool, which overwrites the single default credential file.
- Fix: default credential locations with owner-only permissions; impersonation instead of key files; an Internal or published consent screen for team OAuth clients; a dedicated credential file per server through `GOOGLE_APPLICATION_CREDENTIALS`, kept outside the repository; the credential recorded as write-capable.

F16 Known answer disagrees for definitional reasons

- Detect: a T3 mismatch traced to time zone, attribution, thresholding, a saved filter that was not applied, or Search Console rows summed across every search type.
- Fix: align the definition and rerun; never accept an unexplained difference.

F17 Instructions inside tool results

- Detect: the synthetic injection test; text in notes, reviews or conversations that addresses the agent.
- Fix: quote it and do not act on it; split profiles so no session reads outsider text, reads private data and holds send-out tools at once.

F18 Org or plan policy blocks the setup

- Detect: a connector cannot be added; chat-app workspace roles lack the custom MCP permission; a managed client policy rejects the server.
- Fix: draft the request for the workspace or client admin; never work around policy.

F19 Tools hidden behind an exec or discovery tool

- Detect: the tool list shows only `exec` (PostHog CLI mode, the default for Claude clients and Codex) or only `list_tool_categories`, `get_category_tools` and `describe_tool` (Amplitude `discovery=progressive`); scanner S06 WARN on a PostHog URL without `mode=tools` or an Amplitude URL with progressive discovery. T2 and per-tool client blocks then see nothing to compare or block.
- Fix: PostHog `mode=tools` (or the `x-posthog-mcp-mode: tools` header); Amplitude without `discovery=progressive`. Where CLI mode stays, `readonly=true` is the only layer, and T1, T2 and T4 run against the listing `exec` returns.

F20 Self-hosted Google Ads server open to anyone with the URL

- Detect: a Cloud Run deployment with `--allow-unauthenticated` and no OAuth client variables falls back to application default credentials and answers anyone with the URL as the service account. An unauthenticated POST of `tools/list` to `<service-url>/mcp` must return 401; any other result is FAIL.
- Fix: set `GOOGLE_ADS_MCP_OAUTH_CLIENT_ID` and `GOOGLE_ADS_MCP_OAUTH_CLIENT_SECRET`, redeploy, and rotate the service account's access.

## Scanner rules (scripts/mcp_inventory.py)

- S01 route: official PASS; known third party or community WARN (publisher review); a package in a vendor's own namespace that the registry does not list WARN (verify, then add it); a host, path or package named after a vendor that publishes its own server FAIL (lookalike); a label-only match or an unknown publisher WARN; a retired vendor package INFO (S02 carries the finding).
- S02 retired, deprecated or legacy: FAIL, or WARN before a registry end-of-life date or for a legacy entry the registry marks WARN.
- S03 inline secret: a literal value in a sensitive env, header or argument, a `Bearer` or `Basic` value anywhere, an auth or PAT name with a value of 20 or more characters, a key-like positional argument, a password in a connection string of any form (a URL of any scheme in an argument or env value, `user:password@host`, JDBC `;password=`, keyword strings), a token in a package URL, a credential in a quoted command or a JSON argument, a client secret in an `auth` or `oauth` block, or a JSON blob in an env value: FAIL.
- S04 credential in a URL: a sensitive query key with a literal value FAIL, with a `${...}` reference WARN; user info, or a password or token parameter after `;` or `#`, FAIL; a key-like query value under any key name, or a long random path segment, WARN. The inventory prints none of these values.
- What prints: env and header values never. An argument prints only when it is a flag, a path, a package spec, a number or a plain lower-case word; everything else prints as `[redacted]`, and a URL keeps its host and path but loses user info and any query value that is not a plain setting. A value that may be a name (after `-p`, `--dsn` or `--auth`) stays hidden without a finding. TOML parser messages are cut to a line number, and every line of output passes one filter for URL user info, `Bearer` values and sensitive `key=value` pairs. Treat `[redacted]` in the inventory as hidden, not as proof of a secret: S03 and S04 are the findings. A plain lower-case word after a flag with a neutral name still prints, because nothing marks it as a credential, so read the output before sharing it.
- S05 unpinned install: `@latest`, no version, a git source without a commit, or a Docker image without a tag: WARN for official packages, FAIL for anything else.
- S06 read-only setting: the registry's checks for the matched rule. Enforcing checks (Klaviyo `read-only=true` and `READ_ONLY=true`, PostHog `readonly`, GA4 without `--allow-write` or `ANALYTICS_MCP_ALLOW_WRITE`, Salesforce sobject-reads, `GSC_ENABLE_WRITES=0`, Stape `GTM_SCOPES`, Google Ads `GOOGLE_ADS_MCP_TOOLS_CONFIG`, whose file is not opened) at their own severity; advisory checks (Klaviyo user-generated content, PostHog `project_id` and `mode=tools`, Amplitude without progressive discovery) WARN; PASS when all pass; INFO when the vendor has no config-level switch and the layer must be proven in T2 and T4. HubSpot's developer server gets no S06 line.
- S07 region: the endpoint's region differs from `--expect-region`: FAIL; an auto-routing endpoint: INFO.
- S08 transport: plain `http://` or `ws://` to a non-local host FAIL; SSE on a vendor that serves Streamable HTTP WARN.
- S09 wrong top-level key: a VS Code file with `mcpServers`, or another client's file with `servers`: FAIL, because the server never loads.
- S10 shadowed: the same server name in several scopes of one client: WARN naming which entry wins. Local over project: the local entry wins for this user only. Local or project over user: that entry wins inside the project; the user entry applies in other projects.
- S11 shared write-capable: a write-capable server without a passing enforcing check in a project or workspace file: WARN.
- S12 credential file in the project: a credential path variable or argument that is relative or inside the project directory: FAIL. An `envFile` inside the project: WARN, and the file is not opened.
- S13 proxy wrapper: `mcp-remote` or a similar proxy: WARN when unpinned, INFO when pinned, FAIL on a version the registry marks affected (mcp-remote 0.0.5 to before 0.1.16, CVE-2025-6514); the wrapped URL is classified with S01 to S08.
- S14 auto-approved project servers: `enableAllProjectMcpServers: true` or an `enabledMcpjsonServers` list in user, project, project local or managed settings WARN; `disabledMcpjsonServers` INFO.
- S15 settings that skip a person: `sandboxEnabled: true` on a server, a Codex `auto` or `approve` mode on a server or tool, `permissions.defaultMode` `auto` or `bypassPermissions`, and a `permissions.allow` rule for a whole server or a tool whose name reads as a write: WARN. A settings file with no mode is not reported: from v2.1.283 interactive sessions then start in auto mode (clients.md).
- Exit codes: 1 on any FAIL; otherwise 2 when a file was not read, because the scan is incomplete; otherwise 0.

Regression fixture `assets/regression-configs.json`, run with `--as-of 2026-11-01`, must end `Summary: 27 FAIL, 45 WARN, 5 INFO, 15 PASS`. Every placeholder value holds `exampleonly` or `EXAMPLE-ONLY`, so `... --as-of 2026-11-01 | grep -Eic 'example-?only'` must print 0, for the text output and for `--json`, on Python 3.9 and on 3.11 or later. The secret cases produce these lines (shortened):

```text
FAIL S03 secret-positional (cursor global ~/.cursor/mcp.json): a positional argument looks like a key or token (value not printed). ...
FAIL S03 secret-key-flag (cursor global ~/.cursor/mcp.json): argument --key has an inline value (value not printed). ...
FAIL S03 secret-pat-flag (cursor global ~/.cursor/mcp.json): argument --pat has an inline value (value not printed). ...
FAIL S03 klaviyo-local-pk (cursor global ~/.cursor/mcp.json): env KLAVIYO_PK has an inline value (value not printed). ...
FAIL S03 snowflake-pat (cursor global ~/.cursor/mcp.json): env SNOWFLAKE_PAT has an inline value (value not printed). ...
FAIL S03 posthog-proxy (cursor global ~/.cursor/mcp.json): env POSTHOG_AUTH_HEADER has an inline value (value not printed). ...
FAIL S03 gateway-proxy (cursor global ~/.cursor/mcp.json): header X-Auth has an inline value (value not printed). ...
WARN S04 gateway-proxy (cursor global ~/.cursor/mcp.json): URL query key k carries a value that looks like a key or token (not printed). ...
WARN S04 gateway-proxy (cursor global ~/.cursor/mcp.json): URL query key sig carries a value that looks like a key or token (not printed). ...
FAIL S03 secret-short-k (cursor global ~/.cursor/mcp.json): argument -k has an inline value (value not printed). ...
FAIL S03 connection-positional (cursor global ~/.cursor/mcp.json): a connection string in a positional argument has an inline value (value not printed). ...
FAIL S03 connection-dsn-flag (cursor global ~/.cursor/mcp.json): a connection string in argument --dsn has an inline value (value not printed). ...
FAIL S03 connection-env (cursor global ~/.cursor/mcp.json): env DATABASE_URL has an inline value (value not printed). ...
FAIL S03 connection-jdbc (cursor global ~/.cursor/mcp.json): a connection string in a positional argument has an inline value (value not printed). ...
FAIL S03 connection-keywords (cursor global ~/.cursor/mcp.json): a quoted command or connection string has an inline value (value not printed). ...
FAIL S03 token-in-package-url (cursor global ~/.cursor/mcp.json): a connection string in argument --from has an inline value (value not printed). ...
FAIL S03 secret-quoted-command (cursor global ~/.cursor/mcp.json): a quoted command or connection string has an inline value (value not printed). ...
FAIL S03 secret-json-argument (cursor global ~/.cursor/mcp.json): argument --config has an inline value (value not printed). ...
FAIL S03 secret-auth-block (cursor global ~/.cursor/mcp.json): auth field CLIENT_SECRET has an inline value (value not printed). ...
FAIL S03 klaviyo-settings (vscode user settings ~/.config/Code/User/settings.json): env PRIVATE_API_KEY has an inline value (value not printed). ...
FAIL S03 records (claude-code managed settings /etc/claude-code/managed-settings.json): header X-Records-Key has an inline value (value not printed). ...
FAIL S12 credential-in-other-repo (cursor project ~/other-repo/.cursor/mcp.json): GOOGLE_APPLICATION_CREDENTIALS points inside the project (~/other-repo/secrets/adc.json). ...
```

The same run also shows: `~/.codex/config.toml` under Not read as `TOML error at line 3 (details not printed)`, because its unquoted `API_TOKEN` value stops the parse; no S03 for `quiet-controls` (`--auth oauth`, a UUID after `--project-id`, a credential path outside the project); no S03 for `hidden-without-finding`, whose three values print as `[redacted]`; PASS S01 for `@hubspot/mcp-server` with WARN S02 (legacy beta) and PASS S01 for `@hubspot/cli` (developer server); WARN S01 for `@hubspot/example-tool` (vendor namespace, not in the registry); WARN S06 for the PostHog proxy without `project_id` and `mode`; WARN S14 for both auto-approval keys; PASS S01 for Google's PyPI package, with FAIL S06 on `google-ads-default` and PASS S06 on `google-ads-tools-config`; FAIL S06 on `ga4-env-write`; FAIL S13 on `bridge-affected` and INFO S13 on `bridge-current`; WARN S12 on `env-file-in-repo`; FAIL S08 on `plain-websocket`; WARN S15 on `sandboxed-server`, `approve-everything`, `approve-one`, `permissions.defaultMode` and two `permissions.allow` rules, with no S15 on the read tool rule.

## Drift rules (scripts/mcp_tools_diff.py)

- D01: a new write, hard-to-reverse or unknown tool on a read-only connection FAIL; INFO when the snapshot already marks it blocked or denied; WARN on a read-write connection.
- D02: a new read tool WARN, with a note when its name suggests text written by outsiders.
- D03: a removed tool INFO: update the inventory and allowlists.
- D04: any write, hard-to-reverse or unknown tool with neither a blocked nor a denied_by marker on a read-only connection FAIL, with or without a baseline, unless D01 or D06 already reported it.
- D05: `readOnlyHint` true on a tool whose name reads as a write WARN: annotations are hints, not controls.
- D06: a write, hard-to-reverse or unknown tool that was blocked or denied in the baseline and is neither now FAIL: a deny rule removed, a Blocked category reset, VS Code's Chat: Reset Tool Confirmations, or a role change.
- Markers: `"blocked": true` when the client blocks the tool; `"denied_by"` with `role`, `iam`, `grant`, `scope` or `server` when the source denies it, set only after T4 proved the denial.
- Classification uses name tokens (hard: delete, send, publish, launch, merge and similar; write: create, update, manage, submit and similar; read: get, list, search, query and similar). A name with no known verb is unknown and treated as a write. Mark a reviewed tool with `"reviewed_as"` in the snapshot.

Regression pair `assets/regression-tools-current.json` with `--baseline assets/regression-tools-baseline.json` must end `Summary: 3 FAIL, 0 WARN, 0 INFO`:

```text
FAIL D04 update_contact: write tool with no blocked or denied_by marker on a read-only connection. ...
FAIL D06 create_dashboard: write tool was denied by role in the baseline and is neither now. ...
FAIL D06 manage_crm_objects: write tool was blocked in the client in the baseline and is neither now. ...
```

`execute_sql`, still `"denied_by": "iam"`, raises nothing. Without the baseline the same snapshot gives three D04 FAILs. A snapshot with an empty tools list exits 2: nothing was checked.

## Inventory check rules (scripts/mcp_inventory_check.py)

- C01: every block has all template labels, none of them blank (Tests holds the T lines), and a valid Status.
- C02: no `<...>` placeholder remains.
- C03: T1 to T5 each carry PASS, WARN, FAIL or UNPROVEN, with "because" for WARN and UNPROVEN; T6 is present and, on a connected block, has text and a date.
- C04: Status connected needs T1 PASS or WARN, T2 and T3 PASS, and T4 PASS or UNPROVEN.
- C05: any FAIL or blocked block is named under Summary "Failed or blocked"; a T1 WARN is named under "Open risks".
- C06: a client-only read-only layer records "user told" with a date.
- C07: Prepared has a date; Next review is at most 30 days after it and not overdue.
- C08: T1 to T4 dates on connected blocks are no more than 30 days old.
- C09: "Connected and tested" equals the number of connected blocks.
- C10: the heading names account, environment and client (WARN otherwise).
