-
Notifications
You must be signed in to change notification settings - Fork 503
Description
The Ask
Provide a return path from MCP tools to event handlers that does not enter model context.
MCP's _meta field is designed for exactly this. Exposing it in ToolResultBlock would solve it.
Problem
MCP tools often need to return two types of data:
| Data Type | Destination | Example |
|---|---|---|
| For model | Model context | "Found 3 matching records" |
| For UI/handlers | Event handlers only | Record IDs, metadata, pagination cursors |
Currently, everything in the tool return enters model context. There's no way to say "this part is for handlers, not the model."
The only options today:
| Approach | Reaches Handlers | Enters Model Context |
|---|---|---|
Put in content |
✓ | ✓ (unwanted) |
Put in _meta |
✗ (stripped by SDK) | ✗ |
| Build custom callback | ✓ | ✗ |
To avoid polluting model context, we're forced to build out-of-band callback infrastructure for each tool that needs UI-only data.
What We Have To Do Today
# 1. Define a notifier class
class DataNotifier:
def __init__(self):
self._callback = None
def set_callback(self, cb):
self._callback = cb
def notify(self, data):
if self._callback:
self._callback(data)
# 2. Create module-level singleton
_notifier = DataNotifier()
# 3. Tool fires callback BEFORE return (before SDK processes)
async def my_tool(args):
result = do_work(args)
_notifier.notify(result["ui_data"]) # Side-channel to handler
return {"content": [{"type": "text", "text": "Done"}]}
# 4. Wire callback during app initialization
notifier.set_callback(handler.on_ui_data)
# 5. Handler receives via callback
def on_ui_data(self, data):
self.panel.update(data)This pattern must be repeated for every tool that needs handler-only data.
Why Not Just Use content?
We can put UI data in content and parse it in handlers. But:
- Token waste - Model receives data it doesn't need
- Context pollution - Model sees IDs, cursors, metadata meant for UI
- Semantic confusion - Model may try to interpret UI-specific data structures
MCP designed _meta specifically for "hidden data for client applications only, not visible to the model."
Proposed Solution
Expose _meta in ToolResultBlock:
@dataclass
class ToolResultBlock:
tool_use_id: str
content: str | List[Dict[str, Any]] | None = None
is_error: bool | None = None
meta: Dict[str, Any] | None = None # NEWTools return:
return {
"content": [{"type": "text", "text": "Recalled 3 observations"}],
"_meta": {"observation_ids": ["obs_1", "obs_2", "obs_3"]}
}Handlers receive:
def on_tool_complete(self, block: ToolResultBlock):
if block.meta:
self.highlight_items(block.meta["observation_ids"])- Model sees:
"Recalled 3 observations" - Handler gets:
{"observation_ids": ["obs_1", "obs_2", "obs_3"]}
Use Cases
| Use Case | Model Sees | Handler Gets (via _meta) |
|---|---|---|
| Knowledge retrieval | "Found 3 relevant items" | Item IDs for UI highlighting |
| Search | "Results for 'query'" | Facets, pagination cursor, result IDs |
| File operations | "Uploaded file.txt" | Progress %, bytes transferred |
| Database query | Query results | Execution plan, timing stats |
| API calls | Response data | Rate limits, request ID, headers |
Related
anthropics/claude-code#9767 addresses MCP events/notifications not reaching the LLM for multi-turn state. This request is complementary - we need a channel that reaches event handlers but bypasses model context, for UI-only data that shouldn't consume tokens.
Benefit
- Eliminates custom callback machinery for every UI-data use case
- Aligns SDK with MCP specification intent for
_meta - Natural pattern for synchronous tool calls
- Backwards compatible (new optional field)