Skip to content

feat: per-MCP-server tool_injection control (description_only mode) - #66826

Open
WeilaiSun wants to merge 9 commits into
NousResearch:mainfrom
WeilaiSun:feat/mcp-description-only-tool-injection
Open

feat: per-MCP-server tool_injection control (description_only mode)#66826
WeilaiSun wants to merge 9 commits into
NousResearch:mainfrom
WeilaiSun:feat/mcp-description-only-tool-injection

Conversation

@WeilaiSun

Copy link
Copy Markdown

Summary

Adds per-MCP-server tool_injection config option, supporting "full" (default, backwards-compatible) and "description_only" modes. Description-only tools are always deferred and discovered via tool_search, mirroring Claude Code's defer_loading pattern — the agent sees tool names + descriptions in the system prompt (stable tier, cached prefix) while full schemas are loaded on demand.

Closes #66736 · Inspired by #6839 (Lazy Tool Schema Loading) · Addresses Writer Harness paper critique (arXiv 2607.06906: Hermes tools not in cached prefix)

Motivation

Currently, all MCP tool schemas are loaded into every API call irrespective of frequency. For low-frequency MCP servers (e.g., firecrawl's 26 tools used 2-3 times per session), this wastes tokens on every turn. The tool_search mechanism already supports deferral, but only at a global threshold (>10% context) — there's no per-server control.

Real-world impact: one user moved 5 low-frequency MCP servers (64 tools) to mcporter CLI workarounds, saving ~60+ tool schemas from the tools array per turn. This feature makes that pattern native.

Design

Mirrors Claude Code's three-tier tool pool and Hermes's own Skill frontmatter discovery:

mcp_servers:
  firecrawl:
    command: npx
    args: [-y, firecrawl-mcp]
    tool_injection: description_only  # NEW: name+desc in prompt, schema on demand

Flow:

  1. System prompt stable tier ← tool names + one-line descriptions (cached prefix)
  2. Tools array ← bridge tools only (tool_search, tool_describe, tool_call)
  3. Agent calls tool_searchtool_describetool_call to use deferred tools
  4. Catalog stores full schemas → loaded from BM25 search on demand

Changes (3 files, +90/-1 lines)

  • tools/tool_search.py: mark_description_only_tool() registry + force-defer in assemble_tools() even under threshold
  • tools/mcp_tool.py: read tool_injection config, mark tools during registration
  • agent/system_prompt.py: inject MCP tool inventory into stable tier, mirroring Skill index block

Backwards Compatibility

  • tool_injection defaults to "full" — no behavior change for existing configs
  • All existing tests pass without modification
  • tool_search must be enabled (tools.tool_search) for description_only tools to be usable

Testing

  • Unit: mark_description_only_tool / is_description_only_tool round-trip
  • Unit: classify_tools correctly routes description_only tools to deferrable
  • Unit: assemble_tools forces bridge injection when description_only tools exist
  • Integration: description_only MCP server → tools in stable tier, not in tools array
  • Integration: tool_searchtool_describetool_call works end-to-end

References

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/mcp MCP client and OAuth P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation labels Jul 18, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #66736 and the broader #66481 smart-loading work. This is an independently scoped per-server description-only policy, so it is not marked duplicate; maintainer selection is needed for the MCP lazy-schema design.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused per-server proposal. The global-threshold premise is real on current main (model_tools.py:548-563), but this implementation needs scope and lifecycle work before it is safe to salvage.

Problems

  • agent/system_prompt.py enumerates the process-global marked set, not the agent's filtered toolset. This conflicts with the restricted-session isolation contract documented in tests/tools/test_tool_search.py:419-428.
  • The marking added in tools/mcp_tool.py covers only server._tools; resource/prompt utility schemas are registered separately at tools/mcp_tool.py:5088-5123 and remain eager.
  • Current model_tools.py:550 skips assembly when global tool search is off, while the new inventory still instructs the model to use tool_search.
  • The PR adds no tests despite changing MCP registration, tool assembly, and cached prompt construction.

Suggested changes

  • Derive the inventory from agent-scoped pre-assembly definitions, define utility-tool semantics, and add coverage for scoped sessions, tool-search-off, late MCP registration, and bridge dispatch.

Automated hermes-sweeper review.

Comment thread agent/system_prompt.py Outdated
if agent.valid_tool_names:
try:
from tools.tool_search import get_description_only_tool_names
_do_names = get_description_only_tool_names()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_description_only_tool_names() is process-global. This inventory is not filtered through the agent's enabled/disabled toolsets, so a restricted session can be told about description-only tools from another MCP server. Please derive the inventory from the same session-scoped pre-assembly definitions used by the bridge catalog.

Comment thread tools/mcp_tool.py Outdated
@@ -4799,6 +4811,13 @@ def _should_register(tool_name: str) -> bool:
_track_mcp_tool_server(tool_name_prefixed, name)
registered_names.append(tool_name_prefixed)

# Description-only tools: mark for always-deferred treatment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This marker is applied only in the server-native server._tools registration loop. The resource/prompt utility schemas registered below remain eager, so the advertised per-server mode does not apply to every tool from this server. Please either cover those registrations or narrow and document the policy.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 18, 2026
@WeilaiSun

Copy link
Copy Markdown
Author

Review feedback fixes (3 commits)

Addressed all 4 problems identified by @teknium1 in sweeper review:

P1: Session-scoped inventory ✅

  • agent_init.py: Capture _pre_assembly_tool_names from model_tools._last_resolved_tool_names
  • system_prompt.py: Filter get_description_only_tool_names() against session-scoped names instead of process-global set

P2: Utility tool marking ✅

  • mcp_tool.py L4855: Mark resource/prompt utility schemas as description_only when server has tool_injection: description_only

P3: tool_search=off gate ✅

  • system_prompt.py: Skip inventory block when ts_cfg.enabled == "off" — no misleading tool_search hints

P4: Test coverage ✅

  • TestDescriptionOnly class: 11 tests, all 5 scenarios covered:
    • mark/is round-trip + copy semantics
    • deferrable classification + classify_tools
    • bridge activation below threshold
    • tool_search=off gate
    • session-scoped inventory (restricted session isolation)
    • lazy MCP registration persistence
    • bridge dispatch compatibility
    • system prompt inventory verification (names+descriptions, no full schemas)

50/50 tests pass | Changes match suggested approach: agent-scoped definitions, utility-tool semantics, comprehensive coverage.

@WeilaiSun

Copy link
Copy Markdown
Author

Hey @teknium1, gentle ping — all 4 issues from the sweeper review have been addressed and pushed (see previous comment).

Summary of fixes:

  • P1: Session-scoped inventory derived from agent's filtered toolset
  • P2: Resource/prompt utility schemas now marked description_only
  • P3: tool_search=off gate — no misleading hints when search disabled
  • P4: 11 tests covering all 5 scenarios, all passing

Ready for re-review when you have a chance. Thanks!

WeilaiSun added 6 commits August 2, 2026 00:25
Adds tool_injection config option for MCP servers, supporting two modes:
- full (default, backwards-compatible): all tools loaded eagerly
- description_only: tools always deferred, discovered via tool_search

Modeled after Claude Code's defer_loading pattern and Hermes's
own Skill frontmatter discovery (name+desc in prompt, body on demand).

Changes:
- tools/tool_search.py: mark_description_only_tool() registry + force-defer
  in assemble_tools() even when under threshold
- tools/mcp_tool.py: read tool_injection config, mark tools during registration
- agent/system_prompt.py: inject MCP tool inventory (name+desc) into stable tier
  for description-only servers, mirroring the Skill index block

Closes NousResearch#66736
Refs: NousResearch#6839 (Lazy Tool Schema Loading), Writer Harness paper (arXiv 2607.06906)
…earch gate, tests

Fixes all 4 issues from @teknium1's review:

1. Session scoping: filter description_only inventory against agent.valid_tool_names
   instead of exposing the process-global set. Prevents cross-session tool leakage.

2. Utility tools: mark MCP Resources/Prompts utility tools as description_only
   when the server's tool_injection is set to description_only mode.

3. tool_search-off gate: skip MCP inventory injection when tool_search is
   disabled globally, preventing misleading instructions to use tool_search.

4. Tests: add TestDescriptionOnly class with 8 tests covering mark/is roundtrip,
   classification, assembly force-bridge, session scoping, and duplicate handling.

Test results: 263/264 passed (1 pre-existing Windows env test failure)
…dge tests

P1: Description-only MCP tool inventory in the system prompt now derives
from pre-assembly tool names (agent._pre_assembly_tool_names) instead of
agent.valid_tool_names.  agent.valid_tool_names is the post-tool_search-
assembly visible set — description_only tools are deferred behind bridge
tools, so the intersection was always empty and the inventory block was
never generated.  agent._pre_assembly_tool_names captures the full
session-granted tool set before tool_search deferral, so the inventory
correctly lists description_only tools while still being scoped to the
agent's enabled toolsets.

P4: Added two test scenarios:
- test_lazy_mcp_registration_marking_persists: verifies description_only
  marking + is_deferrable_tool_name + classify_tools for tools registered
  after agent init (lazy MCP server discovery).
- test_bridge_dispatch_finds_description_only_tool: verifies tool_search
  catalog + tool_describe schema retrieval for description_only tools
  via the bridge dispatch path.

P2 + P3 were already addressed in the parent fix commit; this commit
completes the remediation. All 49 test_tool_search.py tests pass.
…4 scenario 1)

OpenCode (deepseek-v4-pro) added test_description_only_inventory_in_system_prompt
to verify the system prompt correctly lists description_only tools with descriptions
but excludes full JSON parameter schemas. Completes all 5 P4 test scenarios.
All 50 tests pass.
…ache hits

_get_last_resolved_tool_names_ flips semantics with cache state: fresh compute
stores pre-assembly names, a quiet_mode cache hit overwrites it with the
cached POST-assembly list (description_only tools already collapsed behind the
bridge). agent_init captured that global into agent._pre_assembly_tool_names,
so the 2nd+ session with the same toolset key got an empty intersection and
the system-prompt inventory silently vanished (gateway/TUI/cron all build
agents with quiet_mode=True).

- model_tools: new _last_pre_assembly_tool_names global, snapshot at the
  pre-assembly point in _compute_tool_definitions; cache value now carries
  (final_list, pre_assembly_names) and the cache-hit path restores both
- agent_init: capture the pre-assembly global (was _last_resolved_tool_names)
- mcp_tool refresh_agent_mcp_tools: publish _pre_assembly_tool_names
  atomically with the snapshot so late-registered description_only servers
  reach the inventory after /reload-mcp or lazy refresh
- tool_search: unmark_description_only_tool; mcp_tool _deregister_tools now
  unmarks on server unload (no stale marks); tool_injection config validated
  (full|description_only, warn+fallback); system_prompt except now logs
- tests: P1 regression (cache hit keeps pre-assembly names + inventory),
  tool_search-off no-inventory real path, rewrote 3 weak tests to real paths
  (_register_server_tools/refresh, build_system_prompt_parts, model_tools)
…arch#66826 P2)

When tool_search assembly is already active, a late-registered
description_only server keeps the POST-assembly name set unchanged
(its tools are bridged away), so refresh_agent_mcp_tools early-returns
without publishing. The pre-assembly view must still be published,
or the system-prompt description_only inventory silently misses the
new server's tools.

- refresh_agent_mcp_tools: publish agent._pre_assembly_tool_names on
  the new_names == current early-return path
- test: P2 regression (baseline refresh -> late register -> refresh
  early-return still tracks the new tool in pre-assembly); RED-verified
  (fails without the fix, passes with it)

Refs: review finding on 13dfba6 (GO 84/100, 1 P2 residual)
@WeilaiSun
WeilaiSun force-pushed the feat/mcp-description-only-tool-injection branch from c97b032 to a0d526f Compare August 1, 2026 17:46
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

One PR addresses issue #66736. #66826 implements the requested per-server description_only policy by deferring marked MCP tools, publishing a scoped name-and-description catalog, and exposing full schemas through the existing tool-search bridge.

Related pull requests

  • feat: per-MCP-server tool_injection control (description_only mode) #66826 best fix — (+828/-15) — n/a: The diff covers regular and generated MCP utility tools, tool-search-disabled behavior, cache hits, late registration, deregistration, scoped inventory, and bridge discovery with added tests. Consistent with the contributor's COMMENTED keep_open review, the implementation remains salvageable, but agent_init.py still copies session scope from the process-global model_tools._last_pre_assembly_tool_names, leaving a possible cross-agent race between tool-definition assembly and agent capture.

Suggested consolidation

Keep #66826 open with a salvage path: preserve its per-server policy, lifecycle handling, prompt inventory, and test coverage, while replacing the process-global pre-assembly handoff with state returned directly from tool-definition assembly or otherwise bound to the receiving agent, then add a concurrent-agent isolation regression test. There are no duplicate PRs to close.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I66736(["issue #66736 (open)"])
    P66826["PR #66826 (open)"]
    P66826 -->|best fix| I66736
    class I66736 open
    class P66826 open
    class P66826 best
    class P66826 target
    click I66736 "https://github.com/NousResearch/hermes-agent/issues/66736"
    click P66826 "https://github.com/NousResearch/hermes-agent/pull/66826"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 1 pull request and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 49 kB of PR diffs, 6 kB of issue/PR text, 6 kB of discussion (7 comments), 2 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

WeilaiSun added 3 commits August 6, 2026 01:08
…ssion test

- tests/run_agent/conftest.py: autouse fixture 将 with_meta 转发到 legacy
  get_tool_definitions mock(33 文件一次性适配,防未来漏)
- tests/test_copilot_initiator.py: 手动补 with_meta mock
- tools/mcp_tool.py: refresh_agent_mcp_tools 改 with_meta 解包直绑,
  消除读进程全局的同类 race
- tests/test_model_tools.py: +agent 级交错 init 回归测试 + finally deregister
- 连带适配 3 个 mcp 相关测试文件
@WeilaiSun

Copy link
Copy Markdown
Author

Thanks for the triage review @GottZ — the race is fixed.

What changed (3 commits on top of the P1/P2 fixes):

  1. get_tool_definitions_with_meta() — the tool-definition assembly now returns (tools, pre_assembly_names) directly from the call that produced them (both cache-hit and fresh paths). agent_init binds agent._pre_assembly_tool_names from that return value instead of copying the process-global _last_pre_assembly_tool_names — no cross-agent handoff, no race window. The legacy global remains only for the remaining non-agent consumers (documented).
  2. Concurrent-agent isolation regression test — two agents with disjoint toolsets interleaved through init; each agent's pre-assembly inventory stays its own (RED on the old implementation).
  3. Same-class race in refresh_agent_mcp_tools (mcp_tool.py) also switched to the with_meta return value instead of reading the process global.
  4. Full mock adaptation across the test suite (conftest forwarding fixture + per-site patches), so the CI surface is green for the affected paths.

Verified: test_model_tools + cache_isolation + delegate + refresh + turn_context + acp suites all pass; ruff clean. (Two pre-existing environment-only failures in TestVisionDispatchLoopSafety and a Windows path-separator test are unrelated and reproduce on the parent commit.)

Happy to adjust if you'd prefer the state bound differently.

@alt-glitch alt-glitch removed the sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/mcp MCP client and OAuth type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Per-MCP-server tool injection control — description-only mode for low-frequency MCPs

4 participants