Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion everyrow-mcp/src/everyrow_mcp/result_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ def _build_result_response(
mcp_server_url: str = "",
*,
requested_page_size: int | None = None,
skip_widget: bool = False,
) -> list[TextContent]:
"""Build MCP TextContent response for Redis-backed results.

Expand All @@ -124,8 +125,19 @@ def _build_result_response(
# Alternative: track a per-task call counter in Redis and only emit on
# the first call. Rejected because it adds state, and re-fetching
# offset=0 (e.g. "show me the results again") should show the widget.
# Widget JSON is only useful for clients that can render iframes
# (Claude.ai, Claude Desktop). Clients like Claude Code don't render
# widgets, so the JSON just wastes context tokens.
#
# Detection uses a two-tier whitelist (see tool_helpers.client_supports_widgets):
# 1. MCP Apps UI capability — clients that advertise
# experimental["io.modelcontextprotocol/ui"] explicitly support widgets.
# 2. Name-based whitelist — Claude.ai/Desktop don't advertise the
# capability yet, so we whitelist known widget-capable client names.
# Unknown clients default to NO widget (saves context tokens).
# This fallback should be removed once clients adopt the capability.
contents: list[TextContent] = []
if offset == 0:
if offset == 0 and not skip_widget:
widget_data: dict[str, Any] = {
"csv_url": csv_url,
"preview": preview_records,
Expand Down Expand Up @@ -196,6 +208,8 @@ async def try_cached_result(
offset: int,
page_size: int,
mcp_server_url: str = "",
*,
skip_widget: bool = False,
) -> list[TextContent] | None:
cached_meta_raw = await redis_store.get_result_meta(task_id)
if not cached_meta_raw:
Expand Down Expand Up @@ -250,6 +264,7 @@ async def try_cached_result(
poll_token=poll_token or "",
mcp_server_url=mcp_server_url,
requested_page_size=page_size,
skip_widget=skip_widget,
)


Expand All @@ -260,6 +275,8 @@ async def try_store_result(
page_size: int,
session_url: str = "",
mcp_server_url: str = "",
*,
skip_widget: bool = False,
) -> list[TextContent]:
"""Store a DataFrame in Redis and return a paginated response."""
try:
Expand Down Expand Up @@ -309,6 +326,7 @@ async def try_store_result(
poll_token=poll_token or "",
mcp_server_url=mcp_server_url,
requested_page_size=page_size,
skip_widget=skip_widget,
)
except Exception:
logger.exception("Failed to store results in Redis for task %s", task_id)
Expand Down
74 changes: 74 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,80 @@ def _get_client(ctx: EveryRowContext) -> AuthenticatedClient:
return ctx.request_context.lifespan_context.client_factory()


def log_client_info(ctx: EveryRowContext, tool_name: str) -> None:
"""Log MCP client identity and capabilities for the current request."""
try:
cp = ctx.session.client_params
if not cp:
logger.info("[%s] client_params=None", tool_name)
return
name = cp.clientInfo.name if cp.clientInfo else "unknown"
version = cp.clientInfo.version if cp.clientInfo else "unknown"
caps = cp.capabilities
experimental = (caps.experimental or {}) if caps else {}
logger.info(
"[%s] client=%s/%s sampling=%s elicitation=%s roots=%s ui=%s",
tool_name,
name,
version,
caps.sampling is not None if caps else False,
caps.elicitation is not None if caps else False,
caps.roots is not None if caps else False,
experimental.get("io.modelcontextprotocol/ui") is not None,
)
except Exception:
logger.debug("Could not log client info for %s", tool_name, exc_info=True)


def client_supports_widgets(ctx: EveryRowContext) -> bool:
"""Return True if the connected MCP client can render widgets.

Uses a two-tier whitelist approach:

1. **MCP Apps UI capability** (spec-recommended, future-proof):
Clients that advertise ``experimental["io.modelcontextprotocol/ui"]``
explicitly support widget rendering. This is the long-term signal.

2. **Name-based whitelist** (pragmatic fallback):
Claude.ai and Claude Desktop can render widgets but don't yet
advertise the UI capability. We maintain a whitelist of known
widget-capable client names so they get widgets today.
This fallback should be removed once clients adopt the capability.

Unknown clients default to **no widget** — this is intentional.
Widget JSON is ~500-2000 tokens; sending it to a client that can't
render it (e.g. Claude Code, third-party MCP clients) wastes context
for no benefit.
"""
try:
cp = ctx.session.client_params
if not cp:
return False

# Tier 1: explicit UI capability (preferred, spec-recommended)
caps = cp.capabilities
if caps:
experimental = caps.experimental or {}
if experimental.get("io.modelcontextprotocol/ui") is not None:
return True

# Tier 2: name-based whitelist for known widget-capable clients
# that don't yet advertise the UI capability.
# Update this set as new clients are verified via log_client_info().
# Known values (from log_client_info, Feb 2026):
# Claude.ai: "Anthropic/ClaudeAI" (version "1.0.0")
# Claude Desktop: "Anthropic/ClaudeAI" (version "1.0.0") — same as Claude.ai
# Claude Code: "claude-code"
# Note: Claude.ai and Claude Desktop report the same clientInfo.name,
# so a single whitelist entry covers both.
_WIDGET_CAPABLE_CLIENTS = {"anthropic/claudeai"}
name = (cp.clientInfo.name or "").lower() if cp.clientInfo else ""
return name in _WIDGET_CAPABLE_CLIENTS
except Exception:
logger.debug("Could not determine widget support", exc_info=True)
return False # unknown client — skip widget to save context tokens


def _submission_text(
label: str, session_url: str, task_id: str, session_id: str = ""
) -> str:
Expand Down
16 changes: 16 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@
TaskState,
_fetch_task_result,
_get_client,
client_supports_widgets,
create_tool_response,
log_client_info,
write_initial_task_state,
)
from everyrow_mcp.utils import fetch_csv_from_url, is_url, save_result_to_csv
Expand Down Expand Up @@ -131,6 +133,7 @@ async def everyrow_agent(params: AgentInput, ctx: EveryRowContext) -> list[TextC
params.task,
len(params.data) if params.data else "artifact",
)
log_client_info(ctx, "everyrow_agent")
client = _get_client(ctx)

_clear_task_state()
Expand Down Expand Up @@ -207,6 +210,7 @@ async def everyrow_single_agent(
Once the task is completed, call everyrow_results to save the output.
"""
logger.info("everyrow_single_agent: task=%.80s", params.task)
log_client_info(ctx, "everyrow_single_agent")
client = _get_client(ctx)

_clear_task_state()
Expand Down Expand Up @@ -293,6 +297,7 @@ async def everyrow_rank(params: RankInput, ctx: EveryRowContext) -> list[TextCon
params.task,
len(params.data) if params.data else "artifact",
)
log_client_info(ctx, "everyrow_rank")
client = _get_client(ctx)

_clear_task_state()
Expand Down Expand Up @@ -386,6 +391,7 @@ async def everyrow_screen(
params.task,
len(params.data) if params.data else "artifact",
)
log_client_info(ctx, "everyrow_screen")
client = _get_client(ctx)

_clear_task_state()
Expand Down Expand Up @@ -472,6 +478,7 @@ async def everyrow_dedupe(
params.equivalence_relation,
len(params.data) if params.data else "artifact",
)
log_client_info(ctx, "everyrow_dedupe")
client = _get_client(ctx)
_clear_task_state()

Expand Down Expand Up @@ -563,6 +570,7 @@ async def everyrow_merge(params: MergeInput, ctx: EveryRowContext) -> list[TextC
len(params.left_data) if params.left_data else "artifact",
len(params.right_data) if params.right_data else "artifact",
)
log_client_info(ctx, "everyrow_merge")
client = _get_client(ctx)
_clear_task_state()

Expand Down Expand Up @@ -648,6 +656,7 @@ async def everyrow_forecast(
params.context or "",
len(params.data) if params.data else "artifact",
)
log_client_info(ctx, "everyrow_forecast")
client = _get_client(ctx)

_clear_task_state()
Expand Down Expand Up @@ -719,6 +728,7 @@ async def everyrow_upload_data(
across multiple tool calls.
"""
logger.info("everyrow_upload_data: source=%.80s", params.source)
log_client_info(ctx, "everyrow_upload_data")
client = _get_client(ctx)

if is_url(params.source):
Expand Down Expand Up @@ -874,6 +884,8 @@ async def everyrow_results_http(
client = _get_client(ctx)
task_id = params.task_id
mcp_server_url = ctx.request_context.lifespan_context.mcp_server_url
log_client_info(ctx, "everyrow_results")
skip_widget = not client_supports_widgets(ctx)

# ── Cross-user access check ──────────────────────────────────
try:
Expand All @@ -894,6 +906,7 @@ async def everyrow_results_http(
params.offset,
params.page_size,
mcp_server_url=mcp_server_url,
skip_widget=skip_widget,
)
if cached is not None:
return cached
Expand Down Expand Up @@ -931,6 +944,7 @@ async def everyrow_results_http(
params.page_size,
session_url,
mcp_server_url=mcp_server_url,
skip_widget=skip_widget,
)


Expand All @@ -954,6 +968,7 @@ async def everyrow_list_sessions(
Use this to find past sessions or check what's been run.
Results are paginated — 25 sessions per page by default.
"""
log_client_info(ctx, "everyrow_list_sessions")
client = _get_client(ctx)

try:
Expand Down Expand Up @@ -1016,6 +1031,7 @@ async def everyrow_cancel(
params: CancelInput, ctx: EveryRowContext
) -> list[TextContent]:
"""Cancel a running everyrow task. Use when the user wants to stop a task that is currently processing."""
log_client_info(ctx, "everyrow_cancel")
client = _get_client(ctx)
task_id = params.task_id

Expand Down