Skip to content

feat(mcp): add tool_search for on-demand MCP schema fetching - #27257

Closed
DaveDev42 wants to merge 2 commits into
NousResearch:mainfrom
DaveDev42:feat/mcp-tool-search
Closed

feat(mcp): add tool_search for on-demand MCP schema fetching#27257
DaveDev42 wants to merge 2 commits into
NousResearch:mainfrom
DaveDev42:feat/mcp-tool-search

Conversation

@DaveDev42

Copy link
Copy Markdown

Summary

  • Implements two-pass lazy MCP-tool loading as proposed in Feature: Lazy Tool Schema Loading — Two-Pass Tool Injection to Reduce Token Overhead #6839 (specifically for MCP-server tools — the heaviest schema source today). Parks MCP schemas in a deferred pool and exposes a single tool_search tool that fetches them on demand.
  • Wire format mirrors Anthropic Claude Code's MCP Tool Search (v2.1.7) — <functions><function>{json}</function></functions> — so models trained on that data recognise it natively, no per-provider prompting required.
  • System prompt now carries a compact deferred-tool roster (name + 1-line summary, grouped by toolset) so the model can pick names without paying the full schema cost upfront.
  • Validated locally with 234 MCP tools across 6 servers (Apple, GitHub, Google Workspace, iCloud, Readwise, Slack): full self.tools array drops from 234 → 0 MCP entries (~273 KB schema savings). tool_search is auto-injected; promoted tools (those the model has fetched via select:) stay callable on subsequent turns via a new pool.promoted_names() set that survives pool.clear()/repopulate cycles.

Activation

Opt-in via ~/.hermes/config.yaml:

mcp:
  tool_search:
    enabled: true            # opt-in (default false → no behaviour change)
    always_active: true      # park even when below threshold
    threshold_chars: 8000    # auto-activate above this MCP schema size

Default enabled: false preserves current behaviour for existing users.

Query forms

tool_search accepts:

  • select:mcp_github_search_code,mcp_slack_conversations_history — exact fetch
  • +github search code — require github in name, rank by remaining terms
  • notebook jupyter — keyword search, top max_results matches

Response is a <functions> block carrying the full JSON schema(s); selected tools are promoted back into self.tools / valid_tool_names on the next get_tool_definitions() pass so subsequent calls dispatch normally.

Files

  • tools/mcp_tool_search.py (new, ~200 lines) — schema, query parser, scorer, response builder, registry self-registration with toolset=hermes-cli, check_fn gated on pool size
  • tools/deferred_pool.py (new, ~100 lines) — thread-safe DeferredToolPool with put/remove/promoted_names/clear/generation counter (long-lived Gateway process where MCP refresh can race agent threads)
  • model_tools.py_apply_mcp_tool_search() pass runs after registry.get_definitions(); cache key extended with deferred_pool.generation so promotions invalidate; tool_search re-injected from registry when pool is non-empty (its check_fn would otherwise hide it at registry-pass time)
  • run_agent.py — system-prompt block listing deferred tools grouped by toolset; post-dispatch hook in both parallel and sequential paths rebuilds self.tools / self.valid_tool_names and clears _cached_system_prompt after a tool_search call
  • toolsets.pytool_search added to _HERMES_CORE_TOOLS so it's part of the hermes-cli composite

Closes

Partially addresses #6839 — MCP-server tools (typically the biggest schema source) only. Built-in toolsets (terminal, file, browser, etc.) are out of scope for this PR; the issue's broader "all toolsets" lazy-loading could layer on top using the same deferred pool.

Test plan

  • Direct round-trip test with all 6 MCP servers + enabled_toolsets=['hermes-cli', 'mcp-*']:
    • Pass 1: 29 tools returned (1 = tool_search, 28 = non-MCP), pool size = 234, MCP count in self.tools = 0 ✅
    • tool_search invoked with select:mcp_github_search_code returns valid <functions> block ✅
    • Pass 2: 30 tools returned, mcp_github_search_code callable, tool_search still present, pool = 233 ✅
  • Gateway integration test with a non-no_mcp platform (the test author's telegram config has no_mcp sentinel; needs a fresh test with platform_toolsets.<plat> that includes MCP server keys)
  • Behaviour check with enabled: false (default) — verify zero-diff vs current main
  • Concurrent gateway request stress test — pool.generation increment under contention

Parks MCP tool schemas in a deferred pool and exposes a single `tool_search`
tool that fetches full schemas on demand. Mirrors Anthropic Claude Code's
MCP Tool Search (v2.1.7) wire format so models trained on that data
recognise the `<functions><function>{json}</function></functions>` envelope
natively.

Validated locally with 234 MCP tools across 6 servers (Apple, GitHub,
Google Workspace, iCloud, Readwise, Slack): full self.tools array drops
from 234 → 0 MCP entries (~273 KB schema savings), with tool_search
auto-injected and promoted tools (those fetched via select:) staying
callable on subsequent turns.

Activation:
  mcp:
    tool_search:
      enabled: true            # opt-in
      always_active: true      # park even when below threshold
      threshold_chars: 8000    # auto-activate above this MCP schema size

Query forms supported by tool_search:
  select:mcp_github_search_code,mcp_slack_conversations_history
  +github search code        (require 'github', rank by remaining terms)
  notebook jupyter           (keyword search, top max_results matches)

System prompt now carries a compact deferred-tool roster (name + 1-line
summary) grouped by toolset, so the model can pick names without paying
the full schema cost upfront.
@cardtest15-coder

This comment was marked as spam.

@daimon-nous daimon-nous Bot added type/feature New feature or request P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets tool/mcp MCP client and OAuth labels May 17, 2026
Two live-test fixes:

1. Registry's dispatch() calls handler(args: dict, **kw); the previous
   _handle_tool_search took (query, max_results=5) directly, so the
   first real call from a model blew up with `unexpected keyword
   argument 'task_id'`. Switched to the canonical (args, **kw) shape.

2. _apply_mcp_tool_search now logger.info()s on every activation, not
   only when quiet_mode=False. Oneshot mode (hermes -z) runs with
   quiet_mode=True so previously there was no agent.log evidence the
   pass ever fired.

Validated with `hermes -z` runs that force MCP usage:
- mcp_github_search_code returned a real GitHub API response
- mcp_slack_channels_list returned actual channel names
both via tool_search → promote → next-turn dispatch.
@DaveDev42

Copy link
Copy Markdown
Author

Live-tested end-to-end with hermes -z (one-shot CLI, quiet_mode=True, platform="cli", all 6 MCP servers enabled = 234 tools):

Test 1 — GitHub MCP

$ hermes -z "Use mcp_github_search_code to search for the EXACT literal string 'def _handle_tool_search' inside NousResearch/hermes-agent. Show the raw result."
Raw search result:
{"result": "{\"total_count\":0,\"incomplete_results\":false}"}

Test 2 — Slack MCP

$ hermes -z "List the names of my Slack channels using mcp_slack_channels_list. Comma-separated, max 5."
gov-general, general, prd-magicmoment, prd-magicmoment-dev-noti, prd-lottecinema

Both go through the full path: model picks an MCP tool by name from the deferred-tool roster in the system prompt → calls tool_search with select:<name> → receives <functions>{...}</functions> block → calls the now-promoted tool on the next turn → real MCP server response surfaces.

Two follow-up fixes in 3da9b087c:

  1. Handler signature — registry's dispatch() calls handler(args: dict, **kw); my first cut took (query, max_results=5) directly and blew up with unexpected keyword argument 'task_id' on the first real model call. Switched to the canonical (args, **kw) shape and extract query/max_results from args inside.
  2. Activation log line_apply_mcp_tool_search now logger.info()s on every activation regardless of quiet_mode. The print() is still gated on quiet_mode=False for interactive runs. Without this, oneshot/gateway runs had zero log evidence the pass even fired.

Test-plan updates:

  • Direct round-trip test (already passing pre-push)
  • Live hermes -z E2E with non-trivial MCP tools (GitHub + Slack)
  • Behaviour check with enabled: false (default) — still pending
  • Concurrent gateway-thread stress test — still pending

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists 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.

2 participants