#!/usr/bin/env bash # MCPmarket skill-invocation telemetry hook. # Wired as PostToolUse + PostToolUseFailure hooks with matcher "Skill" # — Claude Code's matcher filters exclusively, so this script only runs # when the Skill tool is invoked, not on every tool call. Both events # share this script because the classifier handles either shape; # without the PostToolUseFailure wiring every failed invocation would # drop on the floor. # # Reads the hook input JSON from stdin. Fires a single fire-and-forget # POST to /api/v1/plugin/skill-invocation with: # - skill_slug (from tool_input) # - source (user|agent — prefers the native `invocation_trigger` # field on the hook input; falls back on older Claude # Code versions to tailing transcript_path for # $slug. The prompt text # never leaves the user's machine — only the source # bit travels.) # - outcome (success|error — derived from tool_response shape) # - error_class (closed enum, only when outcome=error) # # Reads only: # CLAUDE_PLUGIN_ROOT plugin install dir (Claude Code populates this) # MCPMARKET_API_URL API base URL override (allowlisted, same as sync.sh) # # Credentials come from $PLUGIN_ROOT/.mcp.json — same source sync.sh # uses. No hook-shim wrapping; PostToolUse doesn't get the same env # plumbing SessionStart does, so we read directly. set -euo pipefail MCPMARKET_HOOK_VERSION="0.1.0" USER_AGENT="mcpmarket-skill-hook/${MCPMARKET_HOOK_VERSION}" # Split literal so the build-time substitution can't rewrite the # comparison along with the assignment. Mirrors sync.sh's pattern. MCPMARKET_CLIENT="claude_desktop" if [ "$MCPMARKET_CLIENT" = "__MCPMARKET""_CLIENT__" ]; then MCPMARKET_CLIENT="" fi PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-${MCPMARKET_PLUGIN_ROOT:-}}" if [ -z "$PLUGIN_ROOT" ]; then SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) PLUGIN_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) fi # Hard dependencies. Silent exit (not stderr) because hooks fire on # every skill invocation and a noisy hook would spam the user's terminal. command -v node >/dev/null 2>&1 || exit 0 command -v curl >/dev/null 2>&1 || exit 0 HOOK_INPUT=$(cat) [ -n "$HOOK_INPUT" ] || exit 0 # Parse hook input. The tool_response shape isn't formally specified # across Claude Code versions, so the error detection is permissive: # any of `is_error`, `error`, or `success === false` flags an error. # Skill slug source: tool_input.skill (canonical) or tool_input.name. # All string fields are scrubbed of separator characters (\t/\n/\x00/\x1F) # so a forged value can't smuggle extra positional fields. \x1F (Unit # Separator) is used as the inter-field delimiter — see sync.sh for the # rationale (tab is IFS-whitespace and bash `read` compacts consecutive # tabs, so an empty middle field would shift later fields left). FIELDS=$(HOOK_JSON="$HOOK_INPUT" node -e ' const UNSAFE = /[\t\n\x00\x1F]/; const clean = (s) => (typeof s === "string" && !UNSAFE.test(s) ? s : ""); try { const h = JSON.parse(process.env.HOOK_JSON); const toolName = clean(h.tool_name); const ti = h.tool_input || {}; const skill = clean(ti.skill || ti.name || ""); const transcript = clean(h.transcript_path || ""); // `invocation_trigger` is emitted by Claude Code alongside the // `claude_code.skill_activated` OTel event and is the formal source // of truth for whether the skill came from a user slash command, an // autonomous agent decision, or a nested skill invocation. Older // Claude Code versions do not populate it — the bash side falls // back to the transcript-tail heuristic when this field is empty. const trigger = clean(h.invocation_trigger || ""); let outcome = "success"; let errorClass = ""; // Hook input may carry the failure signal in two places: // - PostToolUse with error: tool_response = { is_error: true, error/message/content: "..." } // - PostToolUseFailure: top-level `error` string, no tool_response // Either is treated as a failure; the message source is whichever is // present (tool_response fields preferred when both exist). The // classifier vocabulary is identical so analytics queries do not have // to split on event source. const classify = (raw) => { const s = String(raw || "").toLowerCase(); if (s.includes("not found") || s.includes("unknown skill")) return "not_found"; if (s.includes("timeout") || s.includes("timed out")) return "timeout"; if (s) return "runtime_error"; // Errored but no extractable message — shape drifted; route to // `unknown` so the regression surfaces in analytics. return "unknown"; }; const resp = h.tool_response; const topError = typeof h.error === "string" ? h.error : ""; if (resp && typeof resp === "object") { const errored = resp.is_error === true || resp.success === false || !!resp.error; if (errored) { outcome = "error"; errorClass = classify(resp.error || resp.message || resp.content || topError); } } else if (topError) { // PostToolUseFailure shape: no tool_response, top-level error string. outcome = "error"; errorClass = classify(topError); } else { // No tool_response AND no top-level error — either Claude Code // crashed before producing any failure signal, or the hook-input // shape drifted in a release. Record as error/unknown rather than // silently rolling up as success. outcome = "error"; errorClass = "unknown"; } process.stdout.write([toolName, skill, transcript, trigger, outcome, errorClass].join("\x1F")); } catch { process.exit(1); } ') || exit 0 IFS=$'\x1F' read -r TOOL_NAME SKILL_SLUG TRANSCRIPT_PATH INVOCATION_TRIGGER OUTCOME ERROR_CLASS <<<"$FIELDS" # Defence-in-depth: matcher should already restrict to Skill, but a # misconfigured hooks.json shouldn't let arbitrary tool calls fire # telemetry under the skill_invocation event type. [ "$TOOL_NAME" = "Skill" ] || exit 0 [ -n "$SKILL_SLUG" ] || exit 0 # Server-side validator rejects malformed slugs; reject early so we # don't waste a network round-trip on input we know will 400. Server # `slugSchema` requires `.min(2)`, so the bash guard enforces the same # two-character floor — a single-char slug would pass `[a-z0-9]$` but # the route would 400 it anyway. if ! echo "$SKILL_SLUG" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$'; then exit 0 fi # Read credentials + toolkit URL from .mcp.json. Same parser sync.sh # uses; each plugin's .mcp.json holds exactly one server keyed by the # per-toolkit plugin name (`mcpmarket-`), so we read the only key # under `mcpServers` rather than hardcoding the key. The \x1F separator # preserves an empty leading field. [ -f "$PLUGIN_ROOT/.mcp.json" ] || exit 0 CREDS=$(MCP_CONFIG_PATH="$PLUGIN_ROOT/.mcp.json" node -e ' try { const cfg = JSON.parse(require("fs").readFileSync(process.env.MCP_CONFIG_PATH, "utf8")); const servers = (cfg && cfg.mcpServers) || {}; const keys = Object.keys(servers); const s = keys.length === 1 ? servers[keys[0]] : {}; const url = s.url || ""; const auth = (s.http_headers && s.http_headers.Authorization) || (s.headers && s.headers.Authorization) || ""; process.stdout.write(url + "\x1F" + auth); } catch { process.stdout.write("\x1F"); } ') || exit 0 IFS=$'\x1F' read -r TOOLKIT_URL MCP_AUTH <<<"$CREDS" API_TOKEN="${MCP_AUTH#Bearer }" [ -n "$TOOLKIT_URL" ] || exit 0 [ -n "$API_TOKEN" ] || exit 0 # Parse org/toolkit slugs. Same logic and validation as sync.sh — a # malformed URL exits silently rather than POSTing junk. URL_PATH=$(echo "$TOOLKIT_URL" | sed -E 's|https?://[^/]*/||; s|/mcp$||') ORG_SLUG=$(echo "$URL_PATH" | cut -d'/' -f1) TOOLKIT_SLUG=$(echo "$URL_PATH" | cut -d'/' -f3) [ -n "$ORG_SLUG" ] && [ -n "$TOOLKIT_SLUG" ] || exit 0 # Two-char floor matches server `slugSchema.min(2)` — a single-char # slug would pass and then be rejected with a 400. echo "$ORG_SLUG" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$' || exit 0 echo "$TOOLKIT_SLUG" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$' || exit 0 # API base URL allowlist — same parser as sync.sh so dev overrides # (localhost / *.mcpmarket.com) match the success-path's behaviour. API_BASE_URL="${MCPMARKET_API_URL:-https://app.mcpmarket.com}" API_SCHEME="" API_HOST="" case "$API_BASE_URL" in https://*) API_SCHEME=https; API_HOST="${API_BASE_URL#https://}"; API_HOST="${API_HOST%%/*}" ;; http://*) API_SCHEME=http; API_HOST="${API_BASE_URL#http://}"; API_HOST="${API_HOST%%/*}" ;; esac case "$API_HOST" in *[!a-zA-Z0-9.:-]*) API_HOST="" ;; *:*:*) API_HOST="" ;; esac API_ALLOWED=false if [ "$API_SCHEME" = "https" ]; then case "$API_HOST" in app.mcpmarket.com|*.mcpmarket.com) API_ALLOWED=true ;; esac elif [ "$API_SCHEME" = "http" ]; then case "$API_HOST" in localhost|localhost:*|127.0.0.1|127.0.0.1:*) API_ALLOWED=true ;; esac fi [ "$API_ALLOWED" = "true" ] || exit 0 # Source attribution. Prefer Claude Code's native `invocation_trigger` # field (emitted alongside the `claude_code.skill_activated` OTel event) # when present — it's the formal source of truth and avoids the # transcript-tail heuristic's misattribution of the second-and-onward # agent re-invocation of the same skill in one user turn. On older # Claude Code versions the field is absent; fall back to tailing the # JSONL transcript for a `$SKILL_SLUG` tag in the most # recent user message. The prompt text is consumed locally and # discarded — only the source bit travels. # # The trigger string literals below are a Claude Code wire-format # contract. The same three values appear in two test sites that lock # the mapping; keep all three in sync when Claude Code adds a new # trigger value: # - apps/web/lib/__tests__/plugin-source.test.ts (case-arm regex) # - apps/web/lib/plugin-template/__tests__/skill-invocation.test.ts (it.each table) SOURCE="agent" case "$INVOCATION_TRIGGER" in user-slash) SOURCE="user" ;; claude-proactive|nested-skill) SOURCE="agent" ;; *) if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then LAST_USER=$(tail -n 50 "$TRANSCRIPT_PATH" 2>/dev/null | TARGET_SLUG="$SKILL_SLUG" node -e ' const target = process.env.TARGET_SLUG; const lines = require("fs").readFileSync(0, "utf8").split("\n"); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (!line) continue; let obj; try { obj = JSON.parse(line); } catch { continue; } const role = obj.role || (obj.message && obj.message.role) || obj.type; if (role !== "user") continue; const content = obj.message ? obj.message.content : obj.content; const text = typeof content === "string" ? content : JSON.stringify(content || ""); if (text.includes("" + target + "")) { process.stdout.write("user"); } process.exit(0); } ' 2>/dev/null || true) if [ "$LAST_USER" = "user" ]; then SOURCE="user" fi fi ;; esac # Build JSON payload. Inline interpolation is safe: every field above # has been regex-validated (skill/org/toolkit slugs) or is sourced from # a closed enum (source, outcome, error_class, client). INVOCATION_URL="${API_BASE_URL}/api/v1/plugin/skill-invocation" # Build a single body via node to avoid bash-printf escape pitfalls # when conditionally including errorClass and client. PAYLOAD=$(SKILL="$SKILL_SLUG" ORG="$ORG_SLUG" TK="$TOOLKIT_SLUG" \ SRC="$SOURCE" OUT="$OUTCOME" ERR="$ERROR_CLASS" CLIENT="$MCPMARKET_CLIENT" \ node -e ' const body = { skillSlug: process.env.SKILL, orgSlug: process.env.ORG, toolkitSlug: process.env.TK, source: process.env.SRC, outcome: process.env.OUT, }; if (process.env.OUT === "error" && process.env.ERR) body.errorClass = process.env.ERR; if (process.env.CLIENT) body.client = process.env.CLIENT; process.stdout.write(JSON.stringify(body)); ') || exit 0 CLIENT_HEADER_ARGS=() if [ -n "$MCPMARKET_CLIENT" ]; then CLIENT_HEADER_ARGS=(-H "X-MCPmarket-Client: $MCPMARKET_CLIENT") fi curl -sS --max-time 3 -X POST \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -H "User-Agent: $USER_AGENT" \ ${CLIENT_HEADER_ARGS[@]+"${CLIENT_HEADER_ARGS[@]}"} \ -d "$PAYLOAD" \ "$INVOCATION_URL" >/dev/null 2>&1 || true exit 0