diff --git a/stackone_ai/feedback/__init__.py b/stackone_ai/feedback/__init__.py deleted file mode 100644 index faf9ba7..0000000 --- a/stackone_ai/feedback/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Feedback collection tools for StackOne.""" - -from .tool import create_feedback_tool - -__all__ = ["create_feedback_tool"] diff --git a/stackone_ai/feedback/tool.py b/stackone_ai/feedback/tool.py deleted file mode 100644 index bcc3ef7..0000000 --- a/stackone_ai/feedback/tool.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Feedback collection tool for StackOne.""" - -from __future__ import annotations - -import json - -from pydantic import BaseModel, Field, field_validator - -from stackone_ai.constants import DEFAULT_BASE_URL -from stackone_ai.models import ( - ExecuteConfig, - JsonDict, - ParameterLocation, - StackOneError, - StackOneTool, - ToolParameters, -) - - -class FeedbackInput(BaseModel): - """Input schema for feedback tool.""" - - feedback: str = Field(..., min_length=1, description="User feedback text") - account_id: str | list[str] = Field(..., description="Account identifier(s) - single ID or list of IDs") - tool_names: list[str] = Field(..., min_length=1, description="List of tool names") - - @field_validator("feedback") - @classmethod - def validate_feedback(cls, v: str) -> str: - """Validate that feedback is non-empty after trimming.""" - trimmed = v.strip() - if not trimmed: - raise ValueError("Feedback must be a non-empty string") - return trimmed - - @field_validator("account_id") - @classmethod - def validate_account_id(cls, v: str | list[str]) -> list[str]: - """Validate and normalize account ID(s) to a list.""" - if isinstance(v, str): - trimmed = v.strip() - if not trimmed: - raise ValueError("Account ID must be a non-empty string") - return [trimmed] - - if isinstance(v, list): - if not v: - raise ValueError("At least one account ID is required") - cleaned = [str(item).strip() for item in v if str(item).strip()] - if not cleaned: - raise ValueError("At least one valid account ID is required") - return cleaned - - raise ValueError("Account ID must be a string or list of strings") - - @field_validator("tool_names") - @classmethod - def validate_tool_names(cls, v: list[str]) -> list[str]: - """Validate and clean tool names.""" - cleaned = [name.strip() for name in v if name.strip()] - if not cleaned: - raise ValueError("At least one tool name is required") - return cleaned - - -class FeedbackTool(StackOneTool): - """Extended tool for collecting feedback with enhanced validation.""" - - def execute( - self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None - ) -> JsonDict: - """ - Execute the feedback tool with enhanced validation. - - If multiple account IDs are provided, sends the same feedback to each account individually. - - Args: - arguments: Tool arguments as string or dict - options: Execution options - - Returns: - Combined response from all API calls - - Raises: - StackOneError: If validation or API call fails - """ - try: - # Parse input - if isinstance(arguments, str): - raw_params = json.loads(arguments) - else: - raw_params = arguments or {} - - # Validate with Pydantic - parsed_params = FeedbackInput(**raw_params) - - # Get list of account IDs (already normalized by validator) - account_ids = parsed_params.account_id - feedback = parsed_params.feedback - tool_names = parsed_params.tool_names - - # If only one account ID, use the parent execute method - if len(account_ids) == 1: - validated_arguments = { - "feedback": feedback, - "account_id": account_ids[0], - "tool_names": tool_names, - } - return super().execute(validated_arguments, options=options) - - # Multiple account IDs - send to each individually - results = [] - errors = [] - - for account_id in account_ids: - try: - validated_arguments = { - "feedback": feedback, - "account_id": account_id, - "tool_names": tool_names, - } - result = super().execute(validated_arguments, options=options) - results.append({"account_id": account_id, "status": "success", "result": result}) - except Exception as exc: - error_msg = str(exc) - errors.append({"account_id": account_id, "status": "error", "error": error_msg}) - results.append({"account_id": account_id, "status": "error", "error": error_msg}) - - # Return combined results - return { - "message": f"Feedback sent to {len(account_ids)} account(s)", - "total_accounts": len(account_ids), - "successful": len([r for r in results if r["status"] == "success"]), - "failed": len(errors), - "results": results, - } - - except json.JSONDecodeError as exc: - raise StackOneError(f"Invalid JSON in arguments: {exc}") from exc - except ValueError as exc: - raise StackOneError(f"Validation error: {exc}") from exc - except Exception as error: - if isinstance(error, StackOneError): - raise - raise StackOneError(f"Error executing feedback tool: {error}") from error - - -def create_feedback_tool( - api_key: str, - account_id: str | None = None, - base_url: str = DEFAULT_BASE_URL, -) -> FeedbackTool: - """ - Create a feedback collection tool. - - Args: - api_key: API key for authentication - account_id: Optional account ID - base_url: Base URL for the API - - Returns: - FeedbackTool configured for feedback collection - """ - name = "tool_feedback" - description = ( - "Collects user feedback on StackOne tool performance. " - 'First ask the user, "Are you ok with sending feedback to StackOne?" ' - "and mention that the LLM will take care of sending it. " - "Call this tool only when the user explicitly answers yes." - ) - - parameters = ToolParameters( - type="object", - properties={ - "account_id": { - "oneOf": [ - { - "type": "string", - "description": 'Single account identifier (e.g., "acc_123456")', - }, - { - "type": "array", - "items": {"type": "string"}, - "description": "List of account identifiers for multiple accounts", - }, - ], - "description": "Account identifier(s) - single ID or list of IDs", - }, - "feedback": { - "type": "string", - "description": "Verbatim feedback from the user about their experience with StackOne tools.", - }, - "tool_names": { - "type": "array", - "items": { - "type": "string", - }, - "description": "Array of tool names being reviewed", - }, - }, - ) - - execute_config = ExecuteConfig( - name=name, - method="POST", - url=f"{base_url}/ai/tool-feedback", - body_type="json", - parameter_locations={ - "feedback": ParameterLocation.BODY, - "account_id": ParameterLocation.BODY, - "tool_names": ParameterLocation.BODY, - }, - ) - - # Create instance by calling parent class __init__ directly since FeedbackTool is a subclass - tool = FeedbackTool.__new__(FeedbackTool) - StackOneTool.__init__( - tool, - description=description, - parameters=parameters, - _execute_config=execute_config, - _api_key=api_key, - _account_id=account_id, - ) - - return tool diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index fef9198..342abc5 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -92,6 +92,9 @@ class ExecuteToolsConfig(TypedDict, total=False): } _USER_AGENT = f"stackone-ai-python/{_SDK_VERSION}" +# The global feedback tool the StackOne MCP server exposes on every account. +_FEEDBACK_TOOL_NAME = "submit_feedback" + # --- Internal tool_search + tool_execute --- @@ -661,8 +664,12 @@ def get_search_tool(self, *, search: SearchMode | None = None) -> SearchTool: return SearchTool(self, config=config) - def _build_tools(self, account_ids: list[str] | None = None) -> Tools: - """Build tool_search + tool_execute tools scoped to this toolset.""" + def _build_tools(self, account_ids: list[str] | None = None, *, feedback: bool = True) -> Tools: + """Build tool_search + tool_execute tools scoped to this toolset. + + The global ``submit_feedback`` tool (exposed by the MCP catalog) is appended by default so + search-and-execute agents can report feedback too; pass ``feedback=False`` to omit it. + """ if self._search_config is None: raise ToolsetConfigError( "Search is disabled. Pass search={} (or search={'method': 'auto'}) to " @@ -672,13 +679,19 @@ def _build_tools(self, account_ids: list[str] | None = None) -> Tools: if account_ids: self._account_ids = account_ids - # Discover available connectors for dynamic descriptions + # Discover available connectors for dynamic descriptions, and grab the global feedback tool + # from the same (cached) catalog fetch so search-and-execute agents inherit it from MCP too. connectors_str = "" + feedback_tool: StackOneTool | None = None try: all_tools = self.fetch_tools(account_ids=self._account_ids) connectors = sorted(all_tools.get_connectors()) if connectors: connectors_str = ", ".join(connectors) + if feedback: + feedback_tool = next( + (tool for tool in all_tools.to_list() if tool.name == _FEEDBACK_TOOL_NAME), None + ) except Exception: logger.debug("Could not discover connectors for tool descriptions") @@ -688,7 +701,10 @@ def _build_tools(self, account_ids: list[str] | None = None) -> Tools: execute_tool = _create_execute_tool(self.api_key, connectors=connectors_str) execute_tool._toolset = self - return Tools([search_tool, execute_tool]) + built: list[StackOneTool] = [search_tool, execute_tool] + if feedback_tool is not None: + built.append(feedback_tool) + return Tools(built) def openai( self, @@ -1183,6 +1199,7 @@ def fetch_tools( account_ids: list[str] | None = None, providers: list[str] | None = None, actions: list[str] | None = None, + feedback: bool = True, ) -> Tools: """Fetch tools with optional filtering by account IDs, providers, and actions @@ -1193,6 +1210,10 @@ def fetch_tools( Case-insensitive matching. actions: Optional list of action patterns with glob support (e.g., ['*_list_employees', 'hibob_create_employees']) + feedback: Whether to include the global feedback tool (``submit_feedback``), + which the StackOne MCP server exposes on every account. Enabled by default and + kept available even when ``providers``/``actions`` filters are applied. + Set to ``False`` to remove it. Defaults to True. Returns: Collection of tools matching the filter criteria @@ -1235,6 +1256,7 @@ def fetch_tools( tuple(sorted(account_scope, key=lambda a: (a is None, a))), tuple(sorted(p.lower() for p in providers)) if providers else None, tuple(sorted(actions)) if actions else None, + feedback, ) cached = self._catalog_cache.get(cache_key) if cached is not None: @@ -1257,13 +1279,29 @@ def _fetch_for_account(account: str | None) -> list[StackOneTool]: for future in futures: all_tools.extend(future.result()) + # submit_feedback is a global MCP tool returned once per account fetch. Pull it aside so + # the connector-keyed provider/action filters don't drop it, collapse it to a single + # instance, and re-attach it unless the caller disabled feedback. + feedback_tool = next( + (tool for tool in all_tools if tool.name == _FEEDBACK_TOOL_NAME), None + ) + connector_tools = [tool for tool in all_tools if tool.name != _FEEDBACK_TOOL_NAME] + if providers: - all_tools = [tool for tool in all_tools if self._filter_by_provider(tool.name, providers)] + connector_tools = [ + tool for tool in connector_tools if self._filter_by_provider(tool.name, providers) + ] if actions: - all_tools = [tool for tool in all_tools if self._filter_by_action(tool.name, actions)] + connector_tools = [ + tool for tool in connector_tools if self._filter_by_action(tool.name, actions) + ] + + final_tools = connector_tools + if feedback and feedback_tool is not None: + final_tools = [*connector_tools, feedback_tool] - result = Tools(all_tools) + result = Tools(final_tools) self._catalog_cache[cache_key] = result return result diff --git a/tests/test_feedback.py b/tests/test_feedback.py deleted file mode 100644 index 79ba51e..0000000 --- a/tests/test_feedback.py +++ /dev/null @@ -1,397 +0,0 @@ -"""Tests for feedback tool.""" - -from __future__ import annotations - -import json -import os -import string - -import httpx -import pytest -import respx -from hypothesis import given, settings -from hypothesis import strategies as st - -from stackone_ai.constants import DEFAULT_BASE_URL -from stackone_ai.feedback import create_feedback_tool -from stackone_ai.models import StackOneError -from tests.conftest import TEST_BASE_URL - -# Hypothesis strategies for PBT -# Various whitespace characters including Unicode -WHITESPACE_CHARS = " \t\n\r\u00a0\u2003\u2009" -whitespace_strategy = st.text(alphabet=WHITESPACE_CHARS, min_size=1, max_size=20) - -# Valid non-empty strings (stripped) -valid_string_strategy = st.text( - alphabet=string.ascii_letters + string.digits + "_-", - min_size=1, - max_size=50, -).filter(lambda s: s.strip()) - -# Invalid JSON strings (strings that cannot be parsed as valid JSON at all) -# Note: Python's json module accepts NaN/Infinity by default, so avoid those -invalid_json_strategy = st.one_of( - st.just("{incomplete"), - st.just('{"missing": }'), - st.just('{"key": value}'), - st.just("[1, 2, 3"), - st.just("{trailing}garbage"), - st.just("{missing closing brace"), - st.just("undefined"), - st.just("not valid json"), - st.just("abc123"), - st.just("foo bar baz"), -) - - -class TestFeedbackToolValidation: - """Test suite for feedback tool input validation.""" - - def test_missing_required_fields(self) -> None: - """Test validation errors for missing required fields.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="account_id"): - tool.execute({"feedback": "Great tools!", "tool_names": ["test_tool"]}) - - with pytest.raises(StackOneError, match="tool_names"): - tool.execute({"feedback": "Great tools!", "account_id": "acc_123456"}) - - with pytest.raises(StackOneError, match="feedback"): - tool.execute({"account_id": "acc_123456", "tool_names": ["test_tool"]}) - - def test_empty_and_whitespace_validation(self) -> None: - """Test validation for empty and whitespace-only strings.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="non-empty"): - tool.execute({"feedback": " ", "account_id": "acc_123456", "tool_names": ["test_tool"]}) - - with pytest.raises(StackOneError, match="non-empty"): - tool.execute({"feedback": "Great!", "account_id": " ", "tool_names": ["test_tool"]}) - - with pytest.raises(StackOneError, match="tool_names"): - tool.execute({"feedback": "Great!", "account_id": "acc_123456", "tool_names": []}) - - with pytest.raises(StackOneError, match="At least one tool name"): - tool.execute({"feedback": "Great!", "account_id": "acc_123456", "tool_names": [" ", " "]}) - - def test_multiple_account_ids_validation(self) -> None: - """Test validation with multiple account IDs.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="At least one account ID is required"): - tool.execute({"feedback": "Great tools!", "account_id": [], "tool_names": ["test_tool"]}) - - with pytest.raises(StackOneError, match="At least one valid account ID is required"): - tool.execute({"feedback": "Great tools!", "account_id": ["", " "], "tool_names": ["test_tool"]}) - - def test_invalid_account_id_type(self) -> None: - """Test validation with invalid account ID type (not string or list).""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - # Pydantic validates input types before our custom validator runs - with pytest.raises(StackOneError, match="(account_id|Input should be a valid)"): - tool.execute({"feedback": "Great tools!", "account_id": 12345, "tool_names": ["test_tool"]}) - - with pytest.raises(StackOneError, match="(account_id|Input should be a valid)"): - tool.execute( - {"feedback": "Great tools!", "account_id": {"nested": "dict"}, "tool_names": ["test_tool"]} - ) - - def test_invalid_json_input(self) -> None: - """Test that invalid JSON input raises appropriate error.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="Invalid JSON"): - tool.execute("not valid json {}") - - with pytest.raises(StackOneError, match="Invalid JSON"): - tool.execute("{missing closing brace") - - @given(whitespace=whitespace_strategy) - @settings(max_examples=50) - def test_whitespace_feedback_validation_pbt(self, whitespace: str) -> None: - """PBT: Test validation for various whitespace patterns in feedback.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="non-empty"): - tool.execute({"feedback": whitespace, "account_id": "acc_123456", "tool_names": ["test_tool"]}) - - @given(whitespace=whitespace_strategy) - @settings(max_examples=50) - def test_whitespace_account_id_validation_pbt(self, whitespace: str) -> None: - """PBT: Test validation for various whitespace patterns in account_id.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="non-empty"): - tool.execute({"feedback": "Great!", "account_id": whitespace, "tool_names": ["test_tool"]}) - - @given(whitespace_list=st.lists(whitespace_strategy, min_size=1, max_size=5)) - @settings(max_examples=50) - def test_whitespace_tool_names_validation_pbt(self, whitespace_list: list[str]) -> None: - """PBT: Test validation for lists containing only whitespace tool names.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="At least one tool name"): - tool.execute({"feedback": "Great!", "account_id": "acc_123456", "tool_names": whitespace_list}) - - @given( - whitespace_list=st.lists(whitespace_strategy, min_size=1, max_size=5), - ) - @settings(max_examples=50) - def test_whitespace_account_ids_list_validation_pbt(self, whitespace_list: list[str]) -> None: - """PBT: Test validation for lists containing only whitespace account IDs.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="At least one valid account ID is required"): - tool.execute( - { - "feedback": "Great tools!", - "account_id": whitespace_list, - "tool_names": ["test_tool"], - } - ) - - @given(invalid_json=invalid_json_strategy) - @settings(max_examples=50) - def test_invalid_json_input_pbt(self, invalid_json: str) -> None: - """PBT: Test that various invalid JSON inputs raise appropriate error.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - with pytest.raises(StackOneError, match="Invalid JSON"): - tool.execute(invalid_json) - - @respx.mock - def test_json_string_input(self) -> None: - """Test that JSON string input is properly parsed.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - route = respx.post(f"{TEST_BASE_URL}/ai/tool-feedback").mock( - return_value=httpx.Response(200, json={"message": "Success"}) - ) - - json_string = json.dumps( - {"feedback": "Great tools!", "account_id": "acc_123456", "tool_names": ["test_tool"]} - ) - result = tool.execute(json_string) - assert result == {"message": "Success"} - assert route.called - assert route.calls[0].response.status_code == 200 - - -class TestFeedbackToolExecution: - """Test suite for feedback tool execution.""" - - @respx.mock - def test_single_account_execution(self) -> None: - """Test execution with single account ID.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"} - - route = respx.post(f"{TEST_BASE_URL}/ai/tool-feedback").mock( - return_value=httpx.Response(200, json=api_response) - ) - - result = tool.execute( - { - "feedback": "Great tools!", - "account_id": "acc_123456", - "tool_names": ["data_export", "analytics"], - } - ) - - assert result == api_response - assert route.called - assert route.call_count == 1 - assert route.calls[0].response.status_code == 200 - request = route.calls[0].request - body = json.loads(request.content) - assert body["feedback"] == "Great tools!" - assert body["account_id"] == "acc_123456" - assert body["tool_names"] == ["data_export", "analytics"] - - @respx.mock - def test_call_method_interface(self) -> None: - """Test that the .call() method works correctly.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - api_response = {"message": "Success", "trace_id": "test-trace-id"} - - route = respx.post(f"{TEST_BASE_URL}/ai/tool-feedback").mock( - return_value=httpx.Response(200, json=api_response) - ) - - result = tool.call( - feedback="Testing the .call() method interface.", - account_id="acc_test004", - tool_names=["tool_feedback"], - ) - - assert result == api_response - assert route.called - assert route.call_count == 1 - assert route.calls[0].response.status_code == 200 - - @respx.mock - def test_api_error_handling(self) -> None: - """Test that API errors are handled properly.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - route = respx.post(f"{TEST_BASE_URL}/ai/tool-feedback").mock( - return_value=httpx.Response(401, json={"error": "Unauthorized"}) - ) - - with pytest.raises(StackOneError): - tool.execute( - { - "feedback": "Great tools!", - "account_id": "acc_123456", - "tool_names": ["test_tool"], - } - ) - - assert route.called - assert route.calls[0].response.status_code == 401 - - @respx.mock - def test_multiple_account_ids_execution(self) -> None: - """Test execution with multiple account IDs - both success and mixed scenarios.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"} - - # Test all successful case - route = respx.post(f"{TEST_BASE_URL}/ai/tool-feedback").mock( - return_value=httpx.Response(200, json=api_response) - ) - - result = tool.execute( - { - "feedback": "Great tools!", - "account_id": ["acc_123456", "acc_789012", "acc_345678"], - "tool_names": ["test_tool"], - } - ) - - assert result == { - "message": "Feedback sent to 3 account(s)", - "total_accounts": 3, - "successful": 3, - "failed": 0, - "results": [ - { - "account_id": "acc_123456", - "status": "success", - "result": {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}, - }, - { - "account_id": "acc_789012", - "status": "success", - "result": {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}, - }, - { - "account_id": "acc_345678", - "status": "success", - "result": {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}, - }, - ], - } - assert route.call_count == 3 - assert route.calls[0].response.status_code == 200 - assert route.calls[1].response.status_code == 200 - assert route.calls[2].response.status_code == 200 - - @respx.mock - def test_multiple_account_ids_mixed_success(self) -> None: - """Test execution with multiple account IDs - mixed success and error.""" - tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - def custom_side_effect(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content) - account_id = body.get("account_id") - if account_id == "acc_123456": - return httpx.Response(200, json={"message": "Success"}) - else: - return httpx.Response(401, json={"error": "Unauthorized"}) - - route = respx.post(f"{TEST_BASE_URL}/ai/tool-feedback").mock(side_effect=custom_side_effect) - - result = tool.execute( - { - "feedback": "Great tools!", - "account_id": ["acc_123456", "acc_unauthorized"], - "tool_names": ["test_tool"], - } - ) - - assert result == { - "message": "Feedback sent to 2 account(s)", - "total_accounts": 2, - "successful": 1, - "failed": 1, - "results": [ - { - "account_id": "acc_123456", - "status": "success", - "result": {"message": "Success"}, - }, - { - "account_id": "acc_unauthorized", - "status": "error", - "error": ( - "Client error '401 Unauthorized' for url " - f"'{TEST_BASE_URL}/ai/tool-feedback'\n" - "For more information check: " - "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401" - ), - }, - ], - } - assert route.call_count == 2 - assert route.calls[0].response.status_code == 200 - assert route.calls[1].response.status_code == 401 - - def test_tool_integration(self) -> None: - """Test that feedback tool integrates properly with toolset.""" - feedback_tool = create_feedback_tool(api_key="test_key", base_url=TEST_BASE_URL) - - assert feedback_tool is not None - assert feedback_tool.name == "tool_feedback" - assert "feedback" in feedback_tool.description.lower() - - # Test OpenAI format - openai_format = feedback_tool.to_openai_function() - assert openai_format["type"] == "function" - assert openai_format["function"]["name"] == "tool_feedback" - assert "feedback" in openai_format["function"]["parameters"]["properties"] - assert "account_id" in openai_format["function"]["parameters"]["properties"] - assert "tool_names" in openai_format["function"]["parameters"]["properties"] - - -@pytest.mark.integration -@pytest.mark.skip(reason="Live integration test - requires valid API key with feedback permissions") -def test_live_feedback_submission() -> None: - """Submit feedback to the live API and assert a successful response.""" - import uuid - - api_key = os.getenv("STACKONE_API_KEY") - if not api_key: - pytest.skip("STACKONE_API_KEY env var required for live feedback test") - - base_url = os.getenv("STACKONE_BASE_URL", DEFAULT_BASE_URL) - - feedback_tool = create_feedback_tool(api_key=api_key, base_url=base_url) - assert feedback_tool is not None, "Feedback tool must be available" - - feedback_token = uuid.uuid4().hex[:8] - result = feedback_tool.execute( - { - "feedback": f"CI live test feedback {feedback_token}", - "account_id": f"acc-ci-{feedback_token}", - "tool_names": ["hibob_list_employees"], - } - ) - - assert isinstance(result, dict) - assert result.get("message", "").lower().startswith("feedback") - assert "trace_id" in result and result["trace_id"] diff --git a/tests/test_feedback_tool.py b/tests/test_feedback_tool.py new file mode 100644 index 0000000..a11a6eb --- /dev/null +++ b/tests/test_feedback_tool.py @@ -0,0 +1,88 @@ +"""Tests for the global feedback tool being inherited from the MCP catalog. + +The StackOne MCP server exposes ``submit_feedback`` on every account, so the SDK no longer defines +its own feedback tool — it inherits it from the catalog. These tests patch the MCP fetch so they do +not depend on the vendored node mock server (which only gains ``submit_feedback`` once its submodule +is bumped to the matching release). +""" + +from stackone_ai import StackOneToolSet +from stackone_ai import toolset as toolset_module +from stackone_ai.toolset import _McpToolDefinition + + +def _fake_catalog(endpoint: str, headers: dict[str, str]) -> list[_McpToolDefinition]: + """A connector tool plus the global submit_feedback tool, mirroring the real MCP catalog.""" + return [ + _McpToolDefinition( + name="hibob_list_employees", + description="List employees", + input_schema={"type": "object", "properties": {}}, + ), + _McpToolDefinition( + name="submit_feedback", + description="Submit feedback", + input_schema={"type": "object", "properties": {}}, + ), + ] + + +class TestFeedbackInheritedFromMcp: + def test_included_by_default(self, monkeypatch): + monkeypatch.setattr(toolset_module, "_fetch_mcp_tools", _fake_catalog) + toolset = StackOneToolSet(api_key="test-key", account_id="acc1") + + tool_names = [tool.name for tool in toolset.fetch_tools().to_list()] + + assert "submit_feedback" in tool_names + + def test_excluded_when_feedback_disabled(self, monkeypatch): + monkeypatch.setattr(toolset_module, "_fetch_mcp_tools", _fake_catalog) + toolset = StackOneToolSet(api_key="test-key", account_id="acc1") + + tool_names = [tool.name for tool in toolset.fetch_tools(feedback=False).to_list()] + + assert "submit_feedback" not in tool_names + assert "hibob_list_employees" in tool_names + + def test_survives_provider_filter(self, monkeypatch): + monkeypatch.setattr(toolset_module, "_fetch_mcp_tools", _fake_catalog) + toolset = StackOneToolSet(api_key="test-key", account_id="acc1") + + tool_names = [tool.name for tool in toolset.fetch_tools(providers=["hibob"]).to_list()] + + assert "submit_feedback" in tool_names + assert "hibob_list_employees" in tool_names + + def test_single_instance_across_accounts(self, monkeypatch): + monkeypatch.setattr(toolset_module, "_fetch_mcp_tools", _fake_catalog) + toolset = StackOneToolSet(api_key="test-key") + + tool_names = [tool.name for tool in toolset.fetch_tools(account_ids=["acc1", "acc2"]).to_list()] + + assert tool_names.count("submit_feedback") == 1 + + +class TestFeedbackInSearchAndExecute: + """search-and-execute agents inherit submit_feedback alongside the meta tools.""" + + def test_build_tools_includes_feedback_by_default(self, monkeypatch): + monkeypatch.setattr(toolset_module, "_fetch_mcp_tools", _fake_catalog) + toolset = StackOneToolSet(api_key="test-key", account_id="acc1", search={"method": "local"}) + + tools = toolset._build_tools(account_ids=["acc1"]) + tool_names = [tool.name for tool in tools.to_list()] + + # the two meta tools + the inherited feedback tool + assert len(tool_names) == 3 + assert "submit_feedback" in tool_names + + def test_build_tools_excludes_feedback_when_disabled(self, monkeypatch): + monkeypatch.setattr(toolset_module, "_fetch_mcp_tools", _fake_catalog) + toolset = StackOneToolSet(api_key="test-key", account_id="acc1", search={"method": "local"}) + + tools = toolset._build_tools(account_ids=["acc1"], feedback=False) + tool_names = [tool.name for tool in tools.to_list()] + + assert "submit_feedback" not in tool_names + assert len(tool_names) == 2