Repository navigation
fix(readers): read n8n include/excludeTools and host MCP keys/files - #1072
Conversation
…s hosts export
The n8n MCP Client Tool reader read only spellings n8n never writes, so the
default export (no `include`), `include: all` and `include: except`
entered the catalog as one named tool and SHIP-N8N-MCP-CLIENT-TOOLSET-UNFILTERED
never fired. It now reads `include` (absent means `all`), `includeTools`
and `excludeTools`, keeping the earlier spellings as ordered aliases.
SHIP-MCP-ENV-SECRET-PASSTHROUGH read only a server's `env` table; it now
also counts secrets forwarded through `env_vars`, `bearer_token_env_var`,
`env_http_headers` and `${NAME}` references in `headers`.
The codex_config coverage claimed to read other hosts' MCP config files. The
read set and the recognised-but-unread set are now constants the walk and
the coverage text are both built from, and a .cursor/mcp.json or
.vscode/mcp.json under the source root is named in a source warning instead
of passing silently.
Refs #1025
…p-tool-reader-conformance # Conflicts: # CHANGELOG.md # STABILITY.md
|
🤖 Engineering review — independent review, cycle 1 No P0–P2 findings. Two P3 notes, neither blocking:
Points checked
Contract and hygiene:
Commands run
|
…E} as NAME
The MCP Server Trigger shared the client node's new selection reading, so
include: all/except and an empty selected added an mcp_server_wildcard
surface to a trigger, a node n8n gives no selection. Only the MCP Client
Tool now reads include and its defaults; the trigger reads the earlier
spellings as before.
The env reference pattern stopped at ':', so ${env:GITHUB_TOKEN} published
'env' and ${input:x} 'input' as forwarded variables. ${env:NAME} is now
read as NAME, ${NAME:-default} still as NAME, and any other ${prefix:...}
names no variable.
Refs #1025
|
🤖 Engineering review — implementer, addressed cycle 1 Head: 227a21d
Checks on 227a21d:
|
|
🤖 Engineering review — independent review, cycle 2 Narrow delta review of No P0–P2 findings.
Verification
Earlier findings
|
The cycle-1 lookahead rejected every ':' but ':-', so the POSIX ${NAME:?msg}
and ${NAME:=default} forms named no variable where main counted NAME. Only a
':' followed by an identifier character, a ${prefix:id} such as
${input:id}, is now skipped.
Refs #1025
|
🤖 Engineering review — implementer, addressed cycle 2 Head: 53d9ac8
Checks on 53d9ac8:
|
|
🤖 Engineering review — merge readiness
|
Summary
inputs/n8n/_tools.py). The reader now reads n8n's owninclude(all/selected/except), with absent meaningall(n8n leaves the default out of an export), plusincludeToolsandexcludeTools. The default export,include: all,include: except, andinclude: selectedwith noincludeToolseach produce one<node>.*wildcard action, amcp_client_wildcarddynamic surface andSHIP-N8N-MCP-CLIENT-TOOLSET-UNFILTERED. Anexceptnode's exclusions go on the artifact record and into the finding'sexcluded_tools. The legacy spellings stay as aliases, read in a fixed order withincludefirst. Before this, the key was picked from aset, so the result depended on the hash seed.inputs/mcp_manifest.py)._env_secret_namesnow reads the whole server declaration. It still readsenv, and adds Codexenv_vars,bearer_token_env_var(always counted),env_http_headers, and${NAME}references inheaders. These now fireSHIP-MCP-ENV-SECRET-PASSTHROUGHand raise the risk score the same way anenventry does. A value written where a variable name belongs passes throughredact_text, so it is never echoed into evidence.inputs/codex_config.py,inputs/mcp_manifest.py). The read set and the other hosts' MCP files are now two module constants. The walk matches files from them, and thecodex_configcoverage text is generated from them, so the two cannot disagree. A.cursor/mcp.jsonor.vscode/mcp.jsonunder the source root now gets a named source warning (core/source_warnings.unread_host_mcp_config_warning) instead of being skipped silently.docs/determinism-boundary.{md,json}are regenerated.docs/checks.mdprose, thefires_whentext andevidence_fieldsindocs/checks/{n8n,mcp_permissions}.yaml, and the regenerateddocs/checks.json. CHANGELOG has three bullets under## Unreleased. STABILITY.md has a migration note (#mcp-reader-conformance-1025) recording which check conditions broaden and which fingerprints move, as § Check IDs requires.Type
Authorship
No person has read this diff before merge; it merges only if the independent review cycles and merge gates recorded in the PR comments pass.
Verification
CI is authoritative for
python -m ruff check .,python -m compileall -q src tests, andpython -m pytest.Additional local checks run (Python 3.14,
PYTHONPATH=src:.):python -m ruff check .: All checks passed.python -m compileall -q src tests: ok.python scripts/generate_schemas.py --check: exit 0. It regenerateddocs/determinism-boundary.{md,json}anddocs/checks.json, and those changes are committed.python scripts/regenerate_goldens.py --check: Checked 24 sample artifacts; 0 changed. No sample verdict moves.python scripts/verify_dependency_lock.py: OK.python scripts/build-llms-full.py: regeneratedllms-full.txt, becausedocs/checks.mdis one of its sources.origin/main'ssrc/from a scratch copy. 15 of the 17 new cases failed there: the four n8n "every tool" exports, theincludeprecedence case, the four per-key passthrough cases, the two credential-header cases, the bearer redaction case, the two coverage parity tests, and the reported workspace (which waspassedon main). The 2 that passed pin behaviour this change keeps:include: selectednaming its tools, and non-secret names that are not counted.tests/test_codex_boundary_check.py::test_codex_check_boundary_json_golden_outputs, a known pre-existing local-only failure on 3.14../shipgate verify --workspace . --config shipgate.yaml --base $(git merge-base origin/main HEAD) --head HEAD --json:control.stateiscomplete, every permission istrue,release_decision.decisionispassed.pytest -m "not perf and not slow", run serially): started in the background on this head. It takes about 3 hours serially here, so CI's six shards are authoritative, and the local result will be added as a comment if it finishes in this session.Release-readiness notes
docs/checks.md(no IDs added or renamed; the changed firing conditions of two are documented)STABILITY.mdDecisions taken
.github/agents/, Claude managed-settings, Cursor permissions.json, Copilot / Gemini CLI / Windsurf MCP configs #663. So.cursor/mcp.jsonand.vscode/mcp.jsonare named, not parsed. They are exactly the host MCP config paths incore/boundary_registry.BOUNDARY_ADAPTERSthat this reader does not read, and a parity test fails if the registry gains one that is in neither tuple. There is one warning per file, so each file is a named evidence gap, and the decision is at leastreview_required.host_grants._mcp_env_var_namesas is. That function is a publication filter. It drops a name that its digest redaction rewrites, for exampleAPI_KEYafterTOKENin oneenv_varslist publishes onlyTOKEN. For detection that would fail open. The reader covers the same key set as Show concrete field changes so an unfamiliar reviewer can act on the first advisory result #795 and uses detection semantics instead: a name counts when it is env-var-shaped and either secret-like or carried by a credential-named header.redact_textruns on each name before it is published.bearer_token_env_varalways counts. It holds a bearer token whatever the variable is called.headersis read only for${NAME}references, and Codex'shttp_headersis not read. Codex documentshttp_headersas static values, so a reference there is not expanded. A literal secret in it is a hardcoded secret, not forwarding, and is out of scope.include: selectedwith noincludeToolsis read asall. UpstreamgetSelectedToolsreturns every tool in that case (if (!includeTools?.length) return tools;).includebut a legacy tool list (tools,toolName, …) is still read asselected. This keeps the aliases working, for example for the communityn8n-nodes-mcpnode. A real first-party export cannot carryincludeToolswithoutinclude: selected, because n8n drops hidden parameters on export.excluded_toolsis added to the finding evidence only when non-empty. An existingall_exceptfinding that excludes nothing keeps its fingerprint._selection_mode(..., client=True)is the only path that readsinclude, treatsexceptas all-except, reads a missing selection asall, and reads an emptyselectedasall. The trigger reads only the earlier spellings, as on main (review cycle 1, C1-1).${env:NAME}is read asNAME,${NAME:-default}is still read asNAME, and any other${prefix:…}(VS Code's${input:id}) names no variable (review cycle 1, C1-2).Protected paths touched
none
Follow-up candidates
src/agents_shipgate/inputs/n8n/_common.py:87:_top_level_string(value, keys: set[str])takes the first present key insetiteration order, which follows the per-process hash seed. When a node carries more than one of the keys, the value it picks can differ from run to run. Affected callers include{"url", "path", "endpoint"}in_auth_risk.py:168and_common.py:284, the workflow-target keys in_tools.py:323, and{"authentication", "authType", "authorization"}in_workflows.py:435. This PR removes that dependence only from the MCP selection mode..cursor/mcp.jsonand.vscode/mcp.jsonincodex_config, instead of naming them, is Read the allowlist files hosts shipped in 2026: GitHub managed-settings MCP allowlist,.github/agents/, Claude managed-settings, Cursor permissions.json, Copilot / Gemini CLI / Windsurf MCP configs #663.