JuliusBrussee/caveman · error

tools must be unique caveman_* names

Error message

tools must be unique caveman_* names

What it means

Thrown inside loadSurfaces() in skills/verbs-gate.mjs when the engine-mcp-tools.json tools array is non-empty-safe: it rejects an empty array, duplicate names, or any entry that is not a string matching /^caveman_[a-z_]+$/. These rules keep the MCP tool namespace uniform (lowercase snake-case prefixed with caveman_) so verb collision checks downstream are reliable. The throw is caught and reported as `engine-mcp-tools.json: tools must be unique caveman_* names` in the gate error list.

Source

Thrown at skills/verbs-gate.mjs:81

  const cloudVerbs = discoveryVerbs(indexSrc, "const CLOUD_DISCOVERY");
  if (toolVerbs.size === 0) errors.push("could not parse TOOL_DISCOVERY verbs from CLI index.ts");
  if (cloudVerbs.size === 0) errors.push("could not parse CLOUD_DISCOVERY verbs from CLI index.ts");

  // MCP tool names: engine server (Go) + agent-native server (TS).
  const mcpTools = new Set();
  let declaredEngineTools = [];
  try {
    const manifest = JSON.parse(readFileSync(join(skillsDir, "engine-mcp-tools.json"), "utf8"));
    if (manifest.schema_version !== "1" || !Array.isArray(manifest.tools)) {
      throw new Error('schema_version must be "1" and tools must be an array');
    }
    declaredEngineTools = [...manifest.tools];
    if (
      declaredEngineTools.length === 0
      || new Set(declaredEngineTools).size !== declaredEngineTools.length
      || declaredEngineTools.some((name) => typeof name !== "string" || !/^caveman_[a-z_]+$/.test(name))
    ) {
      throw new Error("tools must be unique caveman_* names");
    }
    for (const name of declaredEngineTools) mcpTools.add(name);
  } catch (error) {
    errors.push(`engine-mcp-tools.json: ${error.message}`);
  }
  const engineSrc = readOptional(join(skillsDir, "..", "mcp", "engine_tools.go"));
  const sourceEngineTools = [...engineSrc.matchAll(/Tool\w+\s*=\s*"(caveman_[a-z_]+)"/g)].map((match) => match[1]);
  if (sourceEngineTools.length > 0) {
    const declared = [...declaredEngineTools].sort();
    const source = [...new Set(sourceEngineTools)].sort();
    if (JSON.stringify(declared) !== JSON.stringify(source)) {
      errors.push("engine-mcp-tools.json drifted from public/mcp/engine_tools.go");
    }
  }
  const agentMcpSrc = readOptional(join(cliDir, "src", "agent-mcp.ts"));
  for (const m of agentMcpSrc.matchAll(/name:\s*"(caveman_[a-z_]+)"/g)) mcpTools.add(m[1]);
  if (mcpTools.size === 0) errors.push("could not parse MCP tool names");

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Make every entry a unique string matching caveman_[a-z_]+ (e.g. "caveman_tool_search") and ensure at least one entry exists.
  2. Remove duplicates introduced by merges; sort the array to make future duplicates obvious.
  3. Cross-check against public/mcp/engine_tools.go — the gate compares the manifest against `Tool\w+ = "caveman_..."` constants and reports drift.

Example fix

// before
{ "schema_version": "1", "tools": ["caveman_compress", "caveman_compress", "Compress"] }
// after
{ "schema_version": "1", "tools": ["caveman_compress", "caveman_expand"] }
Defensive patterns

Strategy: type-guard

Type guard

function hasValidToolNames(doc) {
  return Array.isArray(doc.tools) && doc.tools.length > 0
    && new Set(doc.tools).size === doc.tools.length
    && doc.tools.every((n) => typeof n === "string" && /^caveman_[a-z_]+$/.test(n));
}

Prevention

When it happens

Trigger: A manifest with zero tools; the same name listed twice after a merge; a name like "Compress", "caveman-compress", "caveman_WebSearch", or a non-string entry (number/object).

Common situations: Merge conflicts duplicating entries; hand-adding a tool with camelCase or a hyphen; generator emitting the raw tool name without the caveman_ prefix.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/194ac2816b0bf206. Report an issue: GitHub.