Lesson 03 · Harness Engineering

Tools, MCP, and Skills

How Hermes manages its capability surface across three layers

The Three Layers

Hermes has three distinct systems for extending what the agent can do. They form a layered stack:

flowchart TB L1["Layer 1   Built-in Tools
~65 total, ~18-28 per session
terminal · file · web · browser
memory · delegate · cron · skills"] L2["Layer 2   MCP Servers
External tool servers
GitHub · Supabase · filesystem · databases"] L3["Layer 3   Skills
Procedural knowledge documents
Step-by-step workflows · cheat sheets · guides"] L1 --> L2 L2 --> L3 style L1 fill:#dbeafe,stroke:#2563eb,stroke-width:3px style L2 fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style L3 fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 1: Three layers of capability. Built-in tools are always available. MCP adds external tools. Skills add knowledge — they aren't executable tools, they teach the agent how to use tools better.

Each layer serves a different purpose. Built-in tools are actions the agent can take. MCP tools are external actions the agent discovers at startup. Skills are knowledge the agent reads on-demand to do its job better. They stack: a skill can teach the agent to use MCP tools effectively, and MCP tools work alongside built-in tools in the same conversation.

The Tool Registry

Every built-in tool lives in a single file under tools/ and self-registers at import time into the ToolRegistry singleton. This is the central pattern you must understand:

flowchart TB FILES["tools/ directory"] FILE1["tools/terminal_tool.py"] FILE2["tools/file_tool.py"] FILE3["tools/web_search_tool.py"] FILE4["tools/browser_tool.py"] REG(["ToolRegistry singleton\nname → {schema, handler,\ntoolset, check_fn}"]) MT["model_tools.py\nqueries registry → builds\nper-session tool schema list"] LLM["LLM receives tool schemas\nand decides which to call"] FILES --> FILE1 FILES --> FILE2 FILES --> FILE3 FILES --> FILE4 FILE1 -->|"registry.register()\nat import time"| REG FILE2 -->|"registry.register()"| REG FILE3 -->|"registry.register()"| REG FILE4 -->|"registry.register()"| REG REG -->|"get_all_tool_names()\nget_tool_definitions()"| MT REG -->|"dispatch(name, args)"| MT MT -->|"tool schemas\ninjected into API call"| LLM style REG fill:#dbeafe,stroke:#2563eb,stroke-width:3px style MT fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 2: The tool registry. Each tool file calls registry.register() at import time. model_tools.py queries the registry to build the tool list the LLM sees, and dispatches tool calls through it.

register() — what goes in

Each tool provides four things when it registers:

registry.register(
    name="terminal",          # unique tool name the LLM calls
    toolset="terminal",        # which toolset it belongs to
    schema={...},              # JSON schema the LLM sees as a function
    handler=lambda args: ...,  # actual implementation — returns JSON string
    check_fn=requirements,        # optional: returns True/False for availability
    requires_env=["API_KEY"],   # optional: env vars needed
)
The key ideas

Auto-discovery: Any tools/*.py with a top-level registry.register() call is auto-imported. No manual list to maintain.

Conditional gating: check_fn lets tools appear only when requirements are met (e.g. websockets package installed, CDP endpoint configured, API key present).

Toolsets: hermes tools enable/disable <toolset> controls entire groups. Toolsets are per-platform (CLI can have different tools than Discord).

No core changes: Adding a tool is one file. Zero changes to the agent core or model_tools.py.

💡 The 20-Tool Recommendation

Anthropic recommends sending ≤20 tools per API call for optimal Claude performance. Hermes often ships more (~28 with browser), which is a real tradeoff: more tools means the model has more options but can also struggle with selection quality. Hermes addresses this with toolsets — you can disable unnecessary toolsets per-session or per-platform. If you're building a similar system, design your tool schema filtering before you have 60+ tools. See Lesson 01: "The Core Is Narrow" is Rule #3.

Availability checks

The check_fn is what makes "system dependency not met" warnings appear in hermes tools list. It's a simple function:

# Some tools are only available on certain platforms
def check_requirements() -> bool:
    return bool(os.getenv("MY_API_KEY"))

# Some need external packages installed
def check():
    try:
        import websockets
        return True
    except ImportError:
        return False

# If check_fn returns False, the tool doesn't appear in the LLM's schema list
# and the model can't call it. No error — it's just invisible.
flowchart TB START["Agent starts"] LOAD["Import all tools/*.py"] REGISTER["Each calls registry.register()"] CHECK["check_fn() called\nfor each toolset"] VISIBLE{"check passes?"} INJECT["Tool added to LLM schema list"] HIDDEN["Tool hidden from LLM"] SESSION["Session starts\nwith enabled toolsets"] START --> LOAD LOAD --> REGISTER REGISTER --> CHECK CHECK -->|"yes"| VISIBLE CHECK -->|"no"| HIDDEN VISIBLE --> SESSION HIDDEN --> SESSION style VISIBLE fill:#f0fdf4,stroke:#16a34a,stroke-width:2px style HIDDEN fill:#fef2f2,stroke:#ef4444,stroke-width:2px
Figure 3: Tool availability lifecycle. At import time, check_fn gates each tool. Only passing tools appear in the LLM's schema. Toolsets are further filtered per-session.

MCP Servers — External Tool Ecosystems

MCP (Model Context Protocol) connects Hermes to external tool servers — GitHub, filesystem, databases, Supabase, browser automation — as if they were built-in tools. They're discovered at startup and registered into the same ToolRegistry with a mcp_<server>_<tool> prefix.

flowchart TB CONFIG["config.yaml\nmcp_servers:\n github:\n supabase:\n time:"] STARTUP["Agent startup"] DISCOVER["discover_mcp_tools()\nreads config.yaml"] CONNECT["Connects to each server\nstdio or HTTP transport"] LIST["Calls list_tools()"] REGISTER2["Registers each tool as\nmcp_servername_toolname\ninto ToolRegistry"] DONE["Tools available\nin every session"] CONFIG --> STARTUP STARTUP --> DISCOVER DISCOVER --> CONNECT CONNECT --> LIST LIST --> REGISTER2 REGISTER2 --> DONE style CONNECT fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style REGISTER2 fill:#dbeafe,stroke:#2563eb,stroke-width:3px
Figure 4: MCP startup discovery. On agent start, Hermes connects to each configured MCP server, lists its tools, and registers them with a prefix. No config changes needed mid-session.

Server types

Three connection types:

# 1. Stdio — local subprocess (most common)
# Runs as a child process, communicates over stdin/stdout
mcp_servers:
  github:
    command: "npx"
    args: ["-y", "@modelcontextprotocol/server-github"]
    env:
      GITHUB_TOKEN: "ghp_..."

# 2. HTTP — remote endpoint
# Direct connection to a remote MCP server
mcp_servers:
  my_api:
    url: "https://mcp.example.com/mcp"
    headers:
      Authorization: "Bearer sk-..."

# 3. HTTP + OAuth — for services like Google Drive, Linear
mcp_servers:
  google_slides:
    url: "https://mcp.example.com/mcp"
    auth: oauth

How MCP tools appear

Once registered, MCP tools look identical to built-in tools from the LLM's perspective. The only difference is the name prefix. For example, with a server called github:

MCP serverTool name in serverHermes tool name
githublist_issuesmcp_github_list_issues
filesystemread_filemcp_filesystem_read_file
timeget_current_timemcp_time_get_current_time
supabaseexecute_sqlmcp_supabase_execute_sql

The agent sees all of these alongside terminal, read_file, web_search, etc. It doesn't know which are built-in vs MCP — it just sees a flat list of callable functions.

flowchart TB BUILTIN["Built-in tools\nterminal · file · web_search\nbrowser · memory · cron"] MCP_TOOLS["MCP-registered tools\nmcp_github_*\nmcp_supabase_*\nmcp_filesystem_*"] COMBINED(["Agent sees one flat list\nof all callable tools"]) LLM2["LLM decides which to call"] BUILTIN --> COMBINED MCP_TOOLS --> COMBINED COMBINED --> LLM2 style BUILTIN fill:#dbeafe,stroke:#2563eb,stroke-width:2px style MCP_TOOLS fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style COMBINED fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 5: Unified tool surface. MCP tools join built-in tools in a single registry. The LLM sees them all as callable functions with no distinction.
Security: env filtering

Hermes does NOT pass your full shell environment to MCP subprocesses. Only PATH, HOME, USER, LANG, and a few others are inherited. All API keys must be explicitly added via the env: key in the server config. This prevents credential leakage to untrusted MCP servers.

Progressive Disclosure — For Both Tools AND Skills

Both tools and skills use progressive disclosure, but in different ways:

flowchart TB subgraph SKILLPD["Skills: always deferred"] S1["System prompt has\nonly names + descriptions
~3K tokens"] S2["Agent calls skill_view()\nto load full content"] S3["skill_manage()\nsaves new procedures"] S1 --> S2 --> S3 end subgraph TOOLPD["Deferrable tools (MCP + plugins): automatic"] T1["System prompt has\ncore Hermes tools only\n+ 3 bridge tools
tool_search · tool_describe · tool_call"] T2["Agent calls tool_search()\nto find deferrable tools by keyword"] T3["Agent calls tool_describe()\nto get full JSON schema"] T4["Agent calls tool_call()\nto invoke the deferred tool"] T1 --> T2 --> T3 --> T4 end style SKILLPD fill:#f0fdf4,stroke:#16a34a,stroke-width:2px style TOOLPD fill:#dbeafe,stroke:#2563eb,stroke-width:3px
Figure 6: Two progressive disclosure systems. Skills always use it (names in prompt, full content on demand). Tools use it automatically for MCP + plugin tools when the Tool Search bridge is active — core tools are never deferred.

Tool Search bridge — how tool progressive disclosure works

When the Tool Search bridge is active (default: auto, config at tools.tool_search in config.yaml), MCP and plugin tools are replaced in the model-visible schema by three bridge tools:

# These 3 tools replace ALL deferrable MCP/plugin tools in the API call
tool_search(query, limit)       → Finds deferrable tools by name/keyword, returns catalog
tool_describe(name)              → Returns the full JSON schema for one deferrable tool
tool_call(name, arguments)       → Invokes a deferrable tool with the given arguments

Default mode: auto — if all deferrable tools would consume ≥10% of the context window, they're replaced by the 3 bridge tools. If less than 10%, they pass through unchanged. This means on low-tool-count sessions, you see all tools; on MCP-heavy sessions, the bridge kicks in automatically.

Core tools are NEVER deferred. The 18-28 built-in tools (terminal, file, web, browser, etc.) always ship in the schema. Only MCP and plugin-registered tools can be deferred.

WhatProgressive disclosure?How
Core built-in tools❌ Never deferredAlways in schema (18-28 per session)
MCP + plugin tools✅ Via Tool Search bridgeReplaced by 3 bridge tools when threshold hit (≥10% context)
Skills✅ AlwaysOnly names in prompt; full content loaded via skill_view()
Why this matters

Without progressive disclosure, every MCP server you add ships its tool schemas on every API call. With just 3-4 MCP servers, you could easily hit 40-50 total tools. The Tool Search bridge keeps the active schema lean by deferring MCP/plugin tools until the agent asks for them by name. If you're building a similar system, design your tool schema filtering before you have 60+ tools — adding progressive disclosure after is much harder.

Skills — Procedural Knowledge

Skills are knowledge documents, not executable tools. They always use progressive disclosure — only names + descriptions go in the system prompt, and the agent must explicitly call skill_view() to load full content:

flowchart TB SKILLS_DIR["skills/ directory\nfull SKILL.md docs"] INDEX["System prompt\ncontains only names +\ndescriptions of all skills"] AGENT["Agent working\non a task"] MATCH{"Skill relevant?"} VIEW["skill_view(name)\nloads full content"] USE["Agent follows steps\navoids repeating mistakes"] SAVE["skill_manage(action='create')\nagent writes new skills"] SKILLS_DIR --> INDEX INDEX --> AGENT AGENT --> MATCH MATCH -->|"yes"| VIEW MATCH -->|"no"| AGENT VIEW --> USE USE --> SAVE SAVE --> SKILLS_DIR style INDEX fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style VIEW fill:#dbeafe,stroke:#2563eb,stroke-width:3px style SAVE fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 7: Skills progressive disclosure. Only names + descriptions (~3K tokens total) go in the system prompt. Full content loads on demand via skill_view(). Agent can also create skills — the self-improvement loop.

Three access levels

LevelToolWhat's loadedToken cost
0skills_list()Skill names + descriptions only~3K tokens
1skill_view(name)Full SKILL.md content (frontmatter + body)Varies
2skill_view(name, file_path)Specific reference file (scripts, templates)Depends on file

The system prompt only contains the index (Level 0). The agent must explicitly call skill_view() to get details. This is deliberate — 60+ skills at full content would blow the context window.

Skill anatomy

# SKILL.md with YAML frontmatter
---
name: my-skill
description: "Brief description shown in skills_list()"
version: 1.0.0
platforms: [linux, macos]
metadata:
  hermes:
    tags: [python, devops]
    requires_toolsets: [terminal]   # Only shown if terminal is enabled
    fallback_for_toolsets: [web]    # Shown when web tools are unavailable
    config:
      - key: my.setting
        description: "Control setting"
        default: "value"
---

## Procedure
1. Step one with exact commands
2. Step two with pitfalls noted
3. Verification step

Agent-managed skills

This is the self-improvement loop in action. The agent can autonomously create and maintain skills using skill_manage:

# After solving a complex multi-step problem, the agent saves the approach
skill_manage(
    action="create",
    name="deploy-fastapi",
    content="---\nname: deploy-fastapi\n...\n---\n## Steps\n..."
)

# To fix a stale skill mid-use
skill_manage(
    action="patch",
    name="deploy-fastapi",
    old_string="old command",
    new_string="new command",
)
Skills vs MCP vs built-in tools

The agent can have all three in the same conversation. A skill might contain a step like "use the mcp_supabase_execute_sql tool with this query", teaching the agent how to combine MCP tools with its built-in knowledge.

How They Work Together

flowchart TB AGENT2["Agent receives a task"] CALL["Can I do this with\nbuilt-in tools?"] MCP_CHECK["Can I do this with\nMCP tools?"] SKILL_CHECK["Is there a skill\nfor this task?"] LEARN["Learn from skill\n→ use tools better"] CREATE["Task succeeded\n→ save as new skill"] DO["Execute with\ntools I have"] AGENT2 --> CALL CALL -->|"yes"| DO CALL -->|"no"| MCP_CHECK MCP_CHECK -->|"yes"| DO MCP_CHECK -->|"no"| SKILL_CHECK SKILL_CHECK -->|"yes"| LEARN LEARN --> DO SKILL_CHECK -->|"no"| DO DO --> CREATE style CALL fill:#dbeafe,stroke:#2563eb,stroke-width:2px style MCP_CHECK fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style SKILL_CHECK fill:#f0fdf4,stroke:#16a34a,stroke-width:2px style CREATE fill:#fef3cd,stroke:#d97706,stroke-width:2px
Figure 8: Runtime decision flow. The agent checks built-in tools first, then MCP, then loads a skill if needed. Skills teach better tool usage; successful tasks create new skills.

Key CLI commands reference

CommandWhat it does
hermes tools listShow all toolsets with ✅/❌ availability
hermes tools enable <name>Enable a toolset for future sessions
hermes mcp catalogBrowse curated MCP servers
hermes mcp install <name>Install an MCP server from catalog
hermes mcp listList configured MCP servers
hermes mcp test <name>Test connectivity to a server
hermes mcp configure <name>Toggle specific tool enable/disable
hermes skills listList all installed skills
hermes skills search <q>Search skills hub
hermes skills install <id>Install a skill from hub
hermes skills browseBrowse all available skills
/reload-mcpSlash command to reload MCP servers mid-session

Key Takeaways

A new MCP server is configured in config.yaml. When does the agent see its tools?
Immediately — the server is hot-reloaded mid-conversation
On next agent startup — MCP discovery runs once during initialization
When the user runs /reload-mcp — that's the only way
Only if the server supports notifications/tools/list_changed