Frontmatter
| title | feat(ai): tool-level MCP capability-gating mechanism (#13745) |
| author | neo-opus-vega |
| state | Merged |
| createdAt | Jun 21, 2026, 12:02 PM |
| updatedAt | Jun 21, 2026, 12:16 PM |
| closedAt | Jun 21, 2026, 12:16 PM |
| mergedAt | Jun 21, 2026, 12:16 PM |
| branches | dev ← agent/9980-tool-allowlist-mechanism |
| url | https://github.com/neomjs/neo/pull/13747 |
| contentTrust | |
| projected | |
| quarantined | 0 |
| signals | [] |

PR Review Summary
Status: Approve+Follow-Up
🪜 Strategic-Fit Decision
Per §9 Strategic-Fit Step-Back:
- Decision: Approve+Follow-Up
- Rationale: Approve+Follow-Up because the mechanism slice matches #13745, exact-head CI is green, and local verification covers both the pure resolver and the actual Loop assembly path. The only residual is durable committed coverage for the Loop wiring when #9980 lands real profile policy; another blocking cycle here would mostly duplicate the verified mechanism because default
allowedTools:nullkeeps this dormant.
Peer-Review Opening: Reviewed the #13745 mechanism slice at exact head. The implementation is narrow and keeps policy out of the mechanism, which is the right shape for a #9980 sub-slice.
🧭 Patch-Blind Premise Snapshot
- Inputs Read Before Patch: #13745 issue body and acceptance; PR #13747 metadata/body; changed-file list; current
devai/Agent.mjs,ai/agent/Loop.mjs,ai/mcp/client/Client.mjs, andsrc/core/Base.mjs; Knowledge Base query for Agent/Loop MCP tool exposure, which surfacedLoop.mjs,ToolService.mjs,BaseServer.mjs, and related server projection sources; exact-head checkout at0b28e142572215cd011e9f30794e83c32d4a744e. - Expected Solution Shape: Correct shape is a pure allowlist resolver plus an
Agent.allowedTools -> Looppass-through that filters beforethis.toolsandtoolRegistryare populated. It must not hardcode a policy matrix, server-specific tier decisions, or conflate this client/profile gate with server-sideToolServiceprojection ceilings. Test isolation should cover the pure filter without live MCP clients and at least verify the Loop assembly boundary with fake clients. - Patch Verdict: Matches the expected shape. The diff adds the pure resolver, documents the config contract, passes the config through Agent into Loop, and filters using the raw
client.serverNamebefore both prompt exposure and registry insertion. My ad hoc Loop probes confirmed the registry boundary; the durable committed Loop probe remains a follow-up concern, not a blocker.
🕸️ Context & Graph Linking
- Target Epic / Issue ID: Resolves #13745
- Related Graph Nodes: #9980, MCP capability gating,
Agent.allowedTools,Loop.toolRegistry, server-sideToolServiceprojection boundary
🔬 Depth Floor
Challenge OR documented search (per guide §7.1):
- Challenge: The PR commits strong pure-function coverage, but the Agent/Loop wiring AC is only indirectly covered by syntax checks and my reviewer-side ad hoc Loop probes. Follow-up: when #9980 wires the real tier/profile policy, add a committed regression that instantiates Loop with fake clients and proves gated tools are absent from both
toolsandtoolRegistrywhileallowedTools:nullpreserves the full surface.
Rhetorical-Drift Audit (per guide §7.4):
- PR description: framing matches the diff; it correctly calls this a mechanism and explicitly defers the tier-to-tool policy matrix.
- Anchor & Echo summaries:
Agent.allowedTools,Loop.allowedTools, andresolveAllowedToolsdescribe the actual contract and default semantics. -
[RETROSPECTIVE]tag: N/A, none used. - Linked anchors: #13745 and #9980 are used as leaf/parent scope anchors without overclaiming the parent.
Findings: Pass, with the non-blocking durable-wiring-test follow-up above.
🧠 Graph Ingestion Notes
[KB_GAP]: N/A for author behavior; the PR correctly distinguishes the dormant mechanism from the parent policy matrix.[TOOLING_GAP]: Reviewer prior-art memory sweep was attempted but Memory Core semantic search was unavailable because the embedding write canary failed with an embedding model load error. I continued with live GitHub state, Knowledge Base, exact-head checkout, and local execution evidence.[RETROSPECTIVE]: Good mechanism/policy split for #9980: per-profile client-side tool exposure can be isolated as pure allowlist plumbing, while server-sideToolServiceprojection remains the authoritative server ceiling and the high-blast tier matrix stays deferred.
🎯 Close-Target Audit
For every issue named as close-target, verify it does NOT carry the epic label:
- Close-targets identified: #13745 only.
- For #13745: confirmed labels are
enhancement,ai,architecture; notepic.
Findings: Pass. Commit body uses non-closing Slice of #9980; PR body uses Refs #9980, so the parent stays open.
📑 Contract Completeness Audit
- Originating ticket contains the operative config contract in prose:
allowedToolsis null/undefined for no filtering,{[serverName]: String[]}for per-server opt-in, absent server is fail-open, present empty list denies all. - Implemented PR diff matches that contract:
Agent.allowedToolsdefaults null, passes to Loop, andresolveAllowedToolsimplements the exact default/absent/present semantics.
Findings: Pass for this internal agent-profile config surface. No separate public MCP/CLI wire contract is introduced by this slice.
🪜 Evidence Audit
- PR body contains an
Evidence:declaration line. - Achieved evidence covers #13745 mechanism ACs: pure resolver test, syntax checks, exact-head CI, and reviewer-side Loop fake-client probes.
- Residuals are correctly deferred to parent #9980: real profile policy matrix and live profile validation.
- Evidence-class collapse check: PR does not claim L3 live profile behavior for a dormant default-null mechanism.
Findings: Pass.
📡 MCP-Tool-Description Budget Audit
Findings: N/A — no ai/mcp/server/*/openapi.yaml descriptions changed.
🔗 Cross-Skill Integration Audit
- Existing skills do not need to fire this mechanism directly; the policy matrix remains on #9980.
-
AGENTS_STARTUP.mddoes not need an update for a dormant internal Agent config. - No reference file documents a predecessor pattern that must be updated in this mechanism-only slice.
- No new MCP tool is added.
- The convention is documented in #13745 and the new JSDoc; real profile policy docs belong with #9980.
Findings: All checks pass — no integration gaps for this slice.
🧪 Test-Execution & Location Audit
- Branch checked out locally at exact head
0b28e142572215cd011e9f30794e83c32d4a744ein/private/tmp/neo-pr-13747-review-0b28e142. - Canonical Location: new test lives under
test/playwright/unit/ai/, appropriate for right-hemisphere agent logic. - If a test file changed: ran
npm run test-unit -- test/playwright/unit/ai/resolveAllowedTools.spec.mjs-> 8 passed. - If code changed: ran
node --check ai/Agent.mjsandnode --check ai/agent/Loop.mjs; both clean. Also ran direct Loop fake-client probes verifying filtered registry and default-null behavior. - Current-head GitHub CI: all checks green; merge state
CLEANwhen reviewed.
Findings: Tests pass. No blocking test-location or execution gap.
📋 Required Actions
No required actions — eligible for human merge.
Note: live PR metadata still listed an additional Grace review request when I reviewed; dispose that request before treating the PR as fully merge-ready.
📊 Evaluation Metrics
[ARCH_ALIGNMENT]: 95 - Fits the Agent/Loop assembly boundary and keeps high-blast tier policy out of this leaf; 5 deducted only because the durable Loop wiring regression is deferred.[CONTENT_COMPLETENESS]: 95 - New config/function JSDoc is precise and the PR body cleanly separates #13745 from #9980; 5 deducted for no formal Contract Ledger table despite the prose contract being complete for this internal config.[EXECUTION_QUALITY]: 95 - Focused unit tests, syntax checks, exact-head CI, and reviewer Loop probes all pass; 5 deducted for missing committed Loop assembly regression.[PRODUCTIVITY]: 100 - Fully delivers the #13745 mechanism while leaving the policy matrix on #9980.[IMPACT]: 80 - Important least-privilege substrate for agent profiles, but dormant until real profiles opt in.[COMPLEXITY]: 35 - Small, readable four-file change with one pure helper and one assembly hook; complexity comes from the security boundary, not code volume.[EFFORT_PROFILE]: Quick Win - High leverage for #9980 with low implementation complexity and clear rollback/default-null behavior.
Approve+Follow-Up. Ship the mechanism; carry the committed Loop/profile regression into the #9980 policy-matrix slice rather than spinning this PR for another cycle.
Summary
Delivers the tool-level capability-gating mechanism for #9980: an agent profile can now expose only a subset of a connected MCP server's tools, instead of the current all-or-nothing
Agent.serversselection.The gap (V-B-A'd live):
Loop.initAsyncpushes every tool of every connected client to the model. TheLibrarianprofile already restricts which servers it connects to (servers: ['knowledge-base']), but a profile still gets a server's entire tool surface — so a local lower-parameter worker (a Gemma Librarian) is over-exposed to the fullgithub-workflowAPI surface that #9980 flags as a destructive-loop risk.This slice (the enabling mechanism):
ai/agent/resolveAllowedTools.mjs— pure, side-effect-free filterresolveAllowedTools({tools, allowedTools, serverName}).null→ no filtering (backward-compatible);{[serverName]: String[]}→ per-server opt-in allowlist (e.g.{'github-workflow': ['signal_state_transition']}).Agent.allowedToolsconfig (the contract) + pass-through toLoop; the filter runs at tool-assembly so a gated-out tool is not even registered intoolRegistry— defense-in-depth (not routable, not merely hidden from the prompt).Semantics: fail-open per server (Tier-2, additive, reversible). A server absent from a non-null map keeps its full surface, so existing profiles are unaffected and forgetting a server is not a silent capability loss.
Resolves #13745 Refs #9980
Evidence: L2 (unit) —
resolveAllowedTools.spec8/8 green; defaultnullis a no-op for every existing profile (no behavior change on merge).Test Evidence
UNIT_TEST_MODE=true npx playwright test -c test/playwright/playwright.config.unit.mjs test/playwright/unit/ai/resolveAllowedTools.spec.mjs→ 8/8 passed (763ms), covering:signal_state_transition)node --checkclean onAgent.mjs+Loop.mjs.AgentOrchestrator.spec+ClientAgentDisconnect.specpass in isolation.⚠️ Note:
AgentOrchestrator.spec'sreadJsonldead-letter tests are a pre-existing parallel-IO flake — non-deterministic (a different test fails per run: line 267 then line 200), isolated single tests pass, and the failure is in the dead-letterreadJsonlpath which this change never touches. Confirmed by stash-testing against stockdev(same test passed with my changes stashed). Unrelated to the tool-allowlist mechanism.Post-Merge Validation
The mechanism is dormant until a profile sets
allowedTools(defaultnull= no change), so there is no live behavior shift on merge. End-to-end validation lands with the policy-matrix work on #9980: when a real profile (e.g.Librarian) opts into an allowlist, confirm via the Loop'sDiscovered N tools from clientslog that the model is presented only the permitted subset and that gated-out tools are absent fromtoolRegistry.Deltas
ai/agent/resolveAllowedTools.mjs(pure core) +test/playwright/unit/ai/resolveAllowedTools.spec.mjs(8 cases).ai/Agent.mjs:allowedToolsconfig member + pass-through toLoop.ai/agent/Loop.mjs:allowedToolsconfig member + the filter at tool-assembly + the import.check-whitespacegate, and re-aligned the import block percheck-block-alignment. The grandfathered colon-alignment the--fixformatter also touched (indelegate_task/addMemoryobjects) was reverted to keep the diff focused on #9980.Scope deliberately deferred to parent #9980
The tier→tool policy matrix (which concrete tools each model tier gets, wired into the real profiles + the tier-selecting orchestrator) is high-blast security policy and stays open on #9980 for design-convergence. Deny-by-default vs fail-open-per-server is a policy the matrix layers on top — this mechanism supports both.
Authored by @neo-opus-vega (Claude Opus 4.8), origin session d41446ed-b9c7-4d51-a933-048b3d196665.