Skip to content

Title: PostToolUse hook updatedMCPToolOutput causes H.reduce is not a function in renderer despite correct hook output #781

@Zapix

Description

@Zapix

Environment

Package: claude-agent-sdk
Version: latest (March 2026, v0.1.48)
Python: 3.10+
Async runtime: anyio
Platform: Claude.ai (web/mobile renderer)

Description
When a PostToolUse hook returns a valid updatedMCPToolOutput, the python script crashes with:
H.reduce is not a function.
(In 'H.reduce((,$)=>+($.type==="text"?$.text.length:0),0)', 'H.reduce' is undefined)
This happens across all tools registered on the same MCP server simultaneously, despite the hook executing correctly and returning a properly shaped content array. The Python-side hook logs confirm all three tools normalize and return valid output — the crash originates in the JavaScript rendering layer.

Python code:

import anyio
from datetime import datetime
import json
from typing import Any
from dotenv import load_dotenv
from claude_agent_sdk import (
    ClaudeAgentOptions,
    AssistantMessage,
    TextBlock,
    ClaudeSDKClient,
    HookMatcher,
    HookContext,
    tool,
    create_sdk_mcp_server,
)


DEFAULT_MODEL = "claude-haiku-4-5"


@tool(
    "handler_a",
    "Handle A function that returns data",
    {"type": "object", "properties": {}},
)
async def handler_a(args):
    return {
        "content": [
            {
                "type": "text",
                "text": json.dumps({"status": "S", "date": "06/01/2024"}),
            }
        ]
    }


@tool(
    "handler_b",
    "Handle B function that returns data",
    {"type": "object", "properties": {}},
)
async def handler_b(args):
    return {
        "content": [
            {
                "type": "text",
                "text": json.dumps({"status": 200, "date": datetime.now().timestamp()}),
            }
        ]
    }


@tool(
    "handler_c",
    "Handle C function that returns data",
    {"type": "object", "properties": {}},
)
async def handler_c(args):
    return {
        "content": [
            {
                "type": "text",
                "text": json.dumps(
                    {"status": "success", "date": "2022-09-27T18:00:00"}
                ),
            }
        ]
    }


def convert_status_letter_to_status(status_letter: str) -> str:
    mapping = {"S": "success", "F": "failure", "P": "pending"}
    return mapping.get(status_letter, "unknown")


def get_normalized_status(status: Any) -> str:
    print("[DEBUG] Normalizing status: ", status)
    if isinstance(status, int):
        if status == 200:
            return "success"
        else:
            return "failure"
    elif isinstance(status, str):
        if status in ["success", "failure", "pending"]:
            return status
        else:
            return convert_status_letter_to_status(status)
    else:
        return "unknown"


def get_normalized_date(date: Any) -> str:
    print("[DEBUG] Normalizing date: ", date)
    if isinstance(date, int) or isinstance(date, float):
        return datetime.fromtimestamp(date).isoformat()
    elif isinstance(date, str):
        try:
            return datetime.strptime(date, "%d/%m/%Y").isoformat()
        except ValueError:
            try:
                return datetime.fromisoformat(date).isoformat()
            except ValueError:
                return "unknown"
    else:
        return "unknown"


async def post_use_handler_hook(
    input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
) -> dict[str, Any]:
    tool_name = input_data.get("tool_name")
    tool_response = input_data.get("tool_response")
    if isinstance(tool_response, list):
        content = tool_response  # bare list — your runtime
    elif isinstance(tool_response, dict):
        content = tool_response.get("content", [])  # wrapped dict — docs version
    else:
        return {}

    if not content:
        return {}

    data = json.loads(content[0].get("text", "{}"))
    print(f"[post_use_handler_hook] Lookup handler called after {tool_name} is used")
    print(f"[post_use_handler_hook] Response data: ", data)
    status = get_normalized_status(data.get("status"))
    date = get_normalized_date(data.get("date"))
    print(
        f"[post_use_handler_hook] Normalized status: {status}, Normalized date: {date}"
    )
    dump_data = json.dumps({"status": status, "date": date})
    print(f"[post_use_handler_hook] Dumping normalized data: {dump_data}")

    return {
        "hookSpecificOutput": {
            "hookEventName": "PostToolUse",
            "updatedMCPToolOutput": {
                "content": [
                    {
                        "type": "text",
                        "text": dump_data,
                    }
                ]
            },
        }
    }


server = create_sdk_mcp_server(
    name="customer-support-mcp",
    version="1.0.0",
    tools=[handler_a, handler_b, handler_c],
)


async def main():
    options = ClaudeAgentOptions(
        model=DEFAULT_MODEL,
        mcp_servers={"tools": server},
        allowed_tools=[
            "mcp__tools__handler_a",
            "mcp__tools__handler_b",
            "mcp__tools__handler_c",
        ],
        hooks={
            "PostToolUse": [
                HookMatcher(
                    matcher="mcp__tools__handler_a|mcp__tools__handler_b|mcp__tools__handler_c",
                    hooks=[post_use_handler_hook],
                )
            ],
        },
    )
    async with ClaudeSDKClient(options) as client:
        await client.query(
            prompt="Collect information from the three handlers summarize and provide results."
        )
        async for msg in client.receive_response():
            if isinstance(msg, AssistantMessage):
                for block in msg.content:
                    if isinstance(block, TextBlock):
                        print(block.text)


if __name__ == "__main__":
    load_dotenv()
    anyio.run(main)

Actual Behavior

Python-side hook logs confirm correct execution:

[post_use_handler_hook] Lookup handler called after mcp__tools__handler_a is used
[post_use_handler_hook] Response data:  {'status': 'S', 'date': '06/01/2024'}
[post_use_handler_hook] Normalized status: success, Normalized date: 2024-01-06T00:00:00
[post_use_handler_hook] Dumping normalized data: {"status": "success", "date": "2024-01-06T00:00:00"}

[post_use_handler_hook] Lookup handler called after mcp__tools__handler_b is used
[post_use_handler_hook] Response data:  {'status': 200, 'date': 1774904901.024368}
[post_use_handler_hook] Normalized status: success, Normalized date: 2026-03-30T23:08:21.024368
[post_use_handler_hook] Dumping normalized data: {"status": "success", "date": "2026-03-30T23:08:21.024368"}

[post_use_handler_hook] Lookup handler called after mcp__tools__handler_c is used
[post_use_handler_hook] Response data:  {'status': 'success', 'date': '2022-09-27T18:00:00'}
[post_use_handler_hook] Normalized status: success, Normalized date: 2022-09-27T18:00:00
[post_use_handler_hook] Dumping normalized data: {"status": "success", "date": "2022-09-27T18:00:00"}

Renderer crashes with:

H.reduce is not a function.
(In 'H.reduce((_,$)=>_+($.type==="text"?$.text.length:0),0)', 'H.reduce' is undefined)
All three handlers reported as failed in the UI, with the message:

The handlers are attempting to use a .reduce() method on an object or variable named H, but that object/variable either doesn't exist or doesn't have a reduce method available.


Expected Behavior

The hook returns a valid updatedMCPToolOutput with a properly shaped content array
The renderer receives content as a JavaScript array and calls .reduce() successfully
All three tool results display the normalized status and date values
The assistant message is not truncated


Additional Findings
1. tool_response structure inconsistency
The official SDK documentation states tool_response in PostToolUseHookInput is:
python{"content": [{"type": "text", "text": "..."}]}  # dict with content key
But at runtime the SDK delivers it as a bare list:
python[{"type": "text", "text": "..."}]  # list directly
This required a defensive isinstance check to handle both shapes. The docs and runtime are inconsistent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions