Skip to content
Draft
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
1 change: 1 addition & 0 deletions everyrow-mcp/deploy/chart/templates/gcpbackendpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ spec:
sessionAffinity:
type: GENERATED_COOKIE
cookieTtlSec: {{ .Values.sessionAffinity.gcpBackendPolicy.cookieTtlSec }}
timeoutSec: {{ .Values.sessionAffinity.gcpBackendPolicy.timeoutSec | default 30 }}
targetRef:
group: ""
kind: Service
Expand Down
1 change: 1 addition & 0 deletions everyrow-mcp/deploy/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ sessionAffinity:
gcpBackendPolicy:
enabled: true
cookieTtlSec: 3600
timeoutSec: 120
# Service-level ClientIP affinity: kube-proxy layer fallback.
clientIP:
enabled: true
Expand Down
10 changes: 10 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ class Settings(BaseSettings):
default=100,
description="If total rows <= this value, skip asking the user for page_size and load all rows directly.",
)
long_poll_timeout: int = Field(
default=25,
description="Max seconds to block in a single everyrow_progress call for widget-capable clients. "
"Must stay under both GKE backend timeout and MCP client tool timeout. "
"Set 0 to disable.",
)
long_poll_interval: int = Field(
default=5,
description="Seconds between internal status checks during long-poll.",
)

# Upload settings (HTTP mode only)
upload_secret: str = Field(
Expand Down
2 changes: 2 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,7 @@
.l-fail::before{background:#e53935!important}
.status-done{color:#4caf50;font-weight:600}
.status-fail{color:#e53935;font-weight:600}
.prompt{margin-top:6px;font-size:12px;color:#666;font-style:italic}
.eta{color:#888;font-size:11px}
@keyframes flash{0%,100%{background:transparent}50%{background:rgba(76,175,80,.15)}}
.flash{animation:flash 1s ease 3}
Expand Down Expand Up @@ -852,6 +853,7 @@
h+=`<span class="${esc(cls)}">${esc(d.status)}</span>`;
h+=`<span>${comp}/${tot}${fail?` (${fail} failed)`:""}</span>`;
if(elapsed)h+=`<span>${fmtTime(elapsed)}</span>`;
if(d.status==="completed")h+=`<div class="prompt">Ask Claude to show results</div>`;
}else{
h+=`<span>${comp}/${tot}</span>`;
const eta=comp>0&&elapsed>0?Math.round((tot-comp)/(comp/elapsed)):0;
Expand Down
29 changes: 28 additions & 1 deletion everyrow-mcp/src/everyrow_mcp/tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ def is_internal_client() -> bool:
return "everyrow" in get_user_agent().lower()


def should_long_poll(ctx: EveryRowContext) -> bool:
"""Return True if this client should use a single long-poll instead of the 12s loop.

Widget-capable clients (claude.ai, Claude Desktop) get a single long-poll
followed by widget handoff, conserving their per-turn tool call quota.
stdio clients (Claude Code) and internal clients (everyrow-cc) keep the
12s short-poll loop.
"""
if settings.is_stdio:
return False
if settings.long_poll_timeout <= 0:
return False
if is_internal_client():
return False
return client_supports_widgets(ctx)


def _submission_text(
label: str, session_url: str, task_id: str, session_id: str = ""
) -> str:
Expand Down Expand Up @@ -430,7 +447,7 @@ def write_file(self, task_id: str) -> None:
started_at=self.started_at,
)

def progress_message(self, task_id: str) -> str:
def progress_message(self, task_id: str, *, widget_handoff: bool = False) -> str:
if self.is_terminal:
if self.error:
return f"Task {self.status.value}: {self.error}"
Expand All @@ -456,6 +473,16 @@ def progress_message(self, task_id: str) -> str:
return f"{completed_msg}\n{next_call}"
return f"Task {self.status.value}. Report the error to the user."

# ── Non-terminal: still running ──────────────────────────────
if widget_handoff:
# Widget-capable client: tell Claude to stop polling.
# The widget already shows live progress via REST polling.
fail_part = f", {self.failed} failed" if self.failed else ""
return dedent(f"""\
Running: {self.completed}/{self.total} complete, {self.running} running{fail_part} ({self.elapsed_s}s elapsed).
Progress is live in the widget above. **Do NOT call everyrow_progress again.**
When the user is ready to review results, call everyrow_results(task_id='{task_id}').""")

if self.is_screen:
return dedent(f"""\
Screen running ({self.elapsed_s}s elapsed).
Expand Down
81 changes: 80 additions & 1 deletion everyrow-mcp/src/everyrow_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
create_tool_response,
is_internal_client,
log_client_info,
should_long_poll,
write_initial_task_state,
)
from everyrow_mcp.utils import fetch_csv_from_url, is_url, save_result_to_csv
Expand Down Expand Up @@ -1006,7 +1007,6 @@ async def everyrow_progress(
Do not add commentary between progress calls, just call again immediately.
"""
logger.debug("everyrow_progress: task_id=%s", params.task_id)
client = _get_client(ctx)
task_id = params.task_id

# ── Cross-user access check ──────────────────────────────────
Expand All @@ -1022,6 +1022,15 @@ async def everyrow_progress(
)
]

if should_long_poll(ctx):
return await _progress_long_poll(ctx, task_id)
return await _progress_short_poll(ctx, task_id)


async def _progress_short_poll(ctx: EveryRowContext, task_id: str) -> list[TextContent]:
"""Original 12s short-poll: sleep once, check status, return."""
client = _get_client(ctx)

# Block server-side before polling — controls the cadence
await asyncio.sleep(redis_store.PROGRESS_POLL_DELAY)

Expand Down Expand Up @@ -1052,6 +1061,76 @@ async def everyrow_progress(
return [TextContent(type="text", text=ts.progress_message(task_id))]


async def _progress_long_poll(ctx: EveryRowContext, task_id: str) -> list[TextContent]:
"""Long-poll for widget-capable clients: block up to ``long_poll_timeout``
seconds, checking status every ``long_poll_interval`` seconds.

If the task completes within the timeout, return the normal completion
message so Claude can immediately call everyrow_results.

If the task is still running, return a widget-handoff message telling
Claude to stop polling — the widget already shows live progress.
"""
client = _get_client(ctx)
timeout = settings.long_poll_timeout
interval = settings.long_poll_interval
elapsed = 0
ts: TaskState | None = None

while elapsed < timeout:
await asyncio.sleep(interval)
elapsed += interval

try:
status_response = handle_response(
await get_task_status_tasks_task_id_status_get.asyncio(
task_id=UUID(task_id),
client=client,
)
)
except Exception:
logger.debug("Long-poll status check failed for %s, retrying", task_id)
continue # retry next iteration

ts = TaskState(status_response)

# Emit MCP progress notification (no-op if no progressToken)
try:
await ctx.report_progress(
progress=float(ts.completed),
total=float(ts.total or 1),
)
except Exception:
pass # progress notifications are best-effort

if ts.is_terminal:
break

if ts is None:
return [
TextContent(
type="text",
text=f"Unable to check status for task {task_id}. "
f"Call everyrow_progress(task_id='{task_id}') to retry.",
)
]

if ts.is_terminal:
logger.info(
"everyrow_progress: task_id=%s status=%s (long-poll)",
task_id,
ts.status.value,
)

# Terminal → normal completion message; non-terminal → widget handoff
return [
TextContent(
type="text",
text=ts.progress_message(task_id, widget_handoff=not ts.is_terminal),
)
]


async def everyrow_results_stdio(
params: StdioResultsInput, ctx: EveryRowContext
) -> list[TextContent]:
Expand Down
Loading