Per-operation tool filtering for read-only deployments (DISABLED_TOOL_OPERATIONS)
Background
Several users running n8n-mcp in enterprise / governance-sensitive contexts want a true read-only mode but are blocked by tools that bundle read and write operations under a single tool name. The existing DISABLED_TOOLS env var only operates at the tool level, so disabling a destructive branch means losing the read branch as well.
Discussed in #564 (by @mahmoudnaif, with a strong governance writeup from @christiansousadev).
The problem
Two tools mix read and destructive operations under one name:
-
n8n_workflow_versions- Read:
list,get - Write/destructive:
rollback,delete,prune,truncate
- Read:
-
n8n_executions- Read:
list,get - Destructive:
delete
- Read:
Today the only way to block the destructive branches via n8n-mcp is to disable the entire tool, which also kills the valuable read paths. Tool annotations (destructiveHint: true) exist but are metadata only — nothing in the dispatch layer enforces them.
The robust workaround today is n8n-side API key scoping (RBAC), which is the right pattern for production but does not give clients a way to express the same intent at the MCP layer.
Proposed feature
Add an env var that filters operations within a tool at dispatch time, before the handler runs:
DISABLED_TOOL_OPERATIONS=n8n_workflow_versions:delete,rollback,prune,truncate;n8n_executions:delete
Format sketch (open to bikeshedding):
- Semicolon-separated list of
<tool_name>:<comma_separated_operations> - Operation names match the existing
action/modeenum values in each tools schema - Disabled operations should also be advertised as such in
tools_documentationoutput so the LLM does not waste a turn trying them
Acceptance criteria
- [ ] New env var parsed at startup with the same safety limits as
DISABLED_TOOLS(size cap, count cap, validation against known tool/operation names) - [ ] Dispatch layer in
src/mcp/server.tschecks the disabled-operation set before calling the handler and returns a clear error (something likeOperation 'delete' on tool 'n8n_executions' is disabled by server policy) - [ ]
tools_documentationreflects the disabled operations so they are visible to clients - [ ] Unit tests covering: parser edge cases, dispatch enforcement for both
n8n_workflow_versionsandn8n_executions, interaction with existingDISABLED_TOOLS - [ ] README /
docs/section explaining the read-only deployment recipe (this env var + n8n API key RBAC)
Out of scope
- Full RBAC inside n8n-mcp — that lives in the n8n API
- Per-user / per-session policy — this is a server-level config knob
Notes
- The implementation is straightforward: a Set lookup in the dispatch path. Cheap at runtime.
- Happy to review a PR. Tagging @christiansousadev and @mahmoudnaif from the discussion in case either of you wants to take a swing at it.
Concieved by Romuald Członkowski — https://www.aiadvisors.pl/en
🎯 Agentic Issue Triage
This is a well-scoped feature request for sub-tool-level operation filtering via a DISABLED_TOOL_OPERATIONS environment variable. The motivation is clear: two MCP tools (n8n_workflow_versions, n8n_executions) bundle read and destructive operations under a single name, and the existing DISABLED_TOOLS mechanism only operates at the whole-tool level, forcing an all-or-nothing choice.
The implementation path is straightforward — the existing getDisabledTools() pattern in src/mcp/server.ts is the direct template for the new feature.
📍 Relevant code locations
| File | Relevance |
|---|---|
src/mcp/server.ts |
getDisabledTools() / disabledToolsCache — model to follow; dispatch check at callTool handler |
src/mcp/tools.ts |
Tool definitions listing action/mode enum values per tool — needed to build the allowlist for validation |
src/mcp/tools-documentation.ts |
tools_documentation output — needs to advertise disabled operations |
The two tools in scope:
n8n_workflow_versions— destructive ops:rollback,delete,prune,truncaten8n_executions— destructive op:delete
🔧 Implementation sketch
1. Parser (mirrors getDisabledTools()):
// src/mcp/server.ts
private disabledToolOperationsCache: Map<string, Set<string>> | null = null;
private getDisabledToolOperations(): Map<string, Set<string>> {
if (this.disabledToolOperationsCache !== null) return this.disabledToolOperationsCache;
const env = process.env.DISABLED_TOOL_OPERATIONS || '';
const map = new Map<string, Set<string>>();
if (!env) { this.disabledToolOperationsCache = map; return map; }
// guard: size cap
const raw = env.length > 10000 ? env.substring(0, 10000) : env;
for (const segment of raw.split(';').slice(0, 200)) {
const [tool, ops] = segment.split(':');
if (tool && ops) map.set(tool.trim(), new Set(ops.split(',').map(o => o.trim())));
}
this.disabledToolOperationsCache = map;
return map;
}
2. Dispatch check (slot in after existing DISABLED_TOOLS check, before handler call):
const disabledOps = this.getDisabledToolOperations().get(name);
if (disabledOps) {
const operation = args?.action ?? args?.mode;
if (operation && disabledOps.has(operation)) {
return { isError: true, content: [{ type: 'text',
text: `Operation '${operation}' on tool '${name}' is disabled by server policy.` }] };
}
}
3. tools_documentation: when building the documentation string for a tool, append a note listing disabled operations if the tool appears in the map. This can be a short suffix like (disabled operations: delete, rollback).
4. Validation at startup: cross-check parsed tool/operation names against the known schema to warn on typos.
🧪 Testing checklist
- [ ] Parser: empty env → empty map
- [ ] Parser: size/count cap enforcement (mirror existing
DISABLED_TOOLStests) - [ ] Parser: malformed segments (no
:, empty ops list) handled gracefully - [ ] Dispatch:
n8n_executionsdeleteblocked,listandgetstill pass through - [ ] Dispatch:
n8n_workflow_versionsrollback/delete/prune/truncateblocked,list/getpass - [ ] Dispatch: interaction with
DISABLED_TOOLS(tool-level disable takes precedence and tool is filtered before operation check) - [ ]
tools_documentationoutput includes disabled-ops annotation for affected tools
📚 Suggested docs / README section
A short "Read-only deployment recipe" section in README.md or docs/ explaining:
- Set
DISABLED_TOOL_OPERATIONS=n8n_workflow_versions:delete,rollback,prune,truncate;n8n_executions:deleteto block all destructive ops at the MCP layer. - Also scope the n8n API key to read-only permissions (RBAC) for defence in depth — the MCP-layer block is a UX guardrail, the API key is the enforcement point.
- Optionally combine with
DISABLED_TOOLSto remove write-only tools entirely (e.g.n8n_delete_workflow).
💡 Notes for reviewers
- The
args?.action ?? args?.modepattern should cover both affected tools; verify thatn8n_workflow_versionsusesactionandn8n_executionsusesaction(ormode) in their respective schemas insrc/mcp/tools.ts. - Consider whether
tools/listshould also omit the enum values for disabled operations from the JSON Schema returned to clients (not justtools_documentation). That would prevent schema-aware clients from offering those options. - The
disabledToolOperationsCacheshould be invalidated (set tonull) under the same conditions asdisabledToolsCache, if any hot-reload path exists.
Generated by Agentic Triage for issue #714 · ● 238.6K · ◷
To install this agentic workflow, run
gh aw add githubnext/agentics/workflows/issue-triage.md@13377ddf7e35c2b6e47aa58f45acb228fba902c8