Skip to content

Commit 8b398ec

Browse files
authored
Fix/safely support elicitation (#75)
* release: bump version to 0.4.2 (patch release) * fix elicitation when the client doesn't support it * changelog
1 parent d1fbd9d commit 8b398ec

File tree

5 files changed

+228
-43
lines changed

5 files changed

+228
-43
lines changed

changelog/0.4.2.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# [0.4.2] - 2025-07-14
2+
3+
## Fixed
4+
5+
- Resolved issue where elicitations were not handled correctly when the client does not support them in the `choose_organization` tool.

src/api/tools/registery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def register_tools(mcp: FastMCP, **filter_flags) -> None:
5656
)
5757

5858
# List of tools to exclude when using API key authentication
59-
api_key_excluded_tools = ["choose_organization"]
59+
api_key_excluded_tools = ["choose_organization", "set_organization"]
6060

6161
for tool in filtered_tools:
6262
func = tool.func

src/api/tools/tools.py

Lines changed: 134 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
get_org_id,
2323
query_graphql_organizations,
2424
)
25-
from src.api.tools.types import Tool
26-
from src.api.tools.types import WorkspaceTarget
25+
from src.api.tools.types import Tool, WorkspaceTarget
2726
from src.utils.uuid_validation import validate_workspace_id, validate_uuid_string
27+
from src.utils.elicitation import try_elicitation, ElicitationError
2828
from src.logger import get_logger
2929

3030
# Set up logger for this module
@@ -712,7 +712,7 @@ def complete_database_migration(
712712
713713
Use Cases:
714714
1. Apply Migration: Set apply_to_production=True to execute the migration on the production database
715-
2. Discard Migration: Set apply_to_production=False to cleanup without applying changes
715+
2. Discard Migration: Set apply_to_production=False to cleanup without applying
716716
717717
Important Notes:
718718
- This tool must be called after 'prepare_database_migration' to properly cleanup the branch database
@@ -1798,7 +1798,7 @@ async def choose_organization(ctx: Context) -> dict:
17981798
if len(organizations) == 1:
17991799
selected_org = organizations[0]
18001800
else:
1801-
1801+
# For multiple organizations, use elicitation to let the user choose
18021802
class OrganizationChoice(BaseModel):
18031803
"""Schema for collecting organization selection."""
18041804

@@ -1807,56 +1807,54 @@ class OrganizationChoice(BaseModel):
18071807
choices=[org["orgID"] for org in organizations],
18081808
)
18091809

1810-
# For multiple organizations, use elicitation to let the user choose
18111810
# Format the organization list for display
18121811
org_list = "\n".join(
18131812
[f"- ID: {org['orgID']} ({org['name']})" for org in organizations]
18141813
)
18151814

1816-
try:
1817-
result = await ctx.elicit(
1818-
message=f"""📋 **Available SingleStore Organizations:**\n\n{org_list}\n\nPlease select an organization to use:""",
1819-
schema=OrganizationChoice,
1820-
)
1815+
elicit_result, error = await try_elicitation(
1816+
ctx=ctx,
1817+
message=f"""**Available SingleStore Organizations:**\n\n{org_list}\n\nPlease select the organization ID you want to use.""",
1818+
schema=OrganizationChoice,
1819+
)
18211820

1822-
if result.action == "accept" and result.data:
1823-
# Parse the selection to get the org ID
1824-
selected = result.data.organizationID
1825-
# Extract orgID from the selection string
1826-
org_id = selected
1827-
# Find the matching organization
1828-
selected_org = next(
1829-
org for org in organizations if org["orgID"] == org_id
1830-
)
1831-
else:
1832-
return {
1833-
"status": "cancelled",
1834-
"message": "Organization selection was cancelled",
1835-
"data": {
1836-
"organizations": organizations,
1837-
"count": len(organizations),
1838-
},
1839-
}
1840-
1841-
except Exception as elicit_error:
1842-
logger.error(
1843-
f"Error during organization elicitation: {str(elicit_error)}"
1821+
if error == ElicitationError.NOT_SUPPORTED:
1822+
# Client doesn't support elicitation, return list and wait for next prompt
1823+
await ctx.info(
1824+
"This client doesn't support interactive organization selection."
1825+
" Please wait for the next prompt to provide the organization ID and call set_organization tool."
18441826
)
18451827
return {
1846-
"status": "error",
1847-
"message": f"Failed to process organization selection: {str(elicit_error)}",
1848-
"error_code": "ELICITATION_FAILED",
1849-
"error_details": {"exception_type": type(elicit_error).__name__},
1828+
"status": "pending_selection",
1829+
"message": "Please provide the organization ID in your next request",
1830+
"data": {
1831+
"organizations": organizations,
1832+
"count": len(organizations),
1833+
},
1834+
}
1835+
1836+
if elicit_result.status == "success" and elicit_result.data:
1837+
# Find the matching organization from the selection
1838+
selected_org_id = elicit_result.data.organizationID
1839+
if selected_org_id:
1840+
for org in organizations:
1841+
if org["orgID"] == selected_org_id:
1842+
selected_org = org
1843+
break
1844+
elif elicit_result.status == "cancelled":
1845+
return {
1846+
"status": "cancelled",
1847+
"message": "Organization selection was cancelled",
1848+
"data": {
1849+
"organizations": organizations,
1850+
"count": len(organizations),
1851+
},
18501852
}
18511853

18521854
# Set the selected organization in settings
18531855
if selected_org:
1854-
if hasattr(settings, "org_id"):
1855-
settings.org_id = selected_org["orgID"]
1856-
else:
1857-
setattr(settings, "org_id", selected_org["orgID"])
1856+
settings.org_id = selected_org["orgID"]
18581857

1859-
# Return consistent response regardless of selection method
18601858
return {
18611859
"status": "success",
18621860
"message": f"Successfully selected organization: {selected_org['name']} (ID: {selected_org['orgID']})",
@@ -1870,6 +1868,15 @@ class OrganizationChoice(BaseModel):
18701868
"user_id": user_id,
18711869
},
18721870
}
1871+
else:
1872+
return {
1873+
"status": "error",
1874+
"message": "No organization was selected",
1875+
"data": {
1876+
"organizations": organizations,
1877+
"count": len(organizations),
1878+
},
1879+
}
18731880

18741881
except Exception as e:
18751882
logger.error(f"Error retrieving organizations: {str(e)}")
@@ -2136,6 +2143,91 @@ async def terminate_virtual_workspace(
21362143
}
21372144

21382145

2146+
async def set_organization(ctx: Context, organization_id: str) -> dict:
2147+
"""
2148+
Set the current organization after retrieving the list from choose_organization.
2149+
This tool should only be used when the client doesn't support elicitation.
2150+
2151+
Args:
2152+
organization_id: The ID of the organization to select, as obtained from the
2153+
choose_organization tool's response.
2154+
2155+
Returns:
2156+
Dictionary with selected organization details
2157+
2158+
Important:
2159+
- This tool should only be called after choose_organization returns a 'pending_selection' status
2160+
- The organization_id must be one of the IDs returned by choose_organization
2161+
2162+
Example flow:
2163+
1. Call choose_organization first
2164+
2. If it returns 'pending_selection', get the organization ID from the list
2165+
3. Call set_organization with the chosen ID
2166+
"""
2167+
settings = config.get_settings()
2168+
user_id = config.get_user_id()
2169+
# Track tool call event
2170+
settings.analytics_manager.track_event(
2171+
user_id,
2172+
"tool_calling",
2173+
{"name": "set_organization", "organization_id": organization_id},
2174+
)
2175+
2176+
logger.debug(f"Setting organization ID: {organization_id}")
2177+
2178+
try:
2179+
# Get the list of organizations to validate the selection
2180+
organizations = query_graphql_organizations()
2181+
2182+
# Find the selected organization
2183+
selected_org = next(
2184+
(org for org in organizations if org["orgID"] == organization_id), None
2185+
)
2186+
2187+
if not selected_org:
2188+
available_orgs = ", ".join(org["orgID"] for org in organizations)
2189+
return {
2190+
"status": "error",
2191+
"message": f"Organization ID '{organization_id}' not found. Available IDs: {available_orgs}",
2192+
"error_code": "INVALID_ORGANIZATION",
2193+
"error_details": {
2194+
"provided_id": organization_id,
2195+
"available_ids": [org["orgID"] for org in organizations],
2196+
},
2197+
}
2198+
2199+
# Set the selected organization in settings
2200+
if hasattr(settings, "org_id"):
2201+
settings.org_id = selected_org["orgID"]
2202+
else:
2203+
setattr(settings, "org_id", selected_org["orgID"])
2204+
2205+
await ctx.info(
2206+
f"Organization set to: {selected_org['name']} (ID: {selected_org['orgID']})"
2207+
)
2208+
2209+
return {
2210+
"status": "success",
2211+
"message": f"Successfully set organization to: {selected_org['name']} (ID: {selected_org['orgID']})",
2212+
"data": {
2213+
"organization": selected_org,
2214+
},
2215+
"metadata": {
2216+
"timestamp": datetime.now(timezone.utc).isoformat(),
2217+
"user_id": user_id,
2218+
},
2219+
}
2220+
2221+
except Exception as e:
2222+
logger.error(f"Error setting organization: {str(e)}")
2223+
return {
2224+
"status": "error",
2225+
"message": f"Failed to set organization: {str(e)}",
2226+
"error_code": "ORGANIZATION_SET_FAILED",
2227+
"error_details": {"exception_type": type(e).__name__},
2228+
}
2229+
2230+
21392231
tools_definition = [
21402232
{"func": get_user_id},
21412233
{"func": workspace_groups_info},
@@ -2154,6 +2246,7 @@ async def terminate_virtual_workspace(
21542246
{"func": list_job_executions, "internal": True},
21552247
{"func": get_notebook_path, "internal": True},
21562248
{"func": choose_organization},
2249+
{"func": set_organization},
21572250
# These tools are under development and not yet available for public use
21582251
{"func": prepare_database_migration, "internal": True},
21592252
{"func": complete_database_migration, "internal": True},

src/utils/elicitation.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Utility functions for handling elicitation with fallbacks."""
2+
3+
from dataclasses import dataclass
4+
from enum import Enum, auto
5+
from typing import Any, Dict, Literal, Optional, Tuple, Type, TypeVar
6+
from mcp.server.fastmcp import Context
7+
from pydantic import BaseModel
8+
9+
10+
class ElicitationError(Enum):
11+
"""Possible elicitation error types."""
12+
13+
NOT_SUPPORTED = auto()
14+
FAILED = auto()
15+
16+
17+
T = TypeVar("T", bound=BaseModel)
18+
19+
20+
@dataclass
21+
class ElicitationResult:
22+
"""Result of an elicitation attempt."""
23+
24+
status: Literal["success", "error", "cancelled"]
25+
message: str
26+
data: Optional[Dict[str, Any]] = None
27+
error_code: Optional[str] = None
28+
error_details: Optional[Dict[str, Any]] = None
29+
30+
31+
async def try_elicitation(
32+
ctx: Context,
33+
message: str,
34+
schema: Type[T],
35+
) -> Tuple[ElicitationResult, Optional[ElicitationError]]:
36+
"""
37+
Try to elicit a response from the user, handling cases where elicitation is not supported.
38+
39+
Args:
40+
ctx: The Context object from MCP.
41+
message: The message to display to the user for elicitation.
42+
schema: The Pydantic schema for elicitation validation.
43+
44+
Returns:
45+
A tuple containing:
46+
1. ElicitationResult with:
47+
- status: 'success', 'error', or 'cancelled'
48+
- message: Description of what happened
49+
- data: The elicited data if successful
50+
- error_code: Error code if there was an error
51+
- error_details: Additional error details
52+
2. ElicitationError: The type of error if one occurred, None if successful
53+
54+
Raises:
55+
Exception: If elicitation fails for any reason other than not being supported
56+
"""
57+
try:
58+
result = await ctx.elicit(message=message, schema=schema)
59+
if result.action == "accept" and result.data:
60+
return (
61+
ElicitationResult(
62+
status="success",
63+
message="Elicitation successful",
64+
data=result.data,
65+
),
66+
None,
67+
)
68+
return (
69+
ElicitationResult(
70+
status="cancelled", message="Elicitation was cancelled by the user"
71+
),
72+
None,
73+
)
74+
except Exception as e:
75+
# Elicitation not supported by the client
76+
if type(e).__name__ == "McpError" and str(e) == "Method not found":
77+
return (
78+
ElicitationResult(
79+
status="error",
80+
message="Client doesn't support elicitation",
81+
error_code="ELICITATION_NOT_SUPPORTED",
82+
error_details={"error_message": str(e)},
83+
),
84+
ElicitationError.NOT_SUPPORTED,
85+
)
86+
# For all other errors, re-raise the original exception
87+
raise

src/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.4.1"
1+
__version__ = "0.4.2"

0 commit comments

Comments
 (0)