Conversation
…tories Declared subagents lost every MCP tool when the parent's MCP servers came from workspace tools.json: the factory captured b.toolsConfigOverride (null in that case) instead of the resolved config. Resolve the effective ToolsConfig before the factories are built, keep backwards-compatible overloads, and make an empty allow-list a true MCP opt-out with a WARN.
…lsConfig propagation Adds the positive case the fix depends on: with the parent's MCP servers defined in workspace tools.json, resolveEffectiveToolsConfig must surface them and hand them to the declared child; plus builder-override precedence and the no-source null case.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Raises the agentscope-ai#3178 patch to full coverage: exercises the null allow-list opt-out, parents without MCP servers, the 3-arg compat builders, and the dynamic-subagent entry that builds a declared child from the effective config.
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Fixes #3178 by resolving the parent ToolsConfig (builder override, else workspace tools.json) before the subagent factories are built, instead of capturing b.toolsConfigOverride — which was null for a tools.json-configured parent, so every declared child lost all MCP tools. The root-cause analysis is right and the new unit tests are genuinely good (they pin both the resolution order and the allow-list filtering). My concern is that the change reaches past the reported bug in three ways: it bypasses disableToolsConfig, it changes what an absent tools: list means for existing workspaces, and it now always overrides a child's own tools config.
Findings
- [Critical]
HarnessAgentBuilderSupport.java:379-387—resolveEffectiveToolsConfigmirrorsHarnessAgent.build()'s precedence but drops the!disableToolsConfiggate (HarnessAgent.java:2761), sodisableToolsConfig()no longer keeps a workspacetools.json(and its${ENV}-substituted MCP credentials) away from subagents. - [Warning]
:330-341—SubagentDeclaration.getTools()normalises an absenttools:key toList.of(), so the new "true opt-out" branch also catches every declaration that simply never mentioned tools; those children also lose the parent'sallow/strictAllow/defaultToolsEnabled, which widens rather than narrows their tool surface, and theSubagentDeclarationjavadoc ("Empty means inherit all parent tools") now contradicts the code. - [Warning]
:343— both branches return a non-null config, sosub.toolsConfig(childTools)always fires and replaces anISOLATEDchild's owntools.json. - [Warning]
:906-911—tools.jsonis read twice per build (here and atHarnessAgent.java:2761); the reads can disagree if the file changes in between. Hoisting the resolution above the subagent block and passing the value in avoids both the duplicate workspace read and the divergence. - [Info]
:361—denyis shared by reference with the parent whilemcpServersis copied.
Verdict
Posting as COMMENT rather than REQUEST_CHANGES because the direction is sound and the fixes are small. Please address the disableToolsConfig gate before merge — it is a privilege-boundary regression rather than a style issue — and decide explicitly whether the absent-tools: case should change behaviour; if it should, a SubagentDeclaration javadoc update plus a release note would keep the upgrade from being silent. Happy to re-review on the next push. CLA is signed, CI is green, mergeable=true; merging stays with the maintainers.
Automated review by github-manager-bot
| static io.agentscope.harness.agent.tools.ToolsConfig resolveEffectiveToolsConfig( | ||
| HarnessAgent.Builder b, WorkspaceManager wsManager) { | ||
| if (b.toolsConfigOverride != null) { | ||
| return b.toolsConfigOverride; | ||
| } | ||
| if (wsManager == null) { | ||
| return null; | ||
| } | ||
| return io.agentscope.harness.agent.tools.ToolsConfigLoader.load(wsManager).orElse(null); |
There was a problem hiding this comment.
[Critical] This helper does not mirror HarnessAgent.build() completely: the build path gates the read with if (!disableToolsConfig) (HarnessAgent.java:2761), but here the workspace tools.json is loaded unconditionally whenever toolsConfigOverride == null.
So HarnessAgent.builder().disableToolsConfig() on a workspace that still contains a tools.json now has the opposite effect for children: the parent keeps a clean default toolkit, while resolveEffectiveToolsConfig hands the file's mcpServers to every subagent factory (buildGeneralPurposeFactory line 483, buildDeclaredFactory line 654) and the child then registers those MCP servers itself. Since ToolsConfigLoader performs ${ENV_VAR} substitution, that means servers with credential-bearing headers get attached to a subagent on a path where the caller explicitly asked for no tools config at all.
Suggested fix — read the same flag (it is package-private on Builder, so this class can see it):
static io.agentscope.harness.agent.tools.ToolsConfig resolveEffectiveToolsConfig(
HarnessAgent.Builder b, WorkspaceManager wsManager) {
if (b.disableToolsConfig) {
return null;
}
if (b.toolsConfigOverride != null) {
return b.toolsConfigOverride;
}
...
}and please add a regression test (disableToolsConfig + a tools.json on disk must yield a null effective config) — none of the current tests cover that flag.
| if (allow == null || allow.isEmpty()) { | ||
| var optOut = new io.agentscope.harness.agent.tools.ToolsConfig(); | ||
| optOut.setMcpServers(Map.of()); | ||
| if (parent != null) { | ||
| optOut.setDeny(parent.getDeny()); | ||
| if (parent.getMcpServers() != null && !parent.getMcpServers().isEmpty()) { | ||
| log.warn( | ||
| "Subagent has an empty tools allow-list: parent MCP servers {} are no" | ||
| + " longer inherited. List the required MCP tool names in the" | ||
| + " declaration to restore them (#3178).", | ||
| parent.getMcpServers().keySet()); | ||
| } |
There was a problem hiding this comment.
[Warning] The blast radius of this branch is much larger than the case described in #3178, because it is not only an explicit empty list that lands here.
SubagentDeclaration.getTools() normalises an absent tools: key to List.of() (SubagentDeclaration.java:146, this.tools = b.tools != null ? List.copyOf(b.tools) : List.of()), so every declaration that does not mention tools: at all takes this path. Before this change those children got the parent's config object verbatim (return parent), i.e. they inherited the parent's allow, strictAllow and defaultToolsEnabled; after it they get a brand-new config that copies only deny and leaves allow == null with defaultToolsEnabled == true (field default). Two silent behaviour changes follow:
- existing workspaces whose subagents relied on inherited MCP servers lose them on upgrade, with only a
WARNline as the signal — and that is exactly the failure mode [Bug]: Declared/general-purpose subagents never inherit MCP tools (inherited toolkit captured from b.toolkit before MCP registration) #3178 reported, now applied to the inherit-all case; - a parent restricted by an
allowlist no longer restricts its children:childToolsConfigreturns an unfiltered config (allow == null,defaultToolsEnabled == true,strictAllow == false), which widens the child's tool surface instead of narrowing it.
The Javadoc on the declaration side still says the opposite (SubagentDeclaration.java:315-318: "Empty means inherit all parent tools"), so the model docs and this method now disagree.
Would you consider one of:
- keeping inheritance for the absent case and only opting out when the author wrote
tools: []explicitly — that needs an "absent vs. empty" distinction onSubagentDeclaration(e.g. keepList<String> toolsnullable, or add atoolsExplicitlyEmptyflag) sincegetTools()cannot tell them apart today; - or, if the opt-out is really intended for both, copying
parent.getAllow()/isStrictAllow()/isDefaultToolsEnabled()intooptOutso only MCP is dropped, updating theSubagentDeclarationJavadoc, and calling the change out in the release notes so users migrating don't lose tools silently.
| parent.getMcpServers().keySet()); | ||
| } | ||
| } | ||
| return optOut; |
There was a problem hiding this comment.
[Warning] Both branches of childToolsConfig now return a non-null object (return optOut here, return child below), so the call site if (childTools != null) sub.toolsConfig(childTools) at line 654 fires unconditionally and the declared child always ends up with an explicit override.
Before this PR a declared child with no allow-list and a parent whose config came only from tools.json received null, which let the child's own HarnessAgent.build() resolve tools.json from the child's own workspace. For WorkspaceMode.ISOLATED subagents that ship their own tools.json, this PR now replaces that file with the parent-derived config, so the child's own MCP servers / allow-deny lists are ignored with no warning.
Two options: keep returning null when there is nothing to propagate (empty allow-list and no parent config to derive from), or skip sub.toolsConfig(...) for WorkspaceMode.ISOLATED children that resolve their own workspace.
| WorkspaceManager wsManager, | ||
| Path workspace, | ||
| SandboxBackedFilesystem sandboxFs) { | ||
| List<SubagentEntry> entries = buildSubagentEntries(b, workspace, sandboxFs); | ||
| io.agentscope.harness.agent.tools.ToolsConfig effectiveToolsConfig = | ||
| resolveEffectiveToolsConfig(b, wsManager); | ||
| List<SubagentEntry> entries = |
There was a problem hiding this comment.
[Warning] tools.json is now read twice per agent build: once from resolveEffectiveToolsConfig here (and again in buildDynamicSubagentsMiddleware, line 930) and once in HarnessAgent.build() at HarnessAgent.java:2761. ToolsConfigLoader.load does a filesystem read through WorkspaceManager (which may be a sandbox or remote filesystem — the loader's own comment says it can be unreachable at build time) plus env substitution and a Jackson parse.
The bigger issue is consistency, not cost: if the file changes between the two reads (console edit, skill_manage writing the workspace), the parent and its children end up with different effective configs and nothing reports the divergence.
Since buildSubagentsMiddleware / buildDynamicSubagentsMiddleware are called from build() at lines 2638/2653, before the tools.json block at 2761, the cheap fix is to hoist the resolvedToolsConfig computation above the subagent block and pass it in as a parameter instead of resolving it a second time inside the helper.
| } | ||
| child.setAllow(kept); | ||
| if (parent != null) { | ||
| child.setDeny(parent.getDeny()); |
There was a problem hiding this comment.
[Info] Minor aliasing asymmetry: setMcpServers defensively copies into a new LinkedHashMap<> (ToolsConfig.java:96-98), but setDeny stores the reference, so parent and child share one mutable List in both branches here. Nothing mutates ToolsConfig today so it is not a live bug, but a List.copyOf(parent.getDeny()) (or a comment stating the config is treated as immutable) would keep the two fields consistent.
…mpty subagent tools Addresses the agentscope-ai#3191 review: resolveEffectiveToolsConfig now mirrors HarnessAgent.build()'s !disableToolsConfig gate; SubagentDeclaration.isToolsDeclared() separates an absent tools key (inherit the parent config unchanged) from an explicitly empty list (true opt-out with a WARN), so declarations that never listed tools keep their behaviour and an ISOLATED child whose parent has no config can still load its own tools.json; allowlistedInheritedToolkit takes the same flag so both layers agree; deny is copied rather than shared; tools.json is resolved once in build() and threaded into both subagent middleware builders.
|
@oss-maintainer Thanks for the review. Pushed follow-up commits
Finding 3 remains partially unresolved. When A complete fix needs an explicit precedence contract for parent configuration, child configuration, and declaration-level tool restrictions. In particular, child configuration must not accidentally undo declaration filters, and the current boolean fields do not distinguish omitted values from explicitly supplied defaults. I have therefore not added automatic configuration merging in these commits. Could you advise whether this remaining case must be handled in this PR, or whether a separately scoped configuration-precedence change is acceptable? I am leaving Finding 3 open pending that decision. Local validation on Java 17 / Windows:
Please re-review the updated commits, including the remaining scope decision for Finding 3. |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review of e947bfee..356efd69 (two new commits). Both blockers from the previous round are fixed, and fixed the way they needed to be — this is no longer a privilege-boundary risk. Posting as COMMENT rather than APPROVE only because CI has not finished on the new head (build (ubuntu-latest) and build (windows-latest) were still in_progress when I looked) and there are two semantics points left below; neither needs a redesign.
What the increment resolved:
resolveEffectiveToolsConfignow returnsnullwhendisableToolsConfig()is set, soHarnessAgent.build()resolvestools.jsononce, above the subagent block, and the same value flows to the main toolkit and every child factory — that removes the previous Critical (workspacetools.jsonand its${ENV}credentials reaching children past the opt-out), the double read, and the possible mid-build divergence between the two reads.disableToolsConfigKeepsWorkspaceToolsJsonAwayFromSubagentsplus theloader.verify(times(disabled ? 0 : 1))assertion inspawnedSharedChildHonoursParentToolsConfigSwitchpin both halves.- The absent-vs-explicitly-empty ambiguity is now a real distinction via
SubagentDeclaration.isToolsDeclared(), and the default branch (return parent) restores the pre-#3178 behaviour for declarations that never mentiontools:— which was the blast-radius concern. The javadoc contradiction is gone,denyis copied defensively, and the new spawned-child tests go through the actual builder path rather than onlychildToolsConfig.
Findings
- [Warning]
SubagentDeclaration.java:327— the new distinction is not reachable fromagents.md/ spec files:AgentSpecLoader.java:337mapstools.isEmpty() → null, so an explicittools: []is still indistinguishable from an absent key and inherits everything. The javadoc promises the opposite for "key is present but empty". - [Warning]
HarnessAgentBuilderSupport.java:679— carry-over: a non-null parent config still shadows anISOLATEDchild's owntools.json, silently. The newWorkspaceMode.SHAREDgate in theelse ifshows the right instinct; worth deciding theISOLATEDprecedence explicitly (code or docs + release note). - [Info]
HarnessAgentBuilderSupport.java:337—parentis returned by reference and shared by the parent toolkit and every non-declaring child;copyListis right there if you would rather hand out a copy.
Verdict
The fix is correct and the test work is thorough — the two spawnedSharedChild* parameterized tests exercise real build() paths with a mocked static loader instead of asserting on helper return values, which is what this area needed. CLA is signed, mergeable=true, no conflicts. My remaining ask is the AgentSpecLoader gap: it is the difference between "the opt-out exists" and "a user can actually use it". @Dedre2001 — if you would rather keep tools: [] as a builder-only capability, saying so in the javadoc is enough for me. Merging stays with the maintainers; @mention me after the next push or when CI closes and I will re-check.
Automated review by github-manager-bot
| * remain on the subagent's inherited toolkit. | ||
| * | ||
| * <p>When the key is <em>absent</em> ({@link #isToolsDeclared()} is {@code false}) all parent | ||
| * tools are inherited. When the key is present but empty the subagent inherits no tools — |
There was a problem hiding this comment.
[Warning] This is the one semantics change in the PR that file-based declarations cannot express.
The absent-vs-explicitly-empty distinction is introduced on the builder field (this.toolsDeclared = b.tools != null), but the path that actually builds declarations from agents.md / agent-spec files collapses the two before they get here: AgentSpecLoader.java:337 does .tools(tools.isEmpty() ? null : tools), and parseToolNames returns List.of() for both a missing key and tools: []. So a spec file that says tools: [] arrives as null → toolsDeclared=false → inherits every parent tool, i.e. exactly the behaviour the author was trying to opt out of, and the opposite of the sentence above.
The consequence is that the new opt-out only works for programmatic SubagentDeclaration.builder().tools(List.of()) callers, while the javadoc promises it for "the key is present but empty" — which is precisely the file case that cannot be detected. A user reading this javadoc, writing tools: [], and getting the parent's MCP servers anyway has no signal to explain why.
Suggested fix: preserve presence in the loader rather than folding it away —
List<String> tools = parseToolNames(asString(fm.get("tools")));
boolean toolsPresent = fm.containsKey("tools") || fm.containsKey("tool_names");
...
.tools(toolsPresent ? tools : null) // tools may be empty => explicit opt-outand ideally add one AgentSpecLoader-driven test (front matter with tools: [] vs no tools key) so the distinction is pinned from the user-facing side, not only from the builder.
|
|
||
| var childTools = childToolsConfig(capturedToolsConfig, decl.getTools()); | ||
| var childTools = | ||
| childToolsConfig(capturedToolsConfig, decl.getTools(), decl.isToolsDeclared()); |
There was a problem hiding this comment.
[Warning] Carry-over from last round: with a non-null parent config, an ISOLATED child still never reads its own tools.json.
childToolsConfig(capturedToolsConfig, decl.getTools(), decl.isToolsDeclared()) returns the parent object unchanged whenever the declaration does not list tools, so childTools != null and sub.toolsConfig(childTools) fires. The child's build path then prefers toolsConfigOverride over ToolsConfigLoader.load(wsManager), which means an ISOLATED child that ships its own agents/<name>/workspace/tools.json gets the parent's servers, allow, deny, strictAllow and defaultToolsEnabled instead — silently, and without the WARN that the empty-allow-list branch emits.
The new else if (capturedDisableToolsConfig && SHARED) branch shows the right instinct (workspace mode matters), and disabledParentStillAllowsIsolatedChildToLoadItsOwnConfig covers the disabled-parent case only. Worth deciding here too: either skip propagation for WorkspaceMode.ISOLATED when the declaration did not list tools (let the child's own workspace win), or keep the precedence and say so in the SubagentDeclaration javadoc plus a release note, because "the parent's tools.json overrides the isolated child's" is a visible behaviour change for every existing isolated declaration whose parent has a tools.json — which after this PR is every tools.json-configured parent.
| List<String> allow, | ||
| boolean allowDeclared) { | ||
| if (!allowDeclared) { | ||
| return parent; |
There was a problem hiding this comment.
[Info] Aliasing nit on the new branch: parent is returned by reference, so one mutable ToolsConfig instance is now shared by the parent and every non-declaring child, and childToolsConfig(null, …, false) returns null — see the test's assertNull(... "so the child can load its own workspace tools.json"), which is the behaviour that makes the previous comment matter.
That is defensible for a read-once config, but ToolsConfig is a mutable bean (setAllow / setDeny / setMcpServers / setDefaultToolsEnabled / setStrictAllow) and it is deliberately mutable elsewhere in this very method (child.setStrictAllow(true), optOut.setMcpServers(Map.of())). Since resolveEffectiveToolsConfig is now hoisted and the same object is handed to the main toolkit and to N child factories, a single future child-side mutation would change what every sibling and the parent register.
If sharing is intended, a copyOf(parent)-style helper here (the copyList helper is already in the file, and setMcpServers already copies the map) would cost nothing and keep the semantics identical, at which point assertNull(child == parent) in the test can stay as an equality check instead of an identity check.
|
Follow-up on the review above: What still holds me at
If 1 is deliberate, say so and I will approve on the next pass. Merging stays with the maintainers. Automated review by github-manager-bot |
|
@oss-maintainer Pushed
Javadoc source: SubagentDeclaration.getTools(). Validation on Java 17 / Windows, using an ASCII checkout and a forked test JVM with a fresh
The two remaining asks are addressed through the loader fix and the explicit precedence documentation, respectively. Please let me know if anything else is needed for approval; merging remains with the maintainers. |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-reviewed e947bfee..c82dc5f6; the two semantics items from the previous round are both closed, and this is now a coherent fix for #3178. What remains is one aliasing hazard on the inherit path and one consistency note, neither of which is a redesign.
Closed since last round
AgentSpecLoader.java:337maps the front-matter key itself (fm.containsKey("tools")) instead oftools.isEmpty(), sotools: []in anagents.mdreally is the opt-out the javadoc advertises, andfileDeclarationPreservesAbsentVersusEmptyToolspins both the absent and empty arms againstchildToolsConfig— exactly the regression guard the last round asked for.SubagentDeclaration#getTools()now states the precedence rule out loud (a non-null parent config overrides the child's owntools.json, including underISOLATED, no merge). Choosing documentation over code here is defensible, and it is the reading theSHAREDgate in the builder already implements.
Open
- [Warning]
HarnessAgentBuilderSupport.java:337— the inherit branch returns the parent instance itself; every other branch in the method defensively copies. See the inline comment. - [Info]
AgentSpecLoader.java:338—skillsstill conflates absent and explicitly-empty, one line below the fix that removed that conflation fortools.
Not verified locally: no build or test run on my side; build (ubuntu-latest) and build (windows-latest) were still queued at review time, and Check License / Check Module Sync are green. Also license/cla reports no status on this head (it does on neighbouring PRs), so I am leaving this as COMMENT rather than APPROVE — worth confirming the CLA bot picked up 代志杰 before merge.
Automated review by github-manager-bot
| List<String> allow, | ||
| boolean allowDeclared) { | ||
| if (!allowDeclared) { | ||
| return parent; |
There was a problem hiding this comment.
[Warning] The non-declaring branch hands the parent's ToolsConfig out by reference, so parent and every non-declaring child share one mutable instance.
allowDeclared == false returns parent itself, and the result is then passed to each child builder via sub.toolsConfig(childTools). buildStaticSubagentEntries builds the factories once and each spawned child receives the same object, so any future per-child mutation — a setAllow/setDeny/setMcpServers on the child's resolved config, or a skill-bound tool group registering into getMcpServers() (which returns the live map, ToolsConfig.java:92) — writes through to the parent's toolkit and to every sibling.
The rest of the method is careful about exactly this (copyList(parent.getDeny()) on both other branches, and setMcpServers copies the map on write), so this one line is the outlier. Cheap to close: return a shallow copy on the inherit branch (the same field-by-field copy the else paths already build), and it also makes the assertNotNull(child) in the new fileDeclarationPreservesAbsentVersusEmptyTools test able to detect aliasing if you add assertNotSame(child, parentToolsConfig).
| .enablePendingToolRecovery(enablePendingToolRecovery) | ||
| .tools(tools.isEmpty() ? null : tools) | ||
| .tools(fm.containsKey("tools") ? tools : null) | ||
| .skills(skills.isEmpty() ? null : skills); |
There was a problem hiding this comment.
[Info] tools now distinguishes absent from explicitly-empty, but skills two lines below still collapses them.
.skills(skills.isEmpty() ? null : skills) is the exact shape that made tools: [] unreachable from a spec file, which c82dc5f just fixed one line above. So today tools: [] means "no inherited tools" while skills: [] still means "inherit everything" — two opposite readings of the same YAML idiom in the same builder chain, and the new javadoc in SubagentDeclaration#getTools() promises readers the opposite.
Not something to widen this PR for: worth either the same fm.containsKey("skills") treatment here (it is a one-token change), or a follow-up issue so the asymmetry is deliberate and recorded rather than accidental.
|
@oss-maintainer Pushed The undeclared-tools branch now returns a separate Regression tests now verify value preservation, distinct parent/child and sibling configurations, isolation after parent-list and child-config/map mutations, both boolean flag settings, and preservation of null fields/defaults. The file-based declaration test also checks that the child does not receive the parent config object itself. Validation: 86 tests passed, 0 failures/errors/skips ( I have left the informational Please let me know if anything remains for approval. Merging stays with the maintainers. |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-reviewed c82dc5f6..7f0c3572. That closes the last open item, and with license/cla now reported success on this head and every check green, this is approved from my side.
childToolsConfig no longer hands the parent instance to non-declaring children: the inherit branch builds a separate ToolsConfig, copies allow/deny through copyList, relies on setMcpServers' own defensive map copy, carries both boolean flags across, and still returns null for a null parent so a child with no parent configuration loads its own workspace config. That is the shallow-copy semantics the other two branches already had, so the method is now internally consistent — no branch leaks a shared mutable object into a child.
The test work is what makes this more than a one-line change: assertNotSame on parent/child and across siblings, mutation of parent lists and of the child's config and server map proving neither direction leaks, both boolean flags in both states, and null fields/defaults preserved. fileDeclarationPreservesAbsentVersusEmptyTools also now checks the child did not receive the parent object itself — which is the assertion that would have caught the original aliasing.
On skills: leaving the absent-versus-explicitly-empty asymmetry out of this PR is the right call, and thanks for saying so rather than silently expanding the scope. It is a real inconsistency (in the same three lines of AgentSpecLoader, tools: [] is now an opt-out while skills: [] still means "inherit everything"), so it deserves its own issue rather than disappearing — thanks also for confirming #3178's own behaviour is unaffected either way.
Not verified locally: no build or test run on my side, so the "86 tests / 8/8 changed lines" figures quoted in the thread are the author's local JaCoCo numbers and I am taking them as reported. What I am confirming independently is GitHub's own state on 7f0c3572: Check License, Check Module Sync, build (ubuntu-latest), build (windows-latest), codecov/patch and license/cla all green, mergeable=true. Merging stays with the maintainers.
Automated review by github-manager-bot
Summary
Fixes #3178 — a declared subagent lost every MCP tool when the parent's MCP servers came from the workspace
tools.jsonand the declaration carried a non-emptytoolsallow-list.Root cause
HarnessAgentBuilderSupportcaptured the parent config asb.toolsConfigOverrideat factory-construction time, whileHarnessAgent.build()resolves the effectiveToolsConfiglater (override, elseToolsConfigLoader.load(...)). For atools.json-configured parent the captured value was thereforenull:childToolsConfig(null, allow)skippedchild.setMcpServers(parent.getMcpServers()), andstrictAllow=true,defaultToolsEnabled=false) also suppressed the child's owntools.jsonload.Net effect: no MCP tools in the child even though the allow-list named them.
Changes
resolveEffectiveToolsConfig(builder, wsManager)— builder override, else workspacetools.jsonthrough the same loader, resolved before the subagent factories are built.buildSubagentEntries,buildStaticSubagentEntries), both factory builders (buildGeneralPurposeFactory,buildDeclaredFactory) and the two middleware builders. Every changed signature keeps a backwards-compatible overload that falls back tob.toolsConfigOverride, so existing callers and tests compile and behave exactly as before.childToolsConfig:Deliberate deviation from the agreed spec (item 2)
Spec v2 phrased the non-empty branch as "copy only parent MCP servers named in
allow". I kept the wholesale copy: themcpServersmap is keyed by server name, not tool name, so filtering it by a tool allow-list would drop every server and reintroduce the bug. The strict allow-list already restricts which tools survive registration.Tests
New
SubagentToolsConfigPropagationTest(4 cases, no MCP server needed) + existingManagedSubagentBoundaryTest(unchanged):nullparent ⇒ tolerated in both casesmvn -pl agentscope-harness spotless:applywas run, so the format check passes.Note: this repository has no per-PR changelog file (recent merges are conventional-commit titles plus tests), so no changelog entry is included. The migration note lives in the helper javadoc: an empty allow-list is now a true MCP opt-out — list the required MCP tool names to restore inheritance.
Out of scope
Reusing the parent's MCP client/connections is deliberately not part of this PR (agreed in the issue thread); it needs its own lifecycle contract.