Skip to main content

P4.7 Phase 1 — read-class tool dispatch to Hermes (direction A, stdio MCP) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (- [ ]) syntax. This plan touches BOTH services (agent Python + api NestJS) and changes the /chat wire contract — the wire-contract task is a paired agent PR (see agent/CLAUDE.md).

Goal: Make a Specialist's configured business tools actually reachable by the Hermes runtime during a /chat draft turn — for read-class tools only (Phase 1). Fix the dead -t wiring, expose business tools to Hermes via a thin stdio MCP server that forwards to the existing tool-gateway, and pass the active-skills.tools ∩ tool_bindings.toolSlug intersection as the selected toolset. Write-class tools are deliberately NOT exposed this phase — their Expert-approval carrier (ExpertQueueItem) has an unresolved messageId gap, deferred to Phase 2.

Architecture (direction A, validated by the 2026-07-09 spike docs/superpowers/specs/2026-07-09-p4.7-hermes-runtime-spike-results.md): The unified tool layer is the ALREADY-LIVE control-plane gateway (POST /v1/runtime/tool-gateway/execute). A thin stdio MCP server (new, in agent/) exposes the business tools to Hermes; each handler forwards to the gateway via the ALREADY-EXISTING agent/tool_gateway_client.py. Credentials/business logic/policy stay in the control plane; Hermes only knows the MCP server name (-t humanwork) and inherits the runner token. Read-class calls execute straight through the gateway (no approval).

Tech stack: agent = FastAPI + Hermes v0.17.0 binary (same container, /opt/hermes/.venv/bin/hermes); MCP server = Python stdio (greenfield, no repo precedent); api = NestJS 11; wire = AgentClient ↔ agent /chat.


Context the implementer needs (verified @ feat/specialist-value-output-p2plus, Explore report 2026-07-09)

Spike-validated facts: -t '<json>' → "Unknown toolsets" (bug live at agent/main.py:750-755 + hermes_client.py:353-354); an mcp_servers KEY is a -t-selectable toolset (cli.py:3639 admits t in mcp_names); business tool names (order_lookup, …) are NOT built-in toolsets (validate_toolset(...)=False), so they MUST come via MCP. Approval cannot ride Hermes — but Phase 1 has no approval (read-only), so that's Phase 2's concern.

Existing assets to REUSE (do not rebuild):

  • agent/tool_gateway_client.py::execute_tool_via_gateway (:29-210) — already POSTs {PLATFORM_API_URL}/v1/runtime/tool-gateway/execute with Authorization: Bearer <runner token>, payload matching ExecuteToolDto (orgId, runId, toolName, action, params, correlationId + optional riskLevel/toolVersion/providerKind/integrationType), handles 403→blocked / 500→failed / 404→raise / one timeout retry. The MCP handler is a thin wrapper over THIS.
  • agent/platform_auth.py::platform_api_token() (:8-20) — runner token (HUMANWORK_RUNNER_TOKEN → … → PLATFORM_API_TOKEN), the aud:'runner' token RunnerTokenGuard verifies.
  • Gateway: tool-execution.controller.ts:66-73 (POST v1/runtime/tool-gateway/execute, @Public()+RunnerTokenGuard), ExecuteToolDto (:18-60). Read-class → ActionPolicyService.decide returns allow (action-policy.service.ts:117) → RuntimeToolExecutor.execute (runtime-tool-executor.service.ts:29, AGENT_TOOL_REGISTRY[toolName] dispatch, credential by (orgId, integrationType) :75-87). Read tools need NO gateway change — they already execute.
  • ConfigAssetsService.getPublishedSkills({orgId, specialistId}) (config-assets.service.ts:554) → PublishedSkill[] with full content.tools[] (slug array, skill.schema.ts:47, regex /^[a-z0-9_]{1,64}$/). SkillTriggerService.evaluate DROPS tools[] (skill-trigger.service.ts:34-42) — Phase 1 must re-fetch getPublishedSkills for tools[], mirroring how P3.5 re-fetched redLines.
  • tool-binding.schema.ts: toolSlug (:14, same regex → intersect by string), accessClass: read|write (:16), kind: bespoke|nango (:15mcp deliberately absent, it's this task's addition).
  • AGENT_TOOL_REGISTRY (agent-api/tool-registry.ts:18, 7 tools, kind: AgentToolKind, handler, integrationTypes, actions[]) is the executor's dispatch source. TOOL_CATALOG (tools/tool-catalog.ts:24, 3 tools, Claude-format) feeds buildToolsManifest. Neither has accessClass today.

Wire contract facts: /chat currently carries NO tool info — AgentChatRequest/buildV1ChatPayload (agent.client.ts) have no toolsets/tools_manifest; AGENT_API_SPEC.md doesn't list it. agent main.py:308 CAN receive tools_manifest (AliasChoices) but NestJS never sends it. So adding a toolset field to /chat is a paired agent+api PR (agent/CLAUDE.md: "Don't add new request fields to /chat without a paired NestJS PR").

Deploy facts (stdio): hermes binary in the agent container (agent/Dockerfile:17); Hermes home / cli-config at /opt/data/config.yaml (HERMES_HOME, :89), volume-shadowed so config is re-asserted at boot (:94 || true pattern). No mcp_servers written today (greenfield). hermes_client._runner_subprocess_env (:250-329) builds the hermes subprocess env with a secret fence (:259) but ALREADY allowlists HUMANWORK_RUNNER_TOKEN/HUMANWORK_MODEL_GATEWAY_TOKEN past it (:324-327); the stdio MCP server inherits this env. Gap: PLATFORM_API_URL has no HUMANWORK_/HERMES_ prefix so it's NOT passed through the :274 prefix filter — must be added explicitly so the MCP handler's tool_gateway_client has a base URL.

DECISION POINTS (reversible defaults — user may override)

  • DP-1 — Phase split: read-class only this phase (USER-DECIDED). write-class tools are filtered OUT of the intersection before -t, so Hermes is never even offered a write tool. Avoids the ExpertQueueItem messageId gap entirely. Phase 2 does write + approval.
  • DP-2 — MCP server exposes tools from AGENT_TOOL_REGISTRY (the executor's real dispatch source, 7 tools, has handler/integrationType), NOT TOOL_CATALOG (3-tool Claude-format manifest feed). The -t name is the single MCP server humanwork; individual tools are MCP tools under it.
  • DP-3 — Intersection computed NestJS-side, passed as a toolset name over /chat. Alternative (agent computes intersection) would need agent to read config-assets — a cross-service coupling the platform avoids. NestJS already owns skills+bindings reads. So /chat carries the resolved toolset name(s); agent just forwards to -t.
  • DP-4 — Phase 1 exposes the single humanwork MCP toolset when the intersection is non-empty; empty → no -t (tool-free, today's behaviour). Per-tool -t granularity isn't needed — the MCP server advertises exactly the intersected read tools; Hermes selects the server, the server lists its tools.

File structure

agent (Python):

  • Create agent/humanwork_mcp_server.py — stdio MCP server; handlers wrap tool_gateway_client.execute_tool_via_gateway.
  • Modify agent/main.py:750-755 — stop json.dumps-ing the manifest into -t; pass the toolset name from the new wire field.
  • Modify agent/hermes_client.py_runner_subprocess_env: add PLATFORM_API_URL passthrough.
  • Modify agent/Dockerfile + boot — write mcp_servers: { humanwork: {command…} } into /opt/data/config.yaml, re-assert at boot.
  • Modify agent/AGENT_API_SPEC.md + agent/CLAUDE.md — document the new /chat toolset field + the MCP server.

api (NestJS):

  • Create api/src/config-assets/tool-binding.reader.ts (or a method on ConfigAssetsService) — getPublishedToolBindings({orgId, specialistId}) (greenfield runtime reader; P2.5 only stored).
  • Create the intersection util — resolveSpecialistToolNames({orgId, specialistId}): getPublishedSkills.content.tools[] ∩ published tool_binding.toolSlug, filtered to accessClass === 'read' (Phase 1).
  • Modify api/src/common/agent.client.tsAgentChatRequest + buildV1ChatPayload: add toolsets?: string[] (or readToolsetName?).
  • Modify api/src/conversations/conversations.service.ts — at the two .chat() dispatch sites, resolve the intersection and pass it (flag-gated).
  • Modify api/src/config-assets/tool-binding.schema.ts — add mcp to kind enum (P4.6 absorbed).
  • Add feature flag tool_dispatch_enabled (default OFF) — Phase 1 ships dark; flip per-org after the live Hermes integration check.

Task 1: getPublishedToolBindings runtime reader (NestJS)

Files: Modify api/src/config-assets/config-assets.service.ts; Test api/test/config-assets-tool-bindings.spec.ts

  • Step 1: Failing test — mirror getPublishedSkills tests: seed a published tool_binding org_instance asset, assert getPublishedToolBindings({orgId, specialistId}) returns [{assetId, versionId, slug, content: ToolBindingContentT}] with dual-scope filter (org AND specialist), archivedAt: IsNull(), half-valid content skipped, and fail-open to [] on any error (it will sit on the /chat path). Include a case proving a scope:"catalog" binding is NOT returned and a wrong-specialist binding is NOT returned (ADR-020).
// api/test/config-assets-tool-bindings.spec.ts (shape — align to getPublishedSkills spec's datasource helper)
it("returns published tool_binding content dual-scoped, fail-open to []", async () => {
// seed published tool_binding for (ORG, SPEC); assert reader returns it with content.toolSlug + accessClass
// seed one for a different specialist; assert excluded
// corrupt one version's content; assert skipped, others returned
});
  • Step 2: Run → fail. cd api && npx jest test/config-assets-tool-bindings.spec.ts

  • Step 3: Implement getPublishedToolBindings on ConfigAssetsService, copying getPublishedSkills (config-assets.service.ts:554) verbatim in structure: same dual-scope where (assetType:"tool_binding", scope:"org_instance", orgId, specialistId, archivedAt: IsNull()), same published-version resolution, same ToolBindingContent.safeParse skip-half-valid, same exception-level fail-open → []. Return PublishedToolBinding[] ({assetId, versionId, versionNo, slug, updatedAt, content: ToolBindingContentT}). Read the real getPublishedSkills first and mirror it exactly — do not invent a different shape.

  • Step 4: Run → pass. Same command.

  • Step 5: Commitfeat(config-assets): getPublishedToolBindings runtime reader (P4.7 P1)


Task 2: Tool-name intersection util (NestJS)

Files: Create api/src/conversations/tool-resolution.util.ts; Test api/test/tool-resolution.spec.ts

  • Step 1: Failing test — pure function intersectReadTools(skills: PublishedSkill[], bindings: PublishedToolBinding[]): string[]:
    • returns slugs present in BOTH skill.content.tools[] (union across matched skills) AND a binding.content.toolSlug
    • filters OUT bindings whose content.accessClass !== "read" (Phase 1 — write tools never surfaced)
    • dedups, stable-sorts (deterministic order for a stable -t)
    • empty inputs / no overlap → []
    • never throws
it("intersects skill tools with read-class bindings only", () => {
const skills = [{content:{tools:["order_lookup","refund_issue"]}}] as any;
const bindings = [
{content:{toolSlug:"order_lookup", accessClass:"read"}},
{content:{toolSlug:"refund_issue", accessClass:"write"}}, // dropped (write)
{content:{toolSlug:"unused", accessClass:"read"}}, // not in any skill
] as any;
expect(intersectReadTools(skills, bindings)).toEqual(["order_lookup"]);
});
  • Step 2: Run → fail.
  • Step 3: Implement the pure function (no DB). Union skill tools → Set; filter bindings to accessClass==="read"; keep binding slugs in the skill set; dedup + sort. Never throws.
  • Step 4: Run → pass.
  • Step 5: Commitfeat(conversations): read-tool intersection util (P4.7 P1)

Task 3: mcp kind + accessClass plumbing prep (NestJS, no behaviour change)

Files: Modify api/src/config-assets/schemas/tool-binding.schema.ts; Modify api/test/*tool-binding.schema*.spec.ts

  • Step 1: Failing test — assert ToolBindingContent now accepts kind: "mcp" (P4.6 absorbed) alongside bespoke|nango, and still rejects unknown kinds. Keep all existing schema assertions green.
  • Step 2: Run → fail (mcp currently rejected).
  • Step 3: Add "mcp" to the kind enum in tool-binding.schema.ts:15. Update the comment that said "no mcp (P4.6 addition absent from code)" to note P4.7 P1 added it. Do NOT change accessClass (already read|write).
  • Step 4: Run → pass (schema + any registry/golden assertion referencing kinds).
  • Step 5: Commitfeat(config-assets): add mcp to tool_binding kind (P4.6 absorbed into P4.7 P1)

Task 4: stdio Humanwork MCP server (agent, greenfield)

Files: Create agent/humanwork_mcp_server.py; Test agent/tests/test_humanwork_mcp_server.py

  • Step 1: Read the reuse targets + confirm the MCP SDK. agent/tool_gateway_client.py::execute_tool_via_gateway signature + return shape; agent/platform_auth.py::platform_api_token. VERIFIED MCP-SDK situation (coordinator, 2026-07-09): the agent requirements do NOT currently include an MCP SDK, and Hermes itself is the MCP client (~/.hermes/hermes-agent/tools/mcp_tool.py:119 uses "the MCP SDK's stdio_client"; its example servers at :17,:27 are command: "npx" = Node servers). So our Python stdio server needs the mcp PyPI package's SERVER side (mcp.server / the FastMCP helper), which is not yet a dep. Task 4 therefore ADDS mcp to agent/requirements*.txt (pin a version compatible with Hermes's client — check mcp_tool.py imports / hermes's own pin for the compatible major). Read the installed mcp package's server API before coding the handler (the server-side decorator/registration API differs across mcp versions). Do NOT hand-roll JSON-RPC framing — use the SDK. If pinning a compatible mcp version proves impossible (Hermes client incompatibility), STOP and report — the fallback is an HTTP-transport MCP server (Hermes supports url: transport per mcp_tool.py:5), which changes deploy shape (Phase-1 decision would return to the user).

  • Step 2: Failing test — the handler logic is unit-testable WITHOUT Hermes: given a tool name + action + params, it calls execute_tool_via_gateway (mocked) with the right ExecuteToolDto fields and returns the gateway's result as the MCP tool result. Test: (a) a read tool forwards orgId/runId/toolName/action/params/correlationId correctly, (b) gateway 403→blocked is surfaced as a tool error not a crash, (c) the server advertises exactly the tools it's configured with.

# agent/tests/test_humanwork_mcp_server.py
def test_handler_forwards_to_gateway(monkeypatch):
calls = {}
async def fake_exec(**kw): calls.update(kw); return {"status":"success","result":{"ok":True}}
monkeypatch.setattr("humanwork_mcp_server.execute_tool_via_gateway", fake_exec)
# invoke the handler for tool "order_lookup" action "get" params {...}
# assert calls has orgId/runId/toolName="order_lookup"/action="get"/params/correlationId
# assert result surfaces {"ok": True}
  • Step 3: Implement agent/humanwork_mcp_server.py — a stdio MCP server that:

    • reads its tool set + orgId/runId context from env (injected by Hermes subprocess env: HUMANWORK_ORG_ID, HUMANWORK_RUN_ID, PLATFORM_API_URL, runner token via platform_api_token()),
    • advertises one MCP tool per configured business tool (from a passed-in list — Phase 1 read tools),
    • each handler calls execute_tool_via_gateway(orgId=…, runId=…, toolName=…, action=…, params=…, correlationId=…) and returns its result (or surfaces a structured error on blocked/failed),
    • runs over stdio (the transport Hermes spawns). Keep it THIN — no business logic, no credential handling (all in the gateway). Match the confirmed MCP transport from Step 1.
  • Step 4: Run → pass. cd agent && python -m pytest tests/test_humanwork_mcp_server.py -v (use the agent venv). Also python -c "import humanwork_mcp_server" to prove it imports clean.

  • Step 5: Commitfeat(agent): thin stdio Humanwork MCP server forwarding to tool-gateway (P4.7 P1)


Task 5: Fix the -t wiring + PLATFORM_API_URL passthrough (agent)

Files: Modify agent/main.py:750-755; Modify agent/hermes_client.py (_runner_subprocess_env); Test agent/tests/ (existing hermes_client / main tests)

  • Step 1: Read agent/main.py:745-780 (the toolsets_json → HermesClient → chat block) and agent/hermes_client.py:250-360 (_runner_subprocess_env + the -t arg build at :353).

  • Step 2: Failing test — assert that when the new wire field (Task 6) carries a toolset name (e.g. ["humanwork"]), HermesClient is constructed with toolsets="humanwork" (comma-joined names) and the -t arg is ["-t", "humanwork"] — NOT a json.dumps of a manifest. And that _runner_subprocess_env includes PLATFORM_API_URL when set in the parent env.

  • Step 3: Implement:

    • main.py:752 — replace toolsets_json = json.dumps(req.tools_manifest) … with: derive the comma-separated toolset name(s) from the new wire field (Task 6's req.toolsets: list[str]), e.g. toolsets_arg = ",".join(req.toolsets) if req.toolsets else "". Drop the json.dumps(manifest) path entirely.
    • hermes_client.py _runner_subprocess_env — add PLATFORM_API_URL to the explicit passthrough (alongside the existing HUMANWORK_* allowlist at :324-327) so the spawned MCP server's tool_gateway_client has a base URL. Also pass HUMANWORK_ORG_ID/HUMANWORK_RUN_ID if the MCP server reads context from env (align with Task 4's env contract).
  • Step 4: Run → pass. cd agent && python -m pytest tests/ -k "hermes_client or toolset or chat" -v. Re-run the echo-guard / assembly regression the program relies on: python -m pytest tests/ -k "echo or assembly or chat_helpers" -v (P3.1 executor branch must not regress).

  • Step 5: Commitfix(agent): pass toolset NAME to -t (not json manifest) + PLATFORM_API_URL passthrough (P4.7 P1)


Task 6: /chat wire field (PAIRED agent + api PR)

Files: Modify api/src/common/agent.client.ts (AgentChatRequest + buildV1ChatPayload); Modify agent/main.py (ChatRequest model, :308 area); Modify agent/AGENT_API_SPEC.md; Test both sides.

  • Step 1: agent side — add the field. In agent's request model (main.py around the tools_manifest Field :308), add toolsets: list[str] = Field(default_factory=list, validation_alias=AliasChoices("toolsets","tool_names")). This is what Task 5 reads. Keep tools_manifest for backwards-compat but it's no longer the -t source.

  • Step 2: api side — send it. In agent.client.ts: add toolsets?: string[] to AgentChatRequest; in buildV1ChatPayload include toolsets: req.toolsets ?? [] in the payload. Add a test asserting the payload carries toolsets.

  • Step 3: Doc the contract. Update agent/AGENT_API_SPEC.md (the /chat request schema) + agent/CLAUDE.md (note the new field + that it's the -t source; MCP server is the carrier) + api/src/conversations/CLAUDE.md if it documents the wire.

  • Step 4: Verify both sides. api: cd api && npx jest agent.client. agent: cd agent && python -m pytest tests/ -k "chat_request or payload or toolset". Both green; wire field round-trips.

  • Step 5: Commitfeat(chat): add toolsets field to /chat wire (paired agent+api, P4.7 P1)


Task 7: Wire the intersection into /chat dispatch (NestJS, flag-gated)

Files: Modify api/src/conversations/conversations.service.ts (two .chat() sites); Add flag tool_dispatch_enabled; Test api/test/ conversations dispatch.

  • Step 1: Add flag tool_dispatch_enabled (default OFF) to feature-flags.constants.ts (FLAG_KEYS + DEFAULT_FLAG_VALUES) + the golden assertion in feature-flag.service.spec.ts. Default OFF = Phase 1 ships dark (byte-identical /chat when off).

  • Step 2: Failing test — at the main dispatch site, when tool_dispatch_enabled is ON for the org, AgentClient.chat is called with toolsets: ["humanwork"] IFF resolveSpecialistToolNames returns a non-empty read-tool intersection; when the intersection is empty OR the flag is OFF, toolsets is []/absent (tool-free, byte-identical to today). Mock getPublishedSkills + getPublishedToolBindings.

  • Step 3: Implement — a private resolveSpecialistToolNames(orgId, specialistId) on ConversationsService (or a small injected resolver) that: flag-check → re-fetch getPublishedSkills + getPublishedToolBindingsintersectReadTools(...) (Task 2) → return intersection.length ? ["humanwork"] : []. Call it at BOTH .chat() dispatch sites (main + regenerate) and pass toolsets into AgentChatRequest. Strictly flag-gated: OFF → don't call the resolver, toolsets absent (no behaviour change, no extra DB reads). Fail-open: resolver error → [] (tool-free), never break the turn.

  • Step 4: Run → pass + regression: cd api && npx jest conversations config-assets feature-flag (no regression; flag-OFF path byte-identical).

  • Step 5: Commitfeat(conversations): resolve read-tool intersection into /chat toolsets, flag-gated (P4.7 P1)


Task 8: cli-config mcp_servers injection + boot re-assert (agent deploy)

Files: Modify agent/Dockerfile; Modify agent boot/entrypoint (the CMD that re-asserts config, Dockerfile:94 area).

  • Step 1: Read agent/Dockerfile:85-95 (the HERMES_HOME=/opt/data/config.yaml + hermes plugins enable … || true boot re-assert pattern) to mirror it.

  • Step 2: Inject mcp_servers. Ensure /opt/data/config.yaml gets an mcp_servers: { humanwork: { command: "<python>", args: ["<path>/humanwork_mcp_server.py"], env: {...} } } entry. Because the volume shadows build-time config, do it in the boot CMD (re-assert every boot, idempotent, || true like the langfuse/plugins pattern) — either via a hermes mcp add-style CLI if one exists (check hermes --help), or by merging a YAML fragment into the existing config at boot with a tiny idempotent script. Do NOT clobber other config sections (github/notion/slack may be operator-added in some envs — merge, don't overwrite).

  • Step 3: Verify the injection is idempotent + non-destructive: boot twice, assert humanwork present once and any pre-existing mcp_servers keys survive. (A shell/python assertion in the build or a note for the deploy check — this is infra, hard to unit-test; document the manual verification.)

  • Step 4: Commitfeat(agent): register humanwork stdio MCP server in Hermes cli-config at boot (P4.7 P1)


Self-review checklist

  • Read-only this phase: no write-class tool ever reaches -t (Task 2 filters accessClass!=="read"); no ExpertQueueItem/approval code touched (that's Phase 2). Grep the diff for ExpertQueueItem/ApprovalRequest → should be absent.
  • Flag-OFF byte-identical: tool_dispatch_enabled OFF → resolver not called, toolsets absent, /chat payload unchanged, -t unset (tool-free = today). Verify in Task 7 test.
  • Reuse not rebuild: MCP handler wraps tool_gateway_client (no new HTTP); reader mirrors getPublishedSkills; gateway read path untouched.
  • Paired PR integrity: Task 6 changes both sides + AGENT_API_SPEC.md + CLAUDE.md together.
  • ADR-020: getPublishedToolBindings dual-scoped (org AND specialist), fail-open [].
  • Credentials never in agent/MCP: the MCP server only forwards toolName/action/params + runner token; the gateway resolves credentials server-side (unchanged).

Verification (verify skill, after all tasks)

  • api: cd api && npx tsc --noEmit clean; npx jest config-assets conversations feature-flag tool-resolution green.
  • agent: cd agent && python -m pytest tests/ -k "mcp or hermes_client or toolset or chat or echo or assembly" green (incl. echo-guard regression).
  • Live integration check (the real payoff — env permitting): with tool_dispatch_enabled ON for a test org that has a published read-class tool_binding whose toolSlug is in a matched skill's tools[], drive one /chat turn and confirm Hermes selects -t humanwork, calls the MCP tool, the MCP handler forwards to the gateway, and the gateway executes the read tool. Fallback if the full local stack is blocked (memory verification-env-gotchas schema-drift): the unit tests already prove each seam (reader, intersection, MCP handler→gateway, -t name); a narrower check is to run humanwork_mcp_server.py standalone over stdio with a mocked gateway and confirm Hermes (hermes chat -Q -t humanwork) lists + calls its tool. Document which level of verification was achieved.
  • git grep -n "accessClass" api/src — confirm the write-class filter is the only accessClass gate this phase (no premature approval logic).

What Phase 2 will add (explicitly NOT this plan)

Write-class exposure + Expert approval: accessClass=write into the intersection, ActionPolicyService.decide gains an accessClass branch, the gateway approval branch moves from ApprovalRequest to ExpertQueueItem — which needs the messageId gap resolved (AgentRun has only conversationId; ExpertQueueItem requires messageId). That gap's resolution is a Phase 2 design decision (deferred per the user's phase-split call).