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
2 changes: 1 addition & 1 deletion everyrow-mcp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
},
{
"name": "everyrow_list_sessions",
"description": "List all everyrow sessions owned by the authenticated user."
"description": "List everyrow sessions owned by the authenticated user (paginated)."
},
{
"name": "everyrow_cancel",
Expand Down
98 changes: 98 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,28 @@ def _check_exactly_one(
raise ValueError(f"{prefix}Provide exactly one of {fields}.")


def _validate_session_id(v: str | None) -> str | None:
"""Validate session_id is a valid UUID string."""
if v is not None:
try:
UUID(v)
except ValueError as exc:
raise ValueError(f"session_id must be a valid UUID: {v}") from exc
return v


def _check_session_exclusivity(
session_id: str | None, session_name: str | None
) -> None:
"""Raise if both session_id and session_name are provided."""
if session_id is not None and session_name is not None:
raise ValueError(
"session_id and session_name are mutually exclusive — "
"pass session_id to resume an existing session, "
"or session_name to create a new one."
)


class _SingleSourceInput(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")

Expand All @@ -149,6 +171,14 @@ class _SingleSourceInput(BaseModel):
default=None,
description="Inline data as a list of row objects.",
)
session_id: str | None = Field(
default=None,
description="Session ID (UUID) to resume. Mutually exclusive with session_name.",
)
session_name: str | None = Field(
default=None,
description="Human-readable name for a new session. Mutually exclusive with session_id.",
)

@field_validator("artifact_id")
@classmethod
Expand All @@ -160,6 +190,11 @@ def validate_artifact_id(cls, v: str | None) -> str | None:
raise ValueError(f"artifact_id must be a valid UUID: {v}") from exc
return v

@field_validator("session_id")
@classmethod
def validate_session_id(cls, v: str | None) -> str | None:
return _validate_session_id(v)

@field_validator("data")
@classmethod
def validate_data_size(
Expand All @@ -181,6 +216,7 @@ def check_input_source(self):
field_names=("artifact_id", "data"),
label="Input",
)
_check_session_exclusivity(self.session_id, self.session_name)
return self

@property
Expand Down Expand Up @@ -334,6 +370,15 @@ class MergeInput(BaseModel):
description="Relationship type: many_to_one (default) or one_to_one.",
)

session_id: str | None = Field(
default=None,
description="Session ID (UUID) to resume. Mutually exclusive with session_name.",
)
session_name: str | None = Field(
default=None,
description="Human-readable name for a new session. Mutually exclusive with session_id.",
)

@field_validator("left_artifact_id", "right_artifact_id")
@classmethod
def validate_artifact_ids(cls, v: str | None) -> str | None:
Expand All @@ -344,6 +389,11 @@ def validate_artifact_ids(cls, v: str | None) -> str | None:
raise ValueError(f"artifact_id must be a valid UUID: {v}") from exc
return v

@field_validator("session_id")
@classmethod
def validate_session_id(cls, v: str | None) -> str | None:
return _validate_session_id(v)

@field_validator("left_data", "right_data")
@classmethod
def validate_data_size(
Expand All @@ -370,6 +420,7 @@ def check_sources(self) -> "MergeInput":
field_names=("right_artifact_id", "right_data"),
label="Right table",
)
_check_session_exclusivity(self.session_id, self.session_name)
return self

@property
Expand Down Expand Up @@ -425,6 +476,14 @@ class UploadDataInput(BaseModel):
"or absolute local file path (stdio mode only).",
min_length=1,
)
session_id: str | None = Field(
default=None,
description="Session ID (UUID) to resume. Mutually exclusive with session_name.",
)
session_name: str | None = Field(
default=None,
description="Human-readable name for a new session. Mutually exclusive with session_id.",
)

@field_validator("source")
@classmethod
Expand All @@ -443,6 +502,16 @@ def validate_source(cls, v: str) -> str:
validate_csv_path(v)
return v

@field_validator("session_id")
@classmethod
def validate_session_id(cls, v: str | None) -> str | None:
return _validate_session_id(v)

@model_validator(mode="after")
def check_session_exclusivity(self) -> "UploadDataInput":
_check_session_exclusivity(self.session_id, self.session_name)
return self


class SingleAgentInput(BaseModel):
"""Input for a single agent operation (no CSV)."""
Expand All @@ -462,6 +531,14 @@ class SingleAgentInput(BaseModel):
default=None,
description="Optional JSON schema for the agent response.",
)
session_id: str | None = Field(
default=None,
description="Session ID (UUID) to resume. Mutually exclusive with session_name.",
)
session_name: str | None = Field(
default=None,
description="Human-readable name for a new session. Mutually exclusive with session_id.",
)

@field_validator("response_schema")
@classmethod
Expand All @@ -470,6 +547,16 @@ def validate_response_schema(
) -> dict[str, Any] | None:
return _validate_response_schema(v)

@field_validator("session_id")
@classmethod
def validate_session_id(cls, v: str | None) -> str | None:
return _validate_session_id(v)

@model_validator(mode="after")
def check_session_exclusivity(self) -> "SingleAgentInput":
_check_session_exclusivity(self.session_id, self.session_name)
return self


def _validate_task_id(v: str) -> str:
"""Validate task_id is a valid UUID."""
Expand Down Expand Up @@ -582,3 +669,14 @@ def validate_output(cls, v: str | None) -> str | None:
if v is not None and not v.lower().endswith(".csv"):
raise ValueError("output_path must end in .csv")
return v


class ListSessionsInput(BaseModel):
"""Input for listing sessions with pagination."""

model_config = ConfigDict(extra="forbid")

offset: int = Field(0, ge=0, description="Number of sessions to skip")
limit: int = Field(
25, ge=1, le=1000, description="Max sessions per page (default 25, max 1000)"
)
17 changes: 13 additions & 4 deletions everyrow-mcp/src/everyrow_mcp/tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,21 @@ def _get_client(ctx: EveryRowContext) -> AuthenticatedClient:
return ctx.request_context.lifespan_context.client_factory()


def _submission_text(label: str, session_url: str, task_id: str) -> str:
def _submission_text(
label: str, session_url: str, task_id: str, session_id: str = ""
) -> str:
"""Build human-readable text for submission tool results."""
if settings.is_stdio:
session_line = f"\nSession ID: {session_id}" if session_id else ""
return dedent(f"""\
{label}
Session: {session_url}
Session: {session_url}{session_line}
Task ID: {task_id}

Share the session_url with the user, then immediately call everyrow_progress(task_id='{task_id}').""")
session_line = f"\nSession ID: {session_id}" if session_id else ""
return dedent(f"""\
{label}
{label}{session_line}
Task ID: {task_id}

Immediately call everyrow_progress(task_id='{task_id}').""")
Expand All @@ -87,6 +91,7 @@ async def _submission_ui_json(
total: int,
token: str,
mcp_server_url: str = "",
session_id: str = "",
) -> str:
"""Build JSON for the session MCP App widget, and store the token for polling."""
poll_token = secrets.token_urlsafe(32)
Expand Down Expand Up @@ -115,6 +120,8 @@ async def _submission_ui_json(
"total": total,
"status": "submitted",
}
if session_id:
data["session_id"] = session_id
if mcp_server_url:
data["progress_url"] = f"{mcp_server_url}/api/progress/{task_id}"
data["poll_token"] = poll_token
Expand All @@ -129,13 +136,14 @@ async def create_tool_response(
token: str,
total: int,
mcp_server_url: str = "",
session_id: str = "",
) -> list[TextContent]:
"""Build the standard submission response for a tool.

Returns human-readable text in all modes, plus a widget JSON
prepended in HTTP mode.
"""
text = _submission_text(label, session_url, task_id)
text = _submission_text(label, session_url, task_id, session_id=session_id)
main_content = TextContent(type="text", text=text)
if settings.is_http:
ui_json = await _submission_ui_json(
Expand All @@ -144,6 +152,7 @@ async def create_tool_response(
total=total,
token=token,
mcp_server_url=mcp_server_url,
session_id=session_id,
)
return [TextContent(type="text", text=ui_json), main_content]
return [main_content]
Expand Down
Loading