anthropics / anthropics/claude-agent-sdk-python

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

Ouverte
#781 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub
Langage dominant
Python
Étoiles
8.1k
Forks
1.3k
Merge moyen
2 j 31 min
PR mergées (30 j)
1

Description

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.

Guide de contribution

Aucun guide de contribution indexé pour ce dépôt

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.