Skip to content

Commit 5cd8738

Browse files
RafaelPoclaude
andauthored
Switch widget UA detection to whitelist (#244)
* Switch widget detection from blocklist to whitelist Now that we've confirmed Claude.ai and Claude Desktop both send "Claude-User", whitelist that UA instead of blocking known non-widget clients. Unknown UAs default to text-only. Also broaden internal client detection to match "everyrow" instead of "everyrow-cc". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix UA comment: internal client sends everyrow not everyrow-cc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix stale docstring and add UA detection tests Update client_supports_widgets docstring to reflect whitelist behavior (unknown UAs now default to no widget, not widget). Add parameterized tests for _widgets_from_user_agent and is_internal_client. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cbe40f6 commit 5cd8738

2 files changed

Lines changed: 99 additions & 28 deletions

File tree

everyrow-mcp/src/everyrow_mcp/tool_helpers.py

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,15 @@ def client_supports_widgets(ctx: EveryRowContext) -> bool:
114114
widget-capable client names so they get widgets today.
115115
This fallback should be removed once clients adopt the capability.
116116
117-
3. **User-Agent fallback** (stateless HTTP mode):
117+
3. **User-Agent whitelist** (stateless HTTP mode):
118118
When ``client_params`` is ``None`` (stateless HTTP — no MCP initialize
119-
handshake), we check the HTTP User-Agent header. Clients known to
120-
NOT support widgets (e.g. Claude Code) are excluded. If the
121-
User-Agent is unknown, we default to **showing widgets** because
122-
HTTP mode traffic is overwhelmingly from Claude.ai/Desktop.
123-
124-
Unknown clients default to **no widget** in stateful mode (tier 2),
125-
but to **widget** in stateless HTTP mode (tier 3) where the population
126-
is predominantly Claude.ai/Desktop.
119+
handshake), we check the HTTP User-Agent header against a whitelist
120+
of known widget-capable UAs (currently ``"Claude-User"``). If the
121+
User-Agent is unknown, we default to **no widget** to avoid wasting
122+
context tokens on clients that can't render them.
123+
124+
Unknown clients default to **no widget** in both stateful (tier 2) and
125+
stateless (tier 3) modes.
127126
"""
128127
try:
129128
cp = ctx.session.client_params
@@ -161,36 +160,36 @@ def _widgets_from_user_agent() -> bool:
161160
162161
Called when client_params is None (stateless HTTP mode).
163162
164-
Strategy: block known non-widget clients, allow everything else.
165-
HTTP mode traffic is predominantly Claude.ai/Desktop which supports
166-
widgets, so defaulting to True minimises false negatives.
167-
168-
The blocklist below will be populated once we observe actual
169-
User-Agent strings from Claude Code's HTTP client in production logs.
163+
Strategy: whitelist known widget-capable clients, deny everything else.
164+
Only clients we have confirmed can render widgets get them; unknown UAs
165+
default to text-only to avoid wasting context tokens on unsupported UIs.
170166
"""
171167
from everyrow_mcp.http_config import get_user_agent # noqa: PLC0415
172168

173169
ua = get_user_agent().lower()
174170

175-
# Known non-widget User-Agent substrings.
176-
# Observed values (Feb 2026):
177-
# Claude Code: "claude-code/2.1.59 (cli)"
178-
# MCP SDK: "python-httpx/0.28.1" (test client)
179-
# OAuth flow: "Bun/1.3.10" (Claude Code's OAuth helper)
180-
_NO_WIDGET_UA_SUBSTRINGS = {"claude-code", "everyrow-cc"}
181-
182-
if any(pattern in ua for pattern in _NO_WIDGET_UA_SUBSTRINGS):
183-
return False
171+
# Whitelist of UA substrings for clients that support widgets.
172+
#
173+
# Observed User-Agent values (Feb 2026):
174+
# Claude.ai: "Claude-User" — supports widgets
175+
# Claude Desktop: "Claude-User" — supports widgets
176+
# Claude Code CLI: "claude-code/2.1.62 (cli)" — text-only
177+
# everyrow: "everyrow/1.0" — text-only (internal)
178+
# MCP SDK (test): "python-httpx/0.28.1" — text-only
179+
# OAuth helper: "Bun/1.3.10" — not a tool caller
180+
#
181+
# Claude.ai and Claude Desktop both send "Claude-User". If Anthropic
182+
# changes this, we'll need to update the whitelist.
183+
_WIDGET_UA_SUBSTRINGS = {"claude-user"}
184184

185-
# Unknown UA in HTTP mode → assume widget-capable (Claude.ai/Desktop).
186-
return True
185+
return any(pattern in ua for pattern in _WIDGET_UA_SUBSTRINGS)
187186

188187

189188
def is_internal_client() -> bool:
190-
"""Return True if the request comes from EveryRow's own app (CC)."""
189+
"""Return True if the request comes from everyrow's own app."""
191190
from everyrow_mcp.http_config import get_user_agent # noqa: PLC0415
192191

193-
return "everyrow-cc" in get_user_agent().lower()
192+
return "everyrow" in get_user_agent().lower()
194193

195194

196195
def _submission_text(
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for User-Agent-based client detection."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from everyrow_mcp.tool_helpers import _widgets_from_user_agent, is_internal_client
8+
9+
_UA_PATCH = "everyrow_mcp.http_config.get_user_agent"
10+
11+
12+
class TestWidgetsFromUserAgent:
13+
"""Tests for _widgets_from_user_agent (tier 3 widget detection)."""
14+
15+
@pytest.mark.parametrize(
16+
"ua",
17+
[
18+
"Claude-User",
19+
"claude-user",
20+
"Claude-User/1.0",
21+
"something Claude-User something",
22+
],
23+
)
24+
def test_widget_capable_clients(self, ua: str) -> None:
25+
with patch(_UA_PATCH, return_value=ua):
26+
assert _widgets_from_user_agent() is True
27+
28+
@pytest.mark.parametrize(
29+
"ua",
30+
[
31+
"claude-code/2.1.62 (cli)",
32+
"everyrow-cc/1.0",
33+
"everyrow/1.0",
34+
"python-httpx/0.28.1",
35+
"Bun/1.3.10",
36+
"curl/8.0",
37+
"",
38+
],
39+
)
40+
def test_non_widget_clients(self, ua: str) -> None:
41+
with patch(_UA_PATCH, return_value=ua):
42+
assert _widgets_from_user_agent() is False
43+
44+
45+
class TestIsInternalClient:
46+
"""Tests for is_internal_client."""
47+
48+
@pytest.mark.parametrize(
49+
"ua",
50+
[
51+
"everyrow/1.0",
52+
"everyrow-cc/1.0",
53+
"Everyrow-CC/2.0",
54+
"something-everyrow-something",
55+
],
56+
)
57+
def test_internal_clients(self, ua: str) -> None:
58+
with patch(_UA_PATCH, return_value=ua):
59+
assert is_internal_client() is True
60+
61+
@pytest.mark.parametrize(
62+
"ua",
63+
[
64+
"Claude-User",
65+
"claude-code/2.1.62 (cli)",
66+
"python-httpx/0.28.1",
67+
"",
68+
],
69+
)
70+
def test_non_internal_clients(self, ua: str) -> None:
71+
with patch(_UA_PATCH, return_value=ua):
72+
assert is_internal_client() is False

0 commit comments

Comments
 (0)