Skip to content

Feature Request: Expose MCP _meta field in ToolResultBlock for handler-only data #407

@santonakakis

Description

@santonakakis

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:

  1. Token waste - Model receives data it doesn't need
  2. Context pollution - Model sees IDs, cursors, metadata meant for UI
  3. 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  # NEW

Tools 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions