diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9499d96..67bb203 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -114,7 +114,7 @@ jobs: env: # Pinned to a commit on stackone-ai-node's main, not the branch, so a Node change never # silently changes what this job grades. Bump it deliberately. - NODE_SDK_REF: c41b1009b10287057ac72070f69ee147fad3be6b + NODE_SDK_REF: 099fd1e3405f8a56125f9691e20ccd1333dda12f steps: - name: Check conformance token is configured env: @@ -134,7 +134,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: StackOneHQ/sdk-conformance - ref: 5f60cc0dfdf4d1a25afad93a68eeefe30e89568d + ref: 4ead1c25bb443ef1fd1986d5f053b2d1475d0e90 token: ${{ secrets.CONFORMANCE_REPO_TOKEN }} path: sdk-conformance persist-credentials: false @@ -161,8 +161,9 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # pnpm from sdk-conformance's packageManager; in each SDK checkout pnpm switches to that + # repo's own packageManager version, so one setup serves all three installs. with: - version: 10.26.0 package_json_file: sdk-conformance/package.json # 24, not 22: the Node SDK's devEngines pins Node ^24.11.0, and the suite runs on it too. diff --git a/MIGRATION.md b/MIGRATION.md index 759a075..a626af1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -82,8 +82,9 @@ Some names were public on `main` for a while before 3.0 but never in a release: Client-side search is gone: the BM25/TF-IDF index, the semantic search client, the `search=` constructor argument, and the `tool_search`/`tool_execute` meta tools. The -server's `*_search_actions` tool now does the search, and `toolset.execute()` runs an -action by id through the server's `*_execute_action` tool. +server's `stackone_search_actions` tool now does the search, and `toolset.execute()` +runs an action by id through the server's `stackone_execute_action` tool. Each is one +call spanning every account in scope. ```python # 2.x @@ -100,17 +101,19 @@ hit = hits[0] result = toolset.execute( hit["action_id"], {"query": {"page_size": 25}}, # the nested form in hit["input_schema"] - account_ids=[hit["account_id"]], + account_ids=[hit["available_accounts"][0]["id"]], session_id=hit.get("session_id"), ) ``` -`search()` returns dicts carrying `action_id` and the `account_id` that found it, plus -`description`, `similarity_score`, `input_schema`, `example_request` and `session_id` -when the server sends them. `top_k` caps the whole result, as in 2.x, after ranking -across every connector; the same action linked on two accounts is two hits. When an -action's connector is linked on more than one account, `execute()` raises -`ToolsetConfigError` unless `account_ids` picks one, such as `[hit["account_id"]]`. +`search()` returns the server's hits: dicts carrying `action_id`, `provider` and +`available_accounts` (each `{"id", "name"}`), plus `description`, `similarity_score`, +`input_schema` and `example_request` when the server sends them, and the search's +`session_id`. `top_k` caps the whole result, as in 2.x; the same action linked on two +accounts is one hit naming both. When an action's connector is linked on more than one +account in scope, the server refuses to pick one and `execute()` raises +`StackOneAPIError` unless `account_ids` names one, such as +`[hit["available_accounts"][0]["id"]]`. `toolset.execute()` has a new signature. It used to take a meta tool name, such as `"tool_execute"`, with arguments as a JSON string or a dict. It now takes @@ -118,8 +121,9 @@ action's connector is linked on more than one account, `execute()` raises must be a dict. It raises instead of returning `{"error": ...}`: `ToolsetConfigError` before any request when `action_id`, `arguments` or `session_id` is malformed, `ToolArgumentsError` when the arguments cannot be encoded as JSON, `ToolsetLoadError` when -no linked connector matches the action, and `StackOneAPIError` when the action fails, -including when the server rejects the arguments. +the server did not serve `stackone_execute_action` (the project's policy disables +search_execute), and `StackOneAPIError` when the action fails, including when the server +rejects the arguments or the action id. To give a model the search and execute tools, set the tool mode on the toolset. `openai()`, `langchain()` and `pydantic_ai()` no longer take `mode=`. @@ -131,7 +135,8 @@ openai_tools = toolset.openai(mode="search_and_execute") # ...for each tool call the model makes: toolset.execute(call.function.name, call.function.arguments) -# 3.0: two tools per connector, served by the server +# 3.0: stackone_search_actions and stackone_execute_action, served by the server and +# spanning every account in scope (at most 50), with stackone_list_accounts for several toolset = StackOneToolSet(tool_mode="search_execute") tools = toolset.fetch_tools() openai_tools = tools.to_openai() @@ -139,6 +144,12 @@ openai_tools = tools.to_openai() messages.extend(tools.execute_openai_tool_calls(message.tool_calls)) ``` +For a single account the server serves that account's own pair, +`__search_actions` and `_execute_action`; the SDK presents them as +`stackone_search_actions` and `stackone_execute_action`, so code and prompts use the same +names for one account or many. A scope of more than 50 accounts raises +`ToolsetConfigError` before any request. + `get_search_tool()` and `semantic_client` are removed. Call `toolset.search()` instead. ## Results @@ -223,7 +234,7 @@ an ordinary argument that happens to be named `headers`: it skips header filteri sent as given, subject to the same JSON-value check as every other argument. A `headers_` argument is dropped with a warning when its value is a list or dict. -`*_execute_action` serves an open `headers` object, so `toolset.execute()` passes your +`stackone_execute_action` serves an open `headers` object, so `toolset.execute()` passes your own headers on to the action, with the exception of those four: ```python @@ -316,7 +327,7 @@ tool.execute({"id": "1"}, options={"feedback_session_id": "chat-42"}) # 3.0 hit = toolset.search("list workers")[0] -toolset.execute(hit["action_id"], account_ids=[hit["account_id"]], session_id=hit.get("session_id")) +toolset.execute(hit["action_id"], session_id=hit.get("session_id")) toolset.submit_feedback( "positive", [hit["action_id"]], diff --git a/README.md b/README.md index e1f55f8..e3d41a0 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,14 @@ hits = toolset.search("list recent comments", top_k=3) # "example_request": {"action_id": "linear_list_comments"}, # "input_schema": {"type": "object", "properties": {"body": {...}}}, # "session_id": "3f9c...", -# "account_id": "acc-123"}, ...] +# "available_accounts": [{"id": "acc-123", "name": "Acme Linear"}]}, ...] hit = hits[0] # 2. Run it. Build the arguments from input_schema. result = toolset.execute( hit["action_id"], {"body": {"variables": {"first": 25}}}, - account_ids=[hit["account_id"]], + account_ids=[hit["available_accounts"][0]["id"]], session_id=hit.get("session_id"), ) result["result"]["data"] # as the server wrote it: {"isError": false, "result": ...}; a failure raises @@ -69,15 +69,28 @@ result["result"]["data"] # as the server wrote it: {"isError": false, "result": toolset.submit_feedback("positive", [hit["action_id"]], session_id=hit.get("session_id")) ``` -`search()` asks every linked connector and returns actions ranked by -`similarity_score`, so a catalog of hundreds of tools never has to fit in a -model's context. This is the recommended way to use the SDK. `top_k` (1–50, default -10) caps the whole result, after ranking across every connector. - -Each hit carries the `account_id` of the account that found it, and the same action -linked on two accounts is two hits. When an action's connector is linked on more than -one account, `execute()` raises `ToolsetConfigError` rather than pick one: pass the -hit's account as `account_ids=[hit["account_id"]]`. +`search()` makes one call to StackOne's `stackone_search_actions` tool, which searches +every linked connector and returns actions ranked by `similarity_score`, so a catalog +of hundreds of tools never has to fit in a model's context. This is the recommended +way to use the SDK. `top_k` (1–50, default 10) caps the whole result. + +Each hit names the accounts that can run it in `available_accounts`, so an action +linked on two accounts is one hit. `execute()` makes one call to +`stackone_execute_action`. When the action's connector is linked on more than one +account in scope, the server refuses to pick one and `execute()` raises +`StackOneAPIError`: pass the account to use as `account_ids=[...]`, one of the hit's +`available_accounts`. + +To hand a model the same two tools, build the toolset with +`tool_mode="search_execute"`: `fetch_tools()` then returns `stackone_search_actions` +and `stackone_execute_action`, spanning every account in scope in one request, up to +50 accounts; more raise `ToolsetConfigError`, so pass `account_ids` to narrow the scope. +Several accounts also get `stackone_list_accounts`. For one account the server serves +that account's own search and execute pair, which the SDK presents under the same two +names: their descriptions, schemas and results read in those names, hits gain +`available_accounts` and `provider`, and a `stackone_account_id` or `provider` argument +naming that account or its connector is dropped, while any other raises +`ToolArgumentsError` before a request is made. Each hit carries the `session_id` of the search that found it, when the server issued one, so read it with `hit.get("session_id")`. Passing it to diff --git a/examples/pydantic_ai_integration.py b/examples/pydantic_ai_integration.py index 7e2e684..1ebe5d7 100644 --- a/examples/pydantic_ai_integration.py +++ b/examples/pydantic_ai_integration.py @@ -28,17 +28,9 @@ from stackone_ai import StackOneToolSet -def pydantic_ai_integration() -> None: - for var in ["STACKONE_API_KEY", "STACKONE_ACCOUNT_ID", "OPENAI_API_KEY"]: - if not os.getenv(var): - print(f"Set {var} to run this example.") - return - - toolset = StackOneToolSet() - tools = toolset.fetch_tools( - actions=["*_list_*"], - account_ids=[os.environ["STACKONE_ACCOUNT_ID"]], - ).to_pydantic_ai() +def individual_tools(account_id: str) -> None: + """One Pydantic AI tool per action: suits a handful of actions.""" + tools = StackOneToolSet().fetch_tools(actions=["*_list_*"], account_ids=[account_id]).to_pydantic_ai() agent = Agent( "openai:gpt-5.4", @@ -46,7 +38,36 @@ def pydantic_ai_integration() -> None: tools=tools, ) result = agent.run_sync("Use one of your tools to list a few records, then summarise them.") - print(f"Result:\n{result.output}") + print(f"Individual tools:\n{result.output}\n") + + +def search_and_execute(account_id: str) -> None: + """StackOne's search and execute tools: the model finds an action, then runs it.""" + toolset = StackOneToolSet(tool_mode="search_execute") + tools = toolset.fetch_tools(account_ids=[account_id]).to_pydantic_ai() + + agent = Agent( + "openai:gpt-5.4", + system_prompt=( + "You answer by calling tools. First call stackone_search_actions to find an action, " + "then call stackone_execute_action with its action_id and arguments built from its " + "example_request." + ), + tools=tools, + ) + result = agent.run_sync("List a few records from any of my connected apps, then summarise them.") + print(f"Search and execute:\n{result.output}") + + +def pydantic_ai_integration() -> None: + for var in ["STACKONE_API_KEY", "STACKONE_ACCOUNT_ID", "OPENAI_API_KEY"]: + if not os.getenv(var): + print(f"Set {var} to run this example.") + return + + account_id = os.environ["STACKONE_ACCOUNT_ID"] + individual_tools(account_id) + search_and_execute(account_id) if __name__ == "__main__": diff --git a/examples/search_and_execute.py b/examples/search_and_execute.py index 8d3a03c..f28b724 100644 --- a/examples/search_and_execute.py +++ b/examples/search_and_execute.py @@ -71,13 +71,12 @@ def search_and_execute() -> None: print(f"\narguments: {json.dumps(arguments)}") # session_id links this call, and the feedback below, to the search that found - # the action. It is optional: leave it out and each call stands alone. account_id - # runs it on the account that found it: with the same provider linked on two - # accounts, execute() refuses to pick one. + # the action. It is optional: leave it out and each call stands alone. + # available_accounts names the accounts that can run it: with the same provider + # linked on two accounts, the server refuses to pick one, so name the first. session_id = best.get("session_id") - result = toolset.execute( - best["action_id"], arguments, account_ids=[best["account_id"]], session_id=session_id - ) + account_id = best["available_accounts"][0]["id"] + result = toolset.execute(best["action_id"], arguments, account_ids=[account_id], session_id=session_id) print(f"\nresult: {json.dumps(result, default=str)[:300]}...") try: diff --git a/stackone_ai/tools.py b/stackone_ai/tools.py index 98421dd..486b6aa 100644 --- a/stackone_ai/tools.py +++ b/stackone_ai/tools.py @@ -32,6 +32,8 @@ from pydantic import BaseModel, Field, PrivateAttr from stackone_ai.types import ( + EXECUTE_ACTION_TOOL_NAME, + SEARCH_ACTIONS_TOOL_NAME, ExecuteConfig, Headers, JsonDict, @@ -883,7 +885,7 @@ def parse_tool_result(result: Any, name: str) -> JsonDict: wins when both are present. Parts that are not text (images, embedded resources) are kept under ``content_parts`` as plain JSON, the form they arrived in. - The result is returned as the server wrote it. For an action tool, ``*_execute_action`` + The result is returned as the server wrote it. For an action tool, ``stackone_execute_action`` and feedback that is ``{"isError": false, "result": ..., "defenderMetadata"?: ..., "policyMetadata"?: ...}``; a search result is bare JSON. @@ -1084,7 +1086,7 @@ def _declared_headers(self) -> tuple[set[str] | None, set[str], bool]: property as something other than an object, an ordinary field that happens to be named ``headers`` rather than a header container. ``nested`` is ``None`` when the ``headers`` object is open — ``type: "object"`` with - no ``properties`` and ``additionalProperties`` not ``false``, as ``*_execute_action`` + no ``properties`` and ``additionalProperties`` not ``false``, as ``stackone_execute_action`` serves it — which declares every name. Any other schema with no ``properties`` declares none. """ @@ -1378,10 +1380,13 @@ class StackOneMcpTool(StackOneTool): """A tool executed over MCP ``tools/call`` on the endpoint that listed it. Every tool the toolset builds is one of these: per-action tools, the - ``*_search_actions`` / ``*_execute_action`` meta tools and ``stackone_submit_feedback``. + ``search_execute`` trio and ``stackone_submit_feedback``. """ _endpoint: str = PrivateAttr() + # The name the server listed the tool under, which tools/call must use: the same as + # ``name`` except for a one-account pair presented under the trio's names. + _served_name: str = PrivateAttr() # Set by the toolset that built the tool: the end-user id it recorded for an account. # Looked up per request, so a tool keeps up with a later GET /accounts and with # set_account_id(). @@ -1401,6 +1406,7 @@ def __init__( account_id: str | None, headers: Headers | None = None, timeout: float = 60.0, + served_name: str | None = None, ) -> None: super().__init__( description=description, @@ -1410,6 +1416,7 @@ def __init__( _account_id=account_id, ) self._endpoint = endpoint + self._served_name = served_name or name def _prepare_headers(self) -> Headers: """The request headers: the configured extras first, then the SDK's own. @@ -1484,7 +1491,9 @@ def execute(self, arguments: str | JsonDict | None = None) -> JsonDict: def call() -> JsonDict: timeout = self._execute_config.timeout - return call_mcp_tool(self._endpoint, self._prepare_headers(), self.name, parsed, timeout=timeout) + return call_mcp_tool( + self._endpoint, self._prepare_headers(), self._served_name, parsed, timeout=timeout + ) account_id = self._account_id without_end_user = account_id is not None and self._recorded_end_user_id() is None @@ -1503,6 +1512,124 @@ def call() -> JsonDict: return call() +@dataclass(frozen=True) +class AccountPair: + """The search and execute tools the server serves a ``search_execute`` listing of one account. + + Several accounts get the trio; one gets its own pair, named + ``__search_actions`` and ``__execute_action``. The + SDK presents the pair under the trio's names, so a caller and a model see one search and + one execute tool however many accounts are in scope. + """ + + account_id: str + connector: str + + @classmethod + def serving(cls, served_name: str, account_id: str) -> AccountPair | None: + """The pair a served tool belongs to, if it is one of an account's pair.""" + for suffix in (f"_{account_id}_search_actions", f"_{account_id}_execute_action"): + if served_name.endswith(suffix) and len(served_name) > len(suffix): + return cls(account_id, served_name[: -len(suffix)]) + return None + + @property + def trio_names(self) -> dict[str, str]: + """Each served name, and the trio name it is presented as.""" + served = f"{self.connector}_{self.account_id}" + return { + f"{served}_search_actions": SEARCH_ACTIONS_TOOL_NAME, + f"{served}_execute_action": EXECUTE_ACTION_TOOL_NAME, + } + + def renamed(self, value: Any) -> Any: + """``value`` with every served name in its strings, however deep, read as its trio name. + + Descriptions, schemas and results name the pair's tools to each other ("run a hit with + ..."); left as served, they would send a model to a tool it was never given. + """ + if isinstance(value, str): + for served, trio_name in self.trio_names.items(): + value = value.replace(served, trio_name) + return value + if isinstance(value, dict): + return {self.renamed(key): self.renamed(item) for key, item in value.items()} + if isinstance(value, list): + return [self.renamed(item) for item in value] + return value + + def presented(self, tool_def: McpToolDefinition) -> McpToolDefinition: + """A served tool of the pair as the trio tool it stands for.""" + return McpToolDefinition( + name=self.trio_names[tool_def.name], + description=self.renamed(tool_def.description), + input_schema=self.renamed(tool_def.input_schema), + ) + + +class StackOneAccountPairTool(StackOneMcpTool): + """One tool of an account's pair, presented and called as the trio tool it stands for. + + It takes the trio's arguments: ``stackone_account_id`` and ``provider`` choose among + accounts, and with one in scope they can only name that account, so they are checked and + dropped, never sent. Its results read in trio names, and a search's hits carry the + ``available_accounts`` and ``provider`` a trio search gives them. + """ + + _pair: AccountPair = PrivateAttr() + + def __init__(self, *, pair: AccountPair, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._pair = pair + + def execute(self, arguments: str | JsonDict | None = None) -> JsonDict: + """Call the served tool with the trio's arguments, answering as the trio tool would. + + Raises: + ToolArgumentsError: If ``stackone_account_id`` names another account, or + ``provider`` another connector, before any request is made. + StackOneAPIError: As :meth:`StackOneMcpTool.execute`, its text in trio names. + """ + scoped = self._without_scope_arguments(self._parse_arguments(arguments)) + try: + result = self._pair.renamed(super().execute(scoped)) + except StackOneAPIError as exc: + renamed = StackOneAPIError( + self._pair.renamed(str(exc)), exc.status_code, self._pair.renamed(exc.response_body) + ) + raise renamed from exc + return self._with_hits_scoped(result) if self.name == SEARCH_ACTIONS_TOOL_NAME else result + + def _without_scope_arguments(self, arguments: JsonDict) -> JsonDict: + """The arguments without ``stackone_account_id`` and ``provider``, once each is checked + to name this pair's account and connector. ``None`` is not given, as for ``session_id``.""" + scoped = dict(arguments) + account = scoped.pop("stackone_account_id", None) + if account is not None and account != self._pair.account_id: + raise ToolArgumentsError( + f"stackone_account_id {_json_text(account)} is not an account {_json_text(self.name)} " + f"can run on; it runs on {_json_text(self._pair.account_id)}." + ) + provider = scoped.pop("provider", None) + if provider is not None and not ( + isinstance(provider, str) and provider.lower() == self._pair.connector.lower() + ): + raise ToolArgumentsError( + f"provider {_json_text(provider)} is not linked in this account scope, " + f"which has {_json_text(self._pair.connector)}." + ) + return scoped + + def _with_hits_scoped(self, result: JsonDict) -> JsonDict: + """A search result whose hits name the account and connector they run on, when the + server left them out.""" + hits = result.get("actions") + if not isinstance(hits, list): + return result + scope = {"available_accounts": [{"id": self._pair.account_id}], "provider": self._pair.connector} + return {**result, "actions": [{**scope, **hit} if isinstance(hit, dict) else hit for hit in hits]} + + def _read_openai_tool_call(call: Any) -> tuple[str, str, str | JsonDict]: """Pull (id, name, arguments) from an openai ToolCall object or its dict form.""" if isinstance(call, dict): diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index 40bbf14..0780659 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -6,20 +6,24 @@ import contextlib import copy import fnmatch +import functools import logging import os import threading import time -from collections.abc import Callable, Collection, Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass, field from typing import Any, TypeVar +from urllib.parse import urlencode import httpx from stackone_ai.tools import ( + AccountPair, McpToolDefinition, RateLimitRetryingClient, RateLimitTimeout, + StackOneAccountPairTool, StackOneMcpTool, StackOneTool, Tools, @@ -34,6 +38,8 @@ ) from stackone_ai.types import ( DEFAULT_BASE_URL, + EXECUTE_ACTION_TOOL_NAME, + SEARCH_ACTIONS_TOOL_NAME, SUBMIT_FEEDBACK_TOOL_NAME, ExecuteToolsConfig, FeedbackCategory, @@ -58,20 +64,20 @@ _Listing = tuple[McpToolDefinition, str | None, str] """A served tool, the account that listed it, and the endpoint it was listed from.""" -_RetryFailed = Callable[["_Catalog"], Collection[str | None]] -"""Given a cached catalog, the accounts marked failed in it to list again now.""" - -# The search_actions meta tool's served schema caps top_k at 50. +# stackone_search_actions' served schema caps top_k at 50. _MAX_TOP_K = 50 +# The server spans at most this many accounts in one search_execute request. +_MAX_SEARCH_EXECUTE_ACCOUNTS = 50 + FAILED_ACCOUNT_RETRY_SECONDS = 30.0 -"""How long an account that failed to list tools is left out of a cached catalog. +"""How long an account that failed to list tools is left out of a cached individual-mode +catalog. Its healthy siblings' listings are cached all the same. A later call within this window serves them without the failed account, and without warning about it again; the first -call after it lists the failed account again. ``execute()`` does not wait: it lists one -that could serve its action again straight away, unless a provider lookup failed to name -it within the window. +call after it lists the failed account again. A ``search_execute`` catalog is one listing +for the whole scope, so it has no account to leave out. """ # Looked up at call time, so tests can move it on rather than wait. @@ -80,19 +86,17 @@ @dataclass(frozen=True) class _Catalog: - """A cached catalog: each healthy account's listing, and when and how each failed one failed.""" + """A cached catalog: each healthy account's listing, and when each failed one failed. + + A ``search_execute`` catalog is one listing for the whole scope, under ``None``. + """ listings: dict[str | None, list[_Listing]] failed_at: dict[str | None, float] = field(default_factory=dict) - errors: dict[str | None, Exception] = field(default_factory=dict) def in_order(self, account_scope: list[str | None]) -> list[_Listing]: return [listing for account in account_scope for listing in self.listings.get(account, [])] - def failures(self) -> dict[str | None, Exception]: - """Each account left out of this catalog, with the error it failed with.""" - return {account: self.errors[account] for account in self.failed_at if account in self.errors} - _Item = TypeVar("_Item") _Result = TypeVar("_Result") @@ -181,8 +185,12 @@ def __init__( Default: 60. Takes precedence over ``execute.timeout`` if set. Increase for slow providers (e.g. Workday). tool_mode: How the endpoint lists tools. ``"search_execute"`` returns - two meta tools per connector instead of one tool per action, - keeping the catalog small enough for a model's context. + ``stackone_list_accounts``, ``stackone_search_actions`` and + ``stackone_execute_action``, spanning every account in scope (at most + 50), instead of one tool per action, keeping the catalog small enough + for a model's context. For one account the server serves that account's + own search and execute pair, presented under the same two names, and no + ``stackone_list_accounts``. headers: Extra HTTP headers sent with every request this toolset makes (``GET /accounts`` and every MCP request). ``Authorization``, ``x-account-id`` and ``User-Agent`` are always the SDK's own and @@ -248,12 +256,8 @@ def __init__( # the accounts, not the catalog. self._end_user_ids: dict[str, str] = {} # Each account's provider, from the same GET /accounts and replaced with it: what - # execute() reads to tell whether an account that failed to list could have served - # an action. + # fetch_tools(providers=...) narrows a search_execute scope by. self._providers: dict[str, str] = {} - # When a provider lookup last failed to name each account, which execute() then - # neither looks up nor lists again early for FAILED_ACCOUNT_RETRY_SECONDS. - self._provider_misses: dict[str, float] = {} # The GET /accounts in flight, if any: an account's end user is looked up by joining # it rather than making another. self._fetching_accounts: concurrent.futures.Future[list[JsonDict]] | None = None @@ -293,13 +297,11 @@ def clear_catalog_cache(self) -> None: Call when linked accounts change outside of ``set_accounts`` or when you need to force a fresh fetch from the StackOne MCP endpoint. The end-user ids - recorded from GET /accounts are kept until the next one replaces them; the accounts - a provider lookup failed to name are forgotten. + and providers recorded from GET /accounts are kept until the next one replaces them. """ with self._cache_lock: self._cache_generation += 1 self._catalog_cache.clear() - self._provider_misses.clear() self._discovered_account_ids = None self._discovering = None @@ -317,7 +319,11 @@ def fetch_tools( account_ids: Optional list of account IDs to filter by. If not provided, uses accounts set via set_accounts() providers: Optional list of provider names (e.g., ['hibob', 'bamboohr']). - Case-insensitive matching. + Case-insensitive matching. In individual mode it keeps the tools named + after these providers; in ``search_execute`` mode, whose tools are named + after no provider, it narrows the accounts listed to those whose + ``GET /accounts`` provider is one of these, looking them up first if + needed. No matching account means no tools. actions: Optional list of action patterns with glob support (e.g., ['*_list_employees', 'hibob_create_employees']) @@ -332,7 +338,9 @@ def fetch_tools( after retries, even when only one of several accounts is; or with the API's status when every account fails with that same status, so a caller can tell a revoked key's 401 from a 429. Other per-account failures skip that account - with a warning, and leave it out for ``FAILED_ACCOUNT_RETRY_SECONDS``. + with a warning, and leave it out for ``FAILED_ACCOUNT_RETRY_SECONDS``. In + ``search_execute`` mode every account is listed in one request, so any + failure is raised. A 429 is retried up to three times, after the server's ``Retry-After`` (capped at 30 seconds) or else a 1s, 2s, 4s backoff with jitter. A wait that would not end @@ -354,22 +362,6 @@ def fetch_tools( ``include_non_shared=True``. """ mode = self._tool_mode if mode is _UNSET else mode - tools, _ = self._fetch_tools(account_ids, providers, actions, mode, retry_failed=None) - return tools - - def _fetch_tools( - self, - account_ids: list[str] | None, - providers: list[str] | None, - actions: list[str] | None, - mode: ToolMode | None, - *, - retry_failed: _RetryFailed | None, - ) -> tuple[Tools, dict[str | None, Exception]]: - """:meth:`fetch_tools`, and each account in scope it left out with the error it failed - with. ``retry_failed`` picks, from a cached catalog, the accounts marked failed to list - again now rather than once they are due. - """ try: # Captured before account resolution, which may discover accounts: a # clear_catalog_cache() landing mid-discovery must stop the catalog it @@ -378,15 +370,23 @@ def _fetch_tools( account_scope: list[str | None] = sorted( dict.fromkeys(self._resolve_account_ids(account_ids)), key=lambda a: (a is None, a) ) + if mode == "search_execute" and providers: + # The trio is named after no provider: narrow the accounts it spans instead. + account_scope = [*self._accounts_of_providers(account_scope, providers)] + if not account_scope: + return Tools([]) + providers = None # Keyed on what was fetched, not on how it is filtered: providers and # actions narrow the list in memory, so they must not force a refetch. # base_url and api_key belong here — leaving them out meant reassigning # either one kept serving the old catalog, still pointed at the old host. - listings, failures = self._catalog(account_scope, mode, generation, retry_failed=retry_failed) + listings = self._catalog(account_scope, mode, generation) + # One account is served its own search and execute pair rather than the trio. + pair_account = account_scope[0] if mode == "search_execute" and len(account_scope) == 1 else None all_tools = [ - self._create_tool(tool_def, account, endpoint) + self._create_tool(tool_def, account, endpoint, pair_account) for tool_def, account, endpoint in self._dedupe_global_tools(listings) ] @@ -396,7 +396,7 @@ def _fetch_tools( if actions: all_tools = [tool for tool in all_tools if self._filter_by_action(tool.name, actions)] - return Tools(all_tools), failures + return Tools(all_tools) except (ToolsetError, StackOneError): # StackOneAPIError carries the HTTP status. Re-wrapping it below would throw @@ -450,12 +450,11 @@ def _catalog( account_scope: list[str | None], mode: ToolMode | None, generation: int, - *, - retry_failed: _RetryFailed | None = None, - ) -> tuple[list[_Listing], dict[str | None, Exception]]: + ) -> list[_Listing]: """Every scoped account's catalog, from the cache where it can be. - One unusable account must not cost the caller every other account's tools, so a + In ``search_execute`` mode, see :meth:`_search_execute_catalog`. Otherwise one + unusable account must not cost the caller every other account's tools, so a failing account is skipped with a warning. The healthy accounts' listings are cached all the same, and the failed account is left out, silently, until ``FAILED_ACCOUNT_RETRY_SECONDS`` have passed, when the next call lists it again. Not @@ -469,26 +468,25 @@ def _catalog( ``generation`` is the cache generation as of the start of the call that resolved ``account_scope``: a clear_catalog_cache() during account discovery must stop the catalog this scope resolves to from being cached, same as the discovered list. - - Returned with each account left out and the error it failed with. ``retry_failed`` is - given the cached catalog and picks accounts marked failed in it to list again now, - without waiting for them to be due. """ key = self._cache_key(account_scope, mode) + if mode == "search_execute": + return self._search_execute_catalog( + [account for account in account_scope if account is not None], key, generation + ) with self._cache_lock: cached = self._catalog_cache.get(key) now = _clock() if cached is None: due = account_scope else: - retried = set(retry_failed(cached)) if retry_failed else set() due = [ account for account, failed_at in cached.failed_at.items() - if account in retried or now - failed_at >= FAILED_ACCOUNT_RETRY_SECONDS + if now - failed_at >= FAILED_ACCOUNT_RETRY_SECONDS ] if not due: - return cached.in_order(account_scope), cached.failures() + return cached.in_order(account_scope) # No param-style pin: arguments are sent verbatim and the server maps them with its # own reverse map, so the model sees whatever style the server serves. @@ -522,21 +520,18 @@ def _store(catalog: _Catalog) -> None: if existing is not None: merged_listings = {**existing.listings, **catalog.listings} merged_failed_at = {**existing.failed_at, **catalog.failed_at} - merged_errors = {**existing.errors, **catalog.errors} for account in catalog.listings: merged_failed_at.pop(account, None) - merged_errors.pop(account, None) - catalog = _Catalog(merged_listings, merged_failed_at, merged_errors) + catalog = _Catalog(merged_listings, merged_failed_at) self._catalog_cache[key] = catalog if len(account_scope) == 1: catalog = _Catalog({account_scope[0]: _fetch_for_account(account_scope[0])}) _store(catalog) - return catalog.in_order(account_scope), {} + return catalog.in_order(account_scope) listings = dict(cached.listings) if cached else {} failed_at = dict(cached.failed_at) if cached else {} - errors = dict(cached.errors) if cached else {} failures: list[tuple[str | None, Exception]] = [] for account, outcome in _fan_out(_fetch_for_account, due): if isinstance(outcome, Exception): @@ -544,18 +539,93 @@ def _store(catalog: _Catalog) -> None: else: listings[account] = outcome failed_at.pop(account, None) - errors.pop(account, None) if not listings: raise _all_accounts_failed(failures) failed_now = _clock() for account, failure in failures: logger.warning("Skipping account that failed to list tools — %s: %s", account, failure) failed_at[account] = failed_now - errors[account] = failure - catalog = _Catalog(listings, failed_at, errors) + catalog = _Catalog(listings, failed_at) _store(catalog) - return catalog.in_order(account_scope), catalog.failures() + return catalog.in_order(account_scope) + + def _search_execute_catalog( + self, account_scope: list[str], key: tuple[Any, ...], generation: int + ) -> list[_Listing]: + """The ``search_execute`` tools spanning every scoped account, from one listing: the + trio for several accounts, the account's own pair for one. + + The server spans every account the request names behind one search and one execute + tool, so there is no account to skip: a failure is raised. A refusal for want of the + end user of an account sent without one is answered as in individual mode: the end + users are looked up, and the scope listed once more. The tools bind no account and + execute on the endpoint that listed them, which names the accounts. + + Raises: + ToolsetConfigError: If the scope has more accounts than one request can span, + before any request is made. + """ + if len(account_scope) > _MAX_SEARCH_EXECUTE_ACCOUNTS: + raise ToolsetConfigError( + f"{len(account_scope)} accounts are in scope, but search and execute span at most " + f"{_MAX_SEARCH_EXECUTE_ACCOUNTS} in one request. Pass account ids to narrow the scope." + ) + with self._cache_lock: + cached = self._catalog_cache.get(key) + if cached is not None: + return cached.listings[None] + + def _list() -> list[_Listing]: + endpoint = self._search_execute_endpoint(account_scope) + listed = fetch_mcp_tools(endpoint, self._build_mcp_headers(None), timeout=self._timeout) + return [(tool_def, None, endpoint) for tool_def in listed] + + without_end_user = [account for account in account_scope if self._end_user_id(account) is None] + try: + listings = _list() + except StackOneAPIError as exc: + refused = next((a for a in without_end_user if is_end_user_refusal(exc, a)), None) + if refused is None or not self._look_up_end_user(refused, exc): + raise + listings = _list() + + with self._cache_lock: + if generation == self._cache_generation: + self._catalog_cache[key] = _Catalog({None: listings}) + return listings + + def _search_execute_endpoint(self, account_scope: list[str]) -> str: + """The MCP endpoint listing search and execute for these accounts. + + The accounts go as one ``x-account-id`` query value, joined by commas, and the recorded + end user of each non-shared one as a repeated ``x-end-user-id``: the server matches each + end user exactly, so they cannot be joined, and as headers some HTTP stacks would join + them. + """ + end_users = dict.fromkeys( + end_user for account in account_scope if (end_user := self._end_user_id(account)) is not None + ) + query = [ + ("tool-mode", "search_execute"), + ("x-account-id", ",".join(account_scope)), + *(("x-end-user-id", end_user) for end_user in end_users), + ] + return f"{self.base_url.rstrip('/')}/mcp?{urlencode(query)}" + + def _accounts_of_providers(self, account_scope: list[str | None], providers: list[str]) -> list[str]: + """The scoped accounts whose provider, as ``GET /accounts`` names it, is one of + ``providers`` (case-insensitive). One ``GET /accounts`` is joined or made first when + any of their providers is not yet known. + """ + accounts = [account for account in account_scope if account is not None] + with self._cache_lock: + unknown = any(account not in self._providers for account in accounts) + if unknown: + self._join_accounts_listing() + wanted = {provider.lower() for provider in providers} + with self._cache_lock: + return [account for account in accounts if self._providers.get(account, "").lower() in wanted] @staticmethod def _dedupe_global_tools( @@ -586,25 +656,6 @@ def _cache_key(self, account_scope: list[str | None], mode: ToolMode | None) -> self.api_key, ) - def _meta_tools(self, suffix: str, account_ids: list[str] | None = None) -> list[StackOneTool]: - """The server's per-connector meta tools, regardless of this toolset's mode. - - The mode is passed down rather than assigned to ``self``. Flipping instance - state here raced with any concurrent ``fetch_tools()``: that call could read - the flipped mode partway through and cache search_execute meta tools under the - individual-mode key, permanently, for every later caller. - """ - tools, _ = self._meta_tools_and_failures(suffix, account_ids, retry_failed=None) - return tools - - def _meta_tools_and_failures( - self, suffix: str, account_ids: list[str] | None, *, retry_failed: _RetryFailed | None - ) -> tuple[list[StackOneTool], dict[str | None, Exception]]: - tools, failures = self._fetch_tools( - account_ids, None, None, "search_execute", retry_failed=retry_failed - ) - return [tool for tool in tools if tool.name.endswith(suffix)], failures - def search( self, query: str, @@ -614,90 +665,48 @@ def search( ) -> list[JsonDict]: """Find actions matching a natural-language query. - Searches every linked connector and merges the results, so the catalog - never has to be loaded into a model's context. + One ``stackone_search_actions`` call, which the server answers across every connector + linked on the accounts in scope, so the catalog never has to be loaded into a model's + context. One account or many, the call and its hits look the same: see + :class:`~stackone_ai.tools.AccountPair`. Args: query: What you want to do, e.g. "list recent comments". - top_k: Maximum results, across every connector searched. - account_ids: Restrict to these accounts. Defaults to every active shared one - (non-shared ones too with ``include_non_shared=True``). + top_k: Maximum results. + account_ids: Restrict to these accounts. Defaults to the toolset's own, or every + active shared one (non-shared ones too with ``include_non_shared=True``). Returns: - At most ``top_k`` action dicts, best first across every connector, each carrying - at least ``action_id`` and ``description``, the ``account_id`` of the account - whose connector found it, and the ``session_id`` of the search when the server - issued one. The same action linked on two accounts is two hits. Pass - ``session_id`` to :meth:`execute` and :meth:`submit_feedback` to link the calls, - and ``account_id`` in ``account_ids`` to run the action on that account. + At most ``top_k`` hits, best first, each as the server serves it: at least + ``action_id`` and ``description``, and ``available_accounts``, the accounts in + scope that can run it, as ``{"id", "name"}`` (just ``{"id"}`` when one account is + in scope). Each also carries the search's + ``session_id`` when the server issued one. Pass ``session_id`` to + :meth:`execute` and :meth:`submit_feedback` to link the calls, and, when + ``available_accounts`` lists more than one, the chosen ``id`` in ``account_ids``. Raises: - StackOneAPIError: With ``status_code`` 429 if the API is still rate limiting - after retries, for any account or connector. A 429 is retried as in - :meth:`fetch_tools`; other per-connector failures are skipped with a - warning. + ToolsetLoadError: If the server did not serve ``stackone_search_actions``. + StackOneAPIError: If the listing or the search fails, including a 429 that + outlasted its retries (retried as in :meth:`fetch_tools`). """ - # The server rejects anything outside 1..50, but only after a round trip per - # connector — and reports it as a load failure, which reads like an outage - # rather than a typo. Fail here instead, where the caller can see why. + # The server rejects anything outside 1..50, but only after a round trip, and reports + # it as a tool error, which reads like an outage rather than a typo. Fail here + # instead, where the caller can see why. if not isinstance(top_k, int) or isinstance(top_k, bool) or not 1 <= top_k <= _MAX_TOP_K: raise ToolsetConfigError( f"top_k must be an integer between 1 and {_MAX_TOP_K}, got {_json_text(top_k)}" ) - tools = self._meta_tools("_search_actions", account_ids) - if not tools: - return [] - - def _search_one(tool: StackOneTool) -> list[JsonDict]: - found = tool.execute({"query": query, "top_k": top_k}) - actions = list(found.get("actions", [])) - # The server returns session_id once per search, beside the actions. Results - # from every connector are merged and re-ranked below, so this is the last - # point at which a hit can still be traced to the search, and the account, - # that produced it. - session_id = found.get("session_id") - traced: JsonDict = {} - if isinstance(session_id, str) and session_id: - traced["session_id"] = session_id - account_id = tool.get_account_id() - if account_id: - traced["account_id"] = account_id - return [{**action, **traced} for action in actions] - - results: list[JsonDict] = [] - failures: list[str] = [] - # Fan out the way fetch_tools() does. Serially, a customer with a dozen - # connectors pays the sum of every connector's latency on the headline call. - for tool, outcome in _fan_out(_search_one, tools): - if isinstance(outcome, Exception): - # Skip everything but a rate limit, as fetch_tools() does. Catching only - # StackOneError let a transport failure — which _describe_mcp_failure - # reports as a ToolsetLoadError — abort the whole search, which is - # precisely the flakiness this guard exists to absorb. - failures.append(f"{tool.name}: {outcome}") - else: - results.extend(outcome) - - if failures and not results: - raise ToolsetLoadError("No connector returned results. " + " | ".join(failures)) - for failure in failures: - logger.warning("Skipping connector that failed to search — %s", failure) - - # Concatenating per-connector results leaves the list grouped by connector, so - # results[0] would be the best hit of whichever connector answered first rather - # than the best hit overall. Rank globally; the server scores every action on the - # same scale. Actions without a score sort last rather than raising. - def _score(action: JsonDict) -> float: - raw = action.get("similarity_score") - if isinstance(raw, bool) or not isinstance(raw, int | float): - return 0.0 - return float(raw) - - # Then cut to top_k: each connector was asked for top_k, so the merged list can - # hold many more. - results.sort(key=_score, reverse=True) - return results[:top_k] + tool = self._trio_tool(SEARCH_ACTIONS_TOOL_NAME, account_ids) + found = tool.execute({"query": query, "top_k": top_k}) + # The server returns session_id once per search, beside the actions; each hit carries + # it so a caller handing one hit on still links the calls. + session_id = found.get("session_id") + traced: JsonDict = {"session_id": session_id} if isinstance(session_id, str) and session_id else {} + actions = found.get("actions") + hits = [action for action in actions if isinstance(action, dict)] if isinstance(actions, list) else [] + return [{**action, **traced} for action in hits[:top_k]] def execute( self, @@ -709,39 +718,29 @@ def execute( ) -> JsonDict: """Execute an action by id, as returned by :meth:`search`. - Always runs through the connector's ``_execute_action`` meta tool, so - ``arguments`` is the nested form every action's ``example_request`` - shows — ``{"query": {...}, "path": {...}, "body": {...}}``. A - ``fetch_tools()`` tool takes the keys its own served schema names instead; - routing by whether an id happened to be in the catalog would make the - argument shape depend on something the caller cannot see. + One ``stackone_execute_action`` call, so ``arguments`` is the nested form every + action's ``example_request`` shows: ``{"query": {...}, "path": {...}, "body": + {...}}``. A ``fetch_tools()`` tool takes the keys its own served schema names instead. - ``arguments["headers"]`` is forwarded to the action: ``*_execute_action`` serves an - open ``headers`` object, so any header name is accepted except Authorization, - x-account-id, User-Agent and x-end-user-id, which the SDK sets itself and which are - dropped. + ``arguments["headers"]`` is forwarded to the action: ``stackone_execute_action`` + serves an open ``headers`` object, so any header name is accepted except + Authorization, x-account-id, User-Agent and x-end-user-id, which the SDK sets itself + and which are dropped. - ``session_id`` is the value a :meth:`search` hit carries. Passing it links - this call to that search server-side. + ``account_ids`` scopes the call. With exactly one id, it is sent as + ``stackone_account_id``, so pass a search hit's chosen ``available_accounts`` id to run + the action on that account. Otherwise the server picks the only account in scope + linked to the action's connector, or answers with an error naming the choices. - ``account_ids`` restricts routing to these accounts. Pass a search hit's - ``account_id`` to run the action on the account that found it. + ``session_id`` is the value a :meth:`search` hit carries. Passing it links this call + to that search server-side. Raises: - ToolsetConfigError: If ``action_id``, ``arguments`` or ``session_id`` is malformed, - or the action's connector is linked on more than one account in scope. + ToolsetConfigError: If ``action_id``, ``arguments`` or ``session_id`` is malformed. ToolArgumentsError: If the arguments cannot be encoded as JSON. - ToolsetLoadError: If no connector matches, or an account in scope that could serve - the action failed to list. An account left out after failing whose provider in - ``GET /accounts`` is the action's connector, or whose provider no - ``GET /accounts`` has named, is listed again first, without waiting out - ``FAILED_ACCOUNT_RETRY_SECONDS``; if it still fails, the action is not run on - another account in its place. When a failed account's provider is not known, - one ``GET /accounts`` is made first to learn it; if that fails, it stays - unknown, and is neither looked up nor listed again early for - ``FAILED_ACCOUNT_RETRY_SECONDS``. + ToolsetLoadError: If the server did not serve ``stackone_execute_action``. StackOneAPIError: If the action fails, including when the server rejects the - arguments. + arguments or the account is ambiguous. """ if not isinstance(action_id, str) or not action_id: raise ToolsetConfigError(f"action_id must be a non-empty string, got {_json_text(action_id)}") @@ -750,94 +749,41 @@ def execute( if session_id is not None and (not isinstance(session_id, str) or not session_id): raise ToolsetConfigError(f"session_id must be a non-empty string, got {_json_text(session_id)}") - suffix = "_execute_action" - # At most one GET /accounts per call, to learn the providers of failed accounts: with - # explicit ids none was made, and an unknown provider could serve any action. - learnt = False - - def _learn_providers(accounts: list[str]) -> set[str]: - nonlocal learnt - if learnt: - return set() - missed = self._learn_providers(accounts) - learnt = missed is not None - return missed or set() - - def _retry_failed(cached: _Catalog) -> list[str | None]: - # An account marked failed that could serve the action is listed again now: left - # out, it would leave the action to whichever account did list, which may be - # another end user's. One whose provider cannot serve it is not, so it does not - # cost every other connector's actions its timeout. Nor is one a lookup recently - # failed to name: it would pay its timeout on every call in the window. - failed = [account for account in cached.failed_at if account is not None] - missed = self._recently_missed(failed) | _learn_providers(failed) - listed = [ - self._connector_of_name(tool_def.name, account, suffix) - for account, listings in cached.listings.items() - for tool_def, _, _ in listings - if tool_def.name.endswith(suffix) - ] - return [ - account - for account in failed - if account not in missed and self._may_serve(account, action_id, listed) - ] + tool = self._trio_tool(EXECUTE_ACTION_TOOL_NAME, account_ids) + # Each key the caller pins is removed and set again, so it is set however the model + # filled the arguments, and action_id lands last in key order, as in Node. Spreading + # arguments over it let a model-supplied "action_id" silently replace the action the + # caller pinned. Optional keys only when given: the served schema makes them optional + # strings, so an absent key is valid and a null is not. + call_arguments: JsonDict = dict(arguments or {}) + pinned_accounts = set(account_ids or []) + if len(pinned_accounts) == 1: + call_arguments.pop("stackone_account_id", None) + call_arguments["stackone_account_id"] = next(iter(pinned_accounts)) + if session_id is not None: + call_arguments.pop("session_id", None) + call_arguments["session_id"] = session_id + call_arguments.pop("action_id", None) + call_arguments["action_id"] = action_id + # Returned as the server wrote it, the same shape tool.execute() returns. + return tool.execute(call_arguments) + + def _trio_tool(self, name: str, account_ids: list[str] | None) -> StackOneTool: + """One of the ``search_execute`` tools spanning these accounts, whatever this toolset's + mode. + + The mode is passed down rather than assigned to ``self``: flipping instance state + raced with a concurrent ``fetch_tools()``, which could cache the trio under the + individual-mode key. - meta_tools, failed = self._meta_tools_and_failures(suffix, account_ids, retry_failed=_retry_failed) - _learn_providers([account for account in failed if account is not None]) - connectors = [self._connector_of(tool, suffix) for tool in meta_tools] - blocking = sorted( - ( - (account, failure) - for account, failure in failed.items() - if account is not None and self._may_serve(account, action_id, connectors) - ), - key=lambda pair: pair[0], - ) - if blocking: - raise ToolsetLoadError( - f"{_json_text(action_id)} may be served by an account that failed to list (" - + "; ".join(f"{account}: {failure}" for account, failure in blocking) - + "). Pass the account id to use, such as a search hit's account_id." - ) - matches = [ - tool - for tool in meta_tools - if action_id.lower().startswith(self._connector_of(tool, "_execute_action") + "_") - ] - if matches: - # Longest connector wins: with both `browser` and `browser_linkedin` linked, - # the first token alone would route every browser_linkedin action to browser. - best = max(len(self._connector_of(t, "_execute_action")) for t in matches) - finalists = [t for t in matches if len(self._connector_of(t, "_execute_action")) == best] - if len(finalists) > 1: - # The same provider linked twice, which discovery makes common. Picking one - # would run the action against an account the caller never chose — another - # end user's, possibly. - ordered = sorted(finalists, key=lambda t: t.get_account_id() or "") - raise ToolsetConfigError( - f"{_json_text(action_id)} matches {len(finalists)} connectors on different accounts " - f"({', '.join(f'{t.name} on {t.get_account_id()}' for t in ordered)}). " - "Pass the account id to use, such as a search hit's account_id." - ) - tool = finalists[0] - # action_id LAST, removed first so it is last in key order too, as in Node. - # Spreading arguments over it let a model-supplied "action_id" silently replace - # the action the caller pinned — the exact thing a host app pins it for. - # session_id only when given: the served schema makes it an optional string, so - # an absent key is valid and a null is not. - call_arguments: JsonDict = dict(arguments or {}) - call_arguments.pop("action_id", None) - if session_id is not None: - call_arguments.pop("session_id", None) - call_arguments["session_id"] = session_id - call_arguments["action_id"] = action_id - # Returned as the server wrote it, the same shape tool.execute() returns. - return tool.execute(call_arguments) - - raise ToolsetLoadError( - f"No connector found for {_json_text(action_id)}. Use search() to discover valid action ids." - ) + Raises: + ToolsetLoadError: If the listing did not serve it, as when the project's policy + disables ``search_execute``. + """ + tool = self.fetch_tools(account_ids=account_ids, mode="search_execute").get_tool(name) + if tool is None: + raise ToolsetLoadError(f"The server did not serve {name} for this account scope.") + return tool def submit_feedback( self, @@ -886,8 +832,8 @@ def submit_feedback( # Found in the served catalog, never built here: the server only serves the tool # when the org flag and the project setting are both on, and a client-side stand-in # would report success for feedback that went nowhere. It is global and served in - # every mode, so one account's search_execute listing — two tools per connector, - # not one per action — is enough to find it. + # every mode, so one account's search_execute listing — the trio, not one tool per + # action — is enough to find it. # The lowest id is the one a caller can predict, whatever order GET /accounts lists # them in. scope = self._resolve_account_ids(account_ids) @@ -929,44 +875,6 @@ def pydantic_ai(self, *, account_ids: list[str] | None = None) -> list[Any]: """ return self.fetch_tools(account_ids=account_ids).to_pydantic_ai() - @staticmethod - def _connector_of(tool: StackOneTool, suffix: str) -> str: - """The connector a meta tool belongs to: its name minus the account id and suffix. - - The account id is stripped by identity, not by splitting on the last underscore. - Account ids are nanoid-shaped and nanoid's default alphabet includes ``_``, so - splitting turned ``mock_acc_1_execute_action`` into connector ``mock_acc`` and - made every action on that account unroutable — with an error blaming the - caller's action id. - """ - return StackOneToolSet._connector_of_name(tool.name, tool.get_account_id(), suffix) - - @staticmethod - def _connector_of_name(name: str, account: str | None, suffix: str) -> str: - """:meth:`_connector_of` for a meta tool's served name and the account that listed it.""" - stem = name[: -len(suffix)] if name.endswith(suffix) else name - if account and stem.endswith(f"_{account}"): - stem = stem[: -(len(account) + 1)] - return stem.lower() - - def _may_serve(self, account_id: str, action_id: str, listed_connectors: list[str]) -> bool: - """Whether an account could serve an action: its provider is the action's connector, - or no GET /accounts has named its provider. - - The connector is the longest prefix of the action among the connectors listed and the - providers GET /accounts named, as execute() routes: a failed ``browser`` account cannot - serve ``browser_linkedin_search_people``. - """ - with self._cache_lock: - provider = self._providers.get(account_id) - known = [p.lower() for p in self._providers.values()] - if provider is None: - return True - lowered = action_id.lower() - matching = [c for c in [*listed_connectors, *known] if lowered.startswith(c + "_")] - connector = max(matching, key=len, default=None) - return provider.lower() == connector - def _filter_by_provider(self, tool_name: str, providers: list[str]) -> bool: """Whether a tool belongs to one of the given providers (case-insensitive). @@ -1141,46 +1049,6 @@ def _join_accounts_listing(self) -> None: self._settle_accounts(fetching, sequence) fetching.result() - def _learn_providers(self, account_ids: list[str]) -> set[str] | None: - """If any of these accounts' providers is unknown, and no lookup has missed it within - ``FAILED_ACCOUNT_RETRY_SECONDS``, join or make one GET /accounts to learn it, and return - those it still could not name; ``None`` if it made no lookup. If that fails the providers - stay unknown, except that a rate limit is raised. Each one still unknown is recorded as - missed, unless clear_catalog_cache() ran meanwhile, which promises to forget misses. - """ - missed = self._recently_missed(account_ids) - with self._cache_lock: - unknown = [a for a in account_ids if a not in self._providers and a not in missed] - generation = self._cache_generation - if not unknown: - return None - try: - self._join_accounts_listing() - except Exception as exc: - if is_rate_limited(exc): - raise - missed_at = _clock() - with self._cache_lock: - still_unknown = {account for account in unknown if account not in self._providers} - if generation == self._cache_generation: - for account in still_unknown: - self._provider_misses[account] = missed_at - return still_unknown - - def _recently_missed(self, account_ids: list[str]) -> set[str]: - """Those of these accounts whose provider is unknown and a lookup failed to name - within ``FAILED_ACCOUNT_RETRY_SECONDS``. - """ - now = _clock() - with self._cache_lock: - return { - account - for account in account_ids - if account not in self._providers - and (missed_at := self._provider_misses.get(account)) is not None - and now - missed_at < FAILED_ACCOUNT_RETRY_SECONDS - } - def _end_user_id(self, account_id: str) -> str | None: """The end-user id GET /accounts recorded for an account, if it is not shared.""" with self._cache_lock: @@ -1296,13 +1164,20 @@ def _create_tool( tool_def: McpToolDefinition, account_id: str | None, endpoint: str, + pair_account: str | None = None, ) -> StackOneTool: """Build an executable tool from a served catalog entry. Every tool, in every mode, executes over MCP ``tools/call`` on the endpoint that listed it, with the ``x-account-id`` of the account that listed it, and the - ``x-end-user-id`` recorded for that account when the request is made. + ``x-end-user-id`` recorded for that account when the request is made. A tool of + ``pair_account``'s search and execute pair is presented as the trio tool it stands for. """ + served_name = tool_def.name + pair = AccountPair.serving(served_name, pair_account) if pair_account else None + if pair is not None: + tool_def = pair.presented(tool_def) + schema = tool_def.input_schema or {} # Pop keys we explicitly override to avoid "multiple values for keyword argument" rest = dict(schema) @@ -1318,7 +1193,10 @@ def _create_tool( type=schema_type, properties=schema_properties, ) - tool = StackOneMcpTool( + tool_class = ( + StackOneMcpTool if pair is None else functools.partial(StackOneAccountPairTool, pair=pair) + ) + tool = tool_class( name=tool_def.name, description=tool_def.description or "", parameters=parameters, @@ -1327,6 +1205,7 @@ def _create_tool( account_id=account_id, headers=self._headers, timeout=self._timeout, + served_name=served_name, ) tool._end_user_id_of = self._end_user_id tool._look_up_end_user = self._look_up_end_user diff --git a/stackone_ai/types.py b/stackone_ai/types.py index 1ac6b0a..6f33265 100644 --- a/stackone_ai/types.py +++ b/stackone_ai/types.py @@ -26,12 +26,21 @@ """How the MCP endpoint lists tools. ``"individual"`` (the server default) lists one tool per action — hundreds per -account. ``"search_execute"`` lists two meta tools per connector instead, a -``*_search_actions`` that ranks actions for a natural-language query and an -``*_execute_action`` that runs one by id. The catalog stays small regardless of -how many accounts are linked, which is what keeps it inside a model's context. +account. ``"search_execute"`` lists one fixed trio across every account instead: +``stackone_list_accounts``, ``stackone_search_actions``, which ranks actions for a +natural-language query, and ``stackone_execute_action``, which runs one by id. The +catalog stays the same size however many accounts are linked, which is what keeps it +inside a model's context. For a single account the server serves that account's own +search and execute pair, which the SDK presents under the same two names. """ +SEARCH_ACTIONS_TOOL_NAME: str = "stackone_search_actions" +"""The ``search_execute`` tool that ranks actions, across every account in scope.""" + +EXECUTE_ACTION_TOOL_NAME: str = "stackone_execute_action" +"""The ``search_execute`` tool that runs an action by id, on the account it names or the +only one in scope linked to the action's connector.""" + SUBMIT_FEEDBACK_TOOL_NAME: str = "stackone_submit_feedback" """The one global tool the MCP endpoint serves in every mode when feedback is enabled. diff --git a/tests/mocks/mcp-server.ts b/tests/mocks/mcp-server.ts index d549260..a941c60 100644 --- a/tests/mocks/mcp-server.ts +++ b/tests/mocks/mcp-server.ts @@ -68,25 +68,32 @@ const submitFeedbackTool = { } as const satisfies McpToolDefinition; /** - * The two meta tools the real endpoint serves per connector under `tool-mode=search_execute`. - * Without them the SDK's search/execute path has nothing to talk to and goes untested. + * The trio the real endpoint serves under `tool-mode=search_execute`, spanning every account the + * request names. Every account here is linked to the one connector, `mock`. */ -const metaTools = (accountId: string): McpToolDefinition[] => [ +const trioTools: McpToolDefinition[] = [ { - name: `mock_${accountId}_search_actions`, + name: 'stackone_list_accounts', + description: 'Lists the accounts connected to this session as `{ id, name, connector }` objects.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'stackone_search_actions', description: 'Search for available actions in natural language.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, top_k: { type: 'integer', minimum: 1, maximum: 50 }, + provider: { type: 'string', enum: ['mock'] }, + stackone_account_id: { type: 'string' }, session_id: { type: 'string' }, }, required: ['query'], }, }, { - name: `mock_${accountId}_execute_action`, + name: 'stackone_execute_action', description: 'Execute an action by its action_id.', inputSchema: { type: 'object', @@ -97,12 +104,58 @@ const metaTools = (accountId: string): McpToolDefinition[] => [ body: { type: 'object' }, headers: { type: 'object' }, session_id: { type: 'string' }, + stackone_account_id: { type: 'string' }, }, required: ['action_id'], }, }, ]; +/** + * The pair the real endpoint serves under `tool-mode=search_execute` when the request names one + * account: named after the account's connector and id, scoped to it, so neither tool takes + * `stackone_account_id` or `provider`. Each names the other, as the real descriptions do. + */ +const pairNames = (accountId: string) => ({ + search: `mock_${accountId}_search_actions`, + execute: `mock_${accountId}_execute_action`, +}); + +const pairTools = (accountId: string): McpToolDefinition[] => { + const { search, execute } = pairNames(accountId); + return [ + { + name: search, + description: `Search for available mock actions in natural language. Run a hit with ${execute}.`, + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + top_k: { type: 'integer', minimum: 1, maximum: 50 }, + session_id: { type: 'string', description: `The session_id an earlier ${search} returned.` }, + }, + required: ['query'], + }, + }, + { + name: execute, + description: `Execute a mock action by the action_id ${search} returned.`, + inputSchema: { + type: 'object', + properties: { + action_id: { type: 'string', description: `An action_id exactly as ${search} returned it.` }, + path: { type: 'object' }, + query: { type: 'object' }, + body: { type: 'object' }, + headers: { type: 'object' }, + session_id: { type: 'string' }, + }, + required: ['action_id'], + }, + }, + ]; +}; + const text = (payload: unknown, isError = false): CallToolResult => ({ isError, content: [{ type: 'text', text: JSON.stringify(payload) }], @@ -159,30 +212,136 @@ const callActionTool = (name: string, args: Record): CallToolRe } }; -const callMetaTool = (name: string, args: Record): CallToolResult | undefined => { - if (name.endsWith('_search_actions')) { +/** The real endpoint's tool-level failure: `{ isError, result: { error, status_code, ... } }`. */ +const toolError = (error: string, status_code: number, error_code: string): CallToolResult => + uca({ error, status_code, error_code, error_category: 'validation', retryable: false }, true); + +/** The server's 400 for a `stackone_account_id` outside the request's accounts. */ +const unknownAccount = (accountId: unknown, accounts: readonly string[]): CallToolResult => + toolError( + `Account "${String(accountId)}" is not permitted. Choose one of [${accounts.map((id) => `${id} (${id})`).join(', ')}].`, + 400, + 'INVALID_ACCOUNT', + ); + +/** + * How the trio answers, modelled on UCA's session-scoped handlers, for the accounts the request + * names. Search finds `mock_list_items` on every account in scope (or the one asked for); execute + * runs it on the account asked for, or the only one, and refuses to guess between several. + */ +const callTrioTool = ( + name: string, + args: Record, + accounts: readonly string[], +): CallToolResult | undefined => { + const requested = typeof args.stackone_account_id === 'string' ? args.stackone_account_id : undefined; + if (requested !== undefined && !accounts.includes(requested)) { + return unknownAccount(requested, accounts); + } + const targets = requested ? [requested] : accounts; + switch (name) { + case 'stackone_list_accounts': + return uca({ accounts: accounts.map((id) => ({ id, name: id, connector: 'mock' })) }); + case 'stackone_search_actions': + return text({ + providers_searched: ['mock'], + session_id: MOCK_SEARCH_SESSION_ID, + actions: [ + { + action_id: 'mock_list_items', + provider: 'mock', + available_accounts: targets.map((id) => ({ id, name: id })), + description: 'List items', + similarity_score: 0.9, + example_request: { query: { page_size: 25 } }, + }, + ], + total_count: 1, + hint: 'To run an action, call stackone_execute_action with its action_id exactly as shown above.', + }); + case 'stackone_execute_action': { + // An unknown action must come back as isError — a normal response with the flag set — + // the way the real endpoint reports it. + if (args.action_id !== 'mock_list_items') { + return toolError( + `Unknown action_id "${String(args.action_id)}" — it does not match any integration connected to this session ([mock]). Call stackone_search_actions first and use an action_id exactly as returned.`, + 400, + 'INVALID_ACTION', + ); + } + if (targets.length > 1) { + return toolError( + `stackone_execute_action runs against multiple accounts — pass "stackone_account_id", one of [${targets.map((id) => `${id} (${id})`).join(', ')}] (or call stackone_list_accounts to list them).`, + 400, + 'INVALID_ACCOUNT', + ); + } + return uca({ data: { nodes: [] }, echoed_query: args.query ?? null, account_id: targets[0] }); + } + default: + return undefined; + } +}; + +/** + * How an account's pair answers. Search returns hits without `available_accounts` or `provider`, + * as the real one does; both refuse the trio's scope arguments, which the pair does not declare. + */ +const callPairTool = ( + name: string, + args: Record, + accountId: string, +): CallToolResult | undefined => { + const { search, execute } = pairNames(accountId); + if (name !== search && name !== execute) { + return undefined; + } + const undeclared = ['stackone_account_id', 'provider'].filter((key) => Object.hasOwn(args, key)); + if (undeclared.length > 0) { + return toolError(`Unrecognized key(s) in object: ${undeclared.join(', ')}`, 400, 'INVALID_ARGUMENTS'); + } + if (name === search) { return text({ + provider: 'mock', session_id: MOCK_SEARCH_SESSION_ID, actions: [ { action_id: 'mock_list_items', description: 'List items', + similarity_score: 0.9, example_request: { query: { page_size: 25 } }, }, ], + total_count: 1, + hint: `To run an action, call ${execute} with its action_id exactly as shown above.`, }); } - if (name.endsWith('_execute_action')) { - // An unknown action must come back as isError — a normal response with the flag set — - // the way the real endpoint reports it. - if (args.action_id !== 'mock_list_items') { - return text({ error: `Unknown action ${String(args.action_id)}` }, true); - } - return uca({ data: { nodes: [] }, echoed_query: args.query ?? null }); + if (args.action_id !== 'mock_list_items') { + return toolError( + `Unknown action_id "${String(args.action_id)}". Call ${search} first and use an action_id exactly as returned.`, + 400, + 'INVALID_ACTION', + ); } - return undefined; + return uca({ data: { nodes: [] }, echoed_query: args.query ?? null, account_id: accountId }); }; +/** + * Every account a request names: the header and each `x-account-id` query value, each a comma + * list, deduped, as the real endpoint reads them. + */ +const namedAccounts = (header: string | undefined, queries: readonly string[]): string[] => [ + ...new Set( + [...(header ? [header] : []), ...queries] + .flatMap((value) => value.split(',')) + .map((id) => id.trim()) + .filter((id) => id.length > 0), + ), +]; + +/** The real endpoint's cap on the accounts one request may name. */ +const MAX_ACCOUNTS = 50; + const hasMethod = async (request: Request, method: string): Promise => { try { const payload = (await request.clone().json()) as { method?: string } | { method?: string }[]; @@ -222,61 +381,82 @@ export function createMcpApp(options: MockMcpServerOptions): HonoApp { app.all('/mcp', async (c) => { // The real endpoint rejects a request with no account. Defaulting here made the // mock more permissive than production and hid a fatal SDK bug: `?? 'default'` - // meant an SDK that never sent the header still got a full catalog. - const accountId = c.req.header('x-account-id') ?? c.req.query('x-account-id'); - if (!accountId) { + // meant an SDK that never sent the header still got a full catalog. Under + // search_execute the SDK names every account in one comma-separated query value. + const accountIds = namedAccounts(c.req.header('x-account-id'), c.req.queries('x-account-id') ?? []); + if (accountIds.length === 0) { return c.json( { statusCode: 400, message: 'Missing x-account-id header or query parameter in request' }, 400, ); } - // No `?? accountTools.default` fallback. Serving a full catalog for an account - // that does not exist is the same permissiveness that hid the missing-header bug - // one line further up: any bug that sends a wrong, stale or mangled account id - // would be invisible. The real API refuses. - // What the real endpoint answers for an account linked before MCP existed, verbatim. - if (accountId === 'legacy-account') { + if (accountIds.length > MAX_ACCOUNTS) { return c.json( - { - jsonrpc: '2.0', - error: { - code: -32600, - message: 'Legacy accounts cannot be used with MCP. Account provider: mock, version: 2', - }, - id: null, - }, + { statusCode: 400, message: `At most ${MAX_ACCOUNTS} accounts may be named, got ${accountIds.length}` }, 400, ); } - // `ratelimit---[after-]` answers 429 (`Retry-After: 0`, - // or ``) to the first n requests for that exact id, or every one, and then serves the - // default catalog. `all` counts every request, `list` only tools/list and `call` only - // tools/call; the tag keeps tests' counters apart. - const rateLimit = /^ratelimit-(all|list|call)-(\d+|always)-(?:after(\d+)-)?/.exec(accountId); - if (rateLimit) { - const [, scope, times, retryAfter = '0'] = rateLimit; - const method = { list: 'tools/list', call: 'tools/call' }[scope]; - if (!method || (await hasMethod(c.req.raw, method))) { - const seen = (rateLimitCounts.get(accountId) ?? 0) + 1; - rateLimitCounts.set(accountId, seen); - if (times === 'always' || seen <= Number(times)) { - return c.json({ statusCode: 429, message: 'Too many requests' }, 429, { - 'Retry-After': retryAfter, - }); + const rateLimited = new Set(); + for (const accountId of accountIds) { + // No `?? accountTools.default` fallback. Serving a full catalog for an account + // that does not exist is the same permissiveness that hid the missing-header bug + // one line further up: any bug that sends a wrong, stale or mangled account id + // would be invisible. The real API refuses. + // What the real endpoint answers for an account linked before MCP existed, verbatim. + if (accountId === 'legacy-account') { + return c.json( + { + jsonrpc: '2.0', + error: { + code: -32600, + message: 'Legacy accounts cannot be used with MCP. Account provider: mock, version: 2', + }, + id: null, + }, + 400, + ); + } + // `ratelimit---[after-]` answers 429 (`Retry-After: 0`, + // or ``) to the first n requests for that exact id, or every one, and then serves the + // default catalog. `all` counts every request, `list` only tools/list and `call` only + // tools/call; the tag keeps tests' counters apart. + const rateLimit = /^ratelimit-(all|list|call)-(\d+|always)-(?:after(\d+)-)?/.exec(accountId); + if (rateLimit) { + rateLimited.add(accountId); + const [, scope, times, retryAfter = '0'] = rateLimit; + const method = { list: 'tools/list', call: 'tools/call' }[scope]; + if (!method || (await hasMethod(c.req.raw, method))) { + const seen = (rateLimitCounts.get(accountId) ?? 0) + 1; + rateLimitCounts.set(accountId, seen); + if (times === 'always' || seen <= Number(times)) { + return c.json({ statusCode: 429, message: 'Too many requests' }, 429, { + 'Retry-After': retryAfter, + }); + } } + } else if (!Object.hasOwn(accountTools, accountId)) { + return c.json({ statusCode: 404, message: `Unknown account ${accountId}` }, 404); } - } else if (!Object.hasOwn(accountTools, accountId)) { - return c.json({ statusCode: 404, message: `Unknown account ${accountId}` }, 404); } - const accountCatalog = (rateLimit ? accountTools.default : accountTools[accountId]) ?? []; + const [accountId] = accountIds; + const accountCatalog = (rateLimited.has(accountId) ? accountTools.default : accountTools[accountId]) ?? []; // The feedback tool comes first because the real endpoint does the same: it is one // global tool, served identically in both modes and once per account listing. Under - // search_execute the per-action catalog is swapped for two meta tools per connector. + // search_execute the per-action catalog is swapped for the trio spanning every account + // named, or for one account its own pair, unless one of them is `search-execute-disabled`, + // which models a project whose policy turns search_execute off: the listing then has + // neither. const searchExecute = c.req.query('tool-mode') === 'search_execute'; + const onlyAccount = accountIds.length === 1 ? accountId : undefined; + const trio = accountIds.includes('search-execute-disabled') + ? [] + : onlyAccount !== undefined + ? pairTools(onlyAccount) + : trioTools; const listed: McpToolDefinition[] = [ ...(submitFeedback ? [submitFeedbackTool] : []), - ...(searchExecute ? metaTools(accountId) : accountCatalog), + ...(searchExecute ? trio : accountCatalog), ]; // A low-level Server rather than McpServer: McpServer.registerTool expects a Zod shape @@ -304,9 +484,14 @@ export function createMcpApp(options: MockMcpServerOptions): HonoApp { session_id: args.session_id ?? null, }); } - // Only search_execute serves meta tools: in individual mode a per-action tool whose + // Only search_execute serves the trio: in individual mode a per-action tool whose // name ends in `_execute_action` is still an action. - return (searchExecute ? callMetaTool(name, args) : undefined) ?? callActionTool(name, args); + const served = !searchExecute + ? undefined + : onlyAccount !== undefined + ? callPairTool(name, args, onlyAccount) + : callTrioTool(name, args, accountIds); + return served ?? callActionTool(name, args); }); const transport = new StreamableHTTPTransport(); diff --git a/tests/mocks/serve.ts b/tests/mocks/serve.ts index 6b0bf51..cfc2774 100755 --- a/tests/mocks/serve.ts +++ b/tests/mocks/serve.ts @@ -37,6 +37,8 @@ interface RecordedRequest { /** The query string the call was made with, e.g. "" or "?tool-mode=search_execute". */ search: string; accountId: string | null; + /** Every `x-account-id` value as sent, header first, then each query value, commas kept. */ + accountIds: string[]; method: string; name?: string; arguments?: unknown; @@ -46,6 +48,8 @@ interface RecordedHeaders { method: string; accountId: string | null; endUserId: string | null; + /** Every end user named, header first, then each `x-end-user-id` query parameter. */ + endUserIds: string[]; } // The SDK-set headers of every JSON-RPC message, initialize and notifications included. @@ -68,6 +72,7 @@ const mcpApp = createMcpApp({ lookalike: metaLookalikeTools, "your-bamboohr-account-id": exampleBamboohrTools, "your-stackone-account-id": exampleBamboohrTools, + "search-execute-disabled": defaultMcpTools, }, submitFeedback, }); @@ -91,20 +96,23 @@ app.delete("/__requests", (c) => { app.use("/mcp", async (c, next) => { const accountId = c.req.header("x-account-id") ?? c.req.query("x-account-id") ?? null; + const accountIds = [c.req.header("x-account-id") ?? [], c.req.queries("x-account-id") ?? []].flat(); const endUserId = c.req.header("x-end-user-id") ?? null; + const endUserIds = [endUserId ?? [], c.req.queries("x-end-user-id") ?? []].flat(); if (c.req.method === "POST") { try { const payload = (await c.req.raw.clone().json()) as unknown; const messages = Array.isArray(payload) ? payload : [payload]; for (const message of messages as { method?: string; params?: Record }[]) { if (typeof message?.method === "string") { - recordedHeaders.push({ method: message.method, accountId, endUserId }); + recordedHeaders.push({ method: message.method, accountId, endUserId, endUserIds }); } if (message?.method !== "tools/call" && message?.method !== "tools/list") continue; recorded.push({ path: "/mcp", search: new URL(c.req.url).search, accountId, + accountIds, method: message.method, name: message.params?.name as string | undefined, arguments: message.params?.arguments, @@ -114,12 +122,15 @@ app.use("/mcp", async (c, next) => { // Not JSON: the MCP handler rejects it, so there is nothing to record. } } - const expected = endUsers && accountId ? END_USER_IDS[accountId] : undefined; - if (expected !== undefined && endUserId !== expected) { + // Every non-shared account named needs its end user among those named, as the real API checks. + const refused = endUsers + ? accountIds.flatMap((value) => value.split(",")).find((id) => END_USER_IDS[id] !== undefined && !endUserIds.includes(END_USER_IDS[id])) + : undefined; + if (refused !== undefined) { return c.json( { statusCode: 400, - message: `x-end-user-id header does not match account end user id for account ${accountId}`, + message: `x-end-user-id header does not match account end user id for account ${refused}`, }, 400, ); diff --git a/tests/test_end_user_id.py b/tests/test_end_user_id.py index 408b9df..a1febb6 100644 --- a/tests/test_end_user_id.py +++ b/tests/test_end_user_id.py @@ -81,21 +81,53 @@ def test_a_listed_tool_sends_it_on_every_call_request(self, mcp_mock_server_with assert _end_user_ids(base_url, "acc1") == {"end-user-1"} assert _end_user_ids(base_url, "acc2") == {None} - def test_meta_tools_and_feedback_send_it(self, mcp_mock_server_with_end_users: str): + def test_the_trio_and_feedback_send_it_as_a_query_parameter(self, mcp_mock_server_with_end_users: str): + """One request names every account, so each end user goes beside the account ids. + + With one account the server serves that account's pair, called by its served name. + """ base_url = mcp_mock_server_with_end_users toolset = StackOneToolSet(api_key="test-key", base_url=base_url, include_non_shared=True) _reset(base_url) - hits = toolset.search("list items", account_ids=None) - assert any(hit["account_id"] == "acc1" for hit in hits) + [hit] = toolset.search("list items", account_ids=None) + assert [account["id"] for account in hit["available_accounts"]] == ["acc1", "acc2"] toolset.execute("mock_list_items", {}, account_ids=["acc1"]) toolset.submit_feedback("positive", ["mock_list_items"], account_ids=["acc1"]) - called = { - r.get("name") for r in httpx.get(f"{base_url}/__requests").json() if r["accountId"] == "acc1" - } - assert {"mock_acc1_search_actions", "mock_acc1_execute_action", "stackone_submit_feedback"} <= called - assert _end_user_ids(base_url, "acc1") == {"end-user-1"} + requests = httpx.get(f"{base_url}/__requests").json() + assert [(r.get("name"), r["search"]) for r in requests if r["method"] == "tools/call"] == [ + ( + "stackone_search_actions", + "?tool-mode=search_execute&x-account-id=acc1%2Cacc2&x-end-user-id=end-user-1", + ), + ( + "mock_acc1_execute_action", + "?tool-mode=search_execute&x-account-id=acc1&x-end-user-id=end-user-1", + ), + ( + "stackone_submit_feedback", + "?tool-mode=search_execute&x-account-id=acc1&x-end-user-id=end-user-1", + ), + ] + assert all(r["endUserIds"] == ["end-user-1"] for r in _headers(base_url)) + + def test_explicit_ids_look_their_end_users_up_when_the_trio_listing_is_refused( + self, mcp_mock_server_with_end_users: str + ): + base_url = mcp_mock_server_with_end_users + toolset = StackOneToolSet(api_key="test-key", base_url=base_url) + _reset(base_url) + + tools = toolset.fetch_tools(account_ids=["acc2", "acc1"], mode="search_execute") + + assert tools.get_tool("stackone_search_actions") is not None + listings = [ + r["search"] for r in httpx.get(f"{base_url}/__requests").json() if r["method"] == "tools/list" + ] + # Refused once without it, then listed with it. + assert listings == ["?tool-mode=search_execute&x-account-id=acc1%2Cacc2&x-end-user-id=end-user-1"] + assert {tuple(r["endUserIds"]) for r in _headers(base_url)} == {(), ("end-user-1",)} def test_a_direct_fetch_accounts_records_it_for_explicit_ids(self, mcp_mock_server_with_end_users: str): base_url = mcp_mock_server_with_end_users @@ -137,8 +169,9 @@ def test_a_model_supplied_end_user_is_not_forwarded(self, mcp_mock_server_with_e ) [call] = [r for r in httpx.get(f"{base_url}/__requests").json() if r["method"] == "tools/call"] + # The one account's pair takes no stackone_account_id: it runs on that account. assert call["arguments"] == {"headers": {"x-custom": "kept"}, "action_id": "mock_list_items"} - assert _end_user_ids(base_url, "acc1") == {"end-user-1"} + assert {tuple(r["endUserIds"]) for r in _headers(base_url)} == {("end-user-1",)} def _accounts_response(monkeypatch: pytest.MonkeyPatch, *bodies: Any) -> list[int]: diff --git a/tests/test_feedback.py b/tests/test_feedback.py index 3507345..6fdf3b2 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -146,24 +146,6 @@ def test_every_hit_carries_the_search_session_id(self, mcp_mock_server: str): assert hits assert all(hit["session_id"] == MOCK_SESSION_ID for hit in hits) - def test_each_hit_keeps_its_own_search_session_across_connectors(self, monkeypatch): - per_connector = { - "a_acc1_search_actions": {"session_id": "s-a", "actions": [{"action_id": "a_x"}]}, - "b_acc2_search_actions": {"session_id": "s-b", "actions": [{"action_id": "b_x"}]}, - } - monkeypatch.setattr(StackOneMcpTool, "execute", lambda self, _args: per_connector[self.name]) - monkeypatch.setattr( - "stackone_ai.toolset.fetch_mcp_tools", - lambda _e, headers, **_k: [ - McpToolDefinition(name=name, description="", input_schema={}) - for name in per_connector - if headers["x-account-id"] in name - ], - ) - toolset = StackOneToolSet(api_key="test-key") - toolset.set_accounts(["acc1", "acc2"]) - assert {h["action_id"]: h["session_id"] for h in toolset.search("x")} == {"a_x": "s-a", "b_x": "s-b"} - def test_the_field_is_omitted_when_the_server_returned_none(self, monkeypatch): monkeypatch.setattr( StackOneMcpTool, "execute", lambda self, _args: {"actions": [{"action_id": "a_x"}]} @@ -171,11 +153,11 @@ def test_the_field_is_omitted_when_the_server_returned_none(self, monkeypatch): monkeypatch.setattr( "stackone_ai.toolset.fetch_mcp_tools", lambda _e, _h, **_k: [ - McpToolDefinition(name="a_acc1_search_actions", description="", input_schema={}) + McpToolDefinition(name="stackone_search_actions", description="", input_schema={}) ], ) toolset = StackOneToolSet(api_key="test-key", account_id="acc1") - assert toolset.search("x") == [{"action_id": "a_x", "account_id": "acc1"}] + assert toolset.search("x") == [{"action_id": "a_x"}] class TestExecuteSessionId: @@ -299,7 +281,9 @@ def test_lists_only_the_lowest_given_account_in_search_execute_mode(self, mcp_mo toolset.submit_feedback("positive", ["acc1_tool_1"], account_ids=["acc2", "acc1"]) listings = [r for r in _requests(mcp_mock_server) if r.get("method") == "tools/list"] - assert [(r["accountId"], r["search"]) for r in listings] == [("acc1", "?tool-mode=search_execute")] + assert [(r["accountIds"], r["search"]) for r in listings] == [ + (["acc1"], "?tool-mode=search_execute&x-account-id=acc1") + ] calls = _tool_calls(mcp_mock_server, SUBMIT_FEEDBACK_TOOL_NAME) assert [c["accountId"] for c in calls] == ["acc1"] @@ -329,10 +313,10 @@ def test_defaults_to_the_lowest_discovered_account_id(self, monkeypatch): {"id": "alpha", "status": "active"}, ], ) - listed: list[tuple[str, str]] = [] + listed: list[str] = [] - def fake_fetch(endpoint: str, headers: dict[str, str], **_k: object) -> list[McpToolDefinition]: - listed.append((headers["x-account-id"], endpoint)) + def fake_fetch(endpoint: str, _headers: dict[str, str], **_k: object) -> list[McpToolDefinition]: + listed.append(endpoint) return [McpToolDefinition(name=SUBMIT_FEEDBACK_TOOL_NAME, description="", input_schema={})] monkeypatch.setattr("stackone_ai.toolset.fetch_mcp_tools", fake_fetch) @@ -343,8 +327,9 @@ def fake_fetch(endpoint: str, headers: dict[str, str], **_k: object) -> list[Mcp StackOneToolSet(api_key="test-key").submit_feedback("positive", ["x"]) - assert listed == [("alpha", "https://api.stackone.com/mcp?tool-mode=search_execute")] - assert called == ["alpha"] + assert listed == ["https://api.stackone.com/mcp?tool-mode=search_execute&x-account-id=alpha"] + # Bound to no account: the endpoint names it. + assert called == [None] def test_accepts_any_sequence_of_tool_names(self, mcp_mock_server: str): toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server) diff --git a/tests/test_fetch_tools.py b/tests/test_fetch_tools.py index 9288613..e849270 100644 --- a/tests/test_fetch_tools.py +++ b/tests/test_fetch_tools.py @@ -900,14 +900,15 @@ def always_fails( class TestMcpEndpoint: - """No param-style pin: the endpoint is {base}/mcp, plus ?tool-mode= when a mode is set.""" + """No param-style pin: the endpoint is {base}/mcp, plus ?tool-mode= when a mode is set, and + under search_execute the accounts it spans.""" @pytest.mark.parametrize( ("mode", "expected"), [ (None, "https://api.example.com/mcp"), ("individual", "https://api.example.com/mcp?tool-mode=individual"), - ("search_execute", "https://api.example.com/mcp?tool-mode=search_execute"), + ("search_execute", "https://api.example.com/mcp?tool-mode=search_execute&x-account-id=acc1"), ], ) def test_endpoint(self, monkeypatch, mode, expected): @@ -1528,13 +1529,46 @@ def test_execute_forwards_host_headers_but_cannot_switch_tenant(self, mcp_mock_s ) [call] = _tool_calls(mcp_mock_server, "mock_default_execute_action") - assert call["accountId"] == "default" + assert call["accountIds"] == ["default"] assert call["arguments"] == { "query": {"page_size": 25}, "headers": {"x-custom": "kept"}, "action_id": "mock_list_items", } + def test_one_listing_spans_every_account_and_hits_name_them(self, mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server) + _reset_requests(mcp_mock_server) + + [hit] = toolset.search("list items", account_ids=["acc2", "acc1"]) + + assert hit["available_accounts"] == [{"id": "acc1", "name": "acc1"}, {"id": "acc2", "name": "acc2"}] + assert hit["session_id"] == "mock-session-1" + listings = [ + r for r in httpx.get(f"{mcp_mock_server}/__requests").json() if r["method"] == "tools/list" + ] + assert [(r["accountIds"], r["search"]) for r in listings] == [ + (["acc1,acc2"], "?tool-mode=search_execute&x-account-id=acc1%2Cacc2") + ] + + def test_an_action_on_several_accounts_needs_one_chosen(self, mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server) + toolset.set_accounts(["acc1", "acc2"]) + + with pytest.raises(StackOneAPIError) as excinfo: + toolset.execute("mock_list_items") + assert excinfo.value.status_code == 400 + assert "one of [acc1 (acc1), acc2 (acc2)]" in str(excinfo.value) + + result = toolset.execute("mock_list_items", account_ids=["acc2"]) + assert result["result"]["account_id"] == "acc2" + + def test_a_listing_without_the_trio_raises(self, mcp_mock_server: str): + """As when the project's policy turns search_execute off.""" + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server) + with pytest.raises(ToolsetLoadError, match="did not serve stackone_search_actions"): + toolset.search("list items", account_ids=["search-execute-disabled"]) + def test_unknown_action_raises_rather_than_returning_an_error_body(self, mcp_mock_server: str): from stackone_ai.types import StackOneAPIError @@ -1549,6 +1583,71 @@ def test_fetch_accounts_lists_linked_accounts(self, mcp_mock_server: str): assert [a["id"] for a in accounts if a["status"] == "active"] == ["default"] +class TestOneAccountPairOverTheWire: + """One account is served `mock__search_actions` / `_execute_action`, and the SDK + shows them to callers and models as the trio's two tools.""" + + def test_fetch_tools_shows_the_trio_names_wherever_the_served_names_appeared(self, mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server, account_id="acc1") + tools = toolset.fetch_tools(mode="search_execute") + + assert [t.name for t in tools] == [ + "stackone_submit_feedback", + "stackone_search_actions", + "stackone_execute_action", + ] + served_text = str([t.to_openai_function() for t in tools]) + assert "mock_acc1" not in served_text + assert "Run a hit with stackone_execute_action." in served_text + + def test_a_model_searches_and_executes_with_the_trio_names(self, mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server, account_id="acc1") + tools = toolset.fetch_tools(mode="search_execute") + _reset_requests(mcp_mock_server) + + found = tools.get_tool("stackone_search_actions").execute( # type: ignore[union-attr] + {"query": "list items", "provider": "mock", "stackone_account_id": "acc1"} + ) + [hit] = found["actions"] + result = tools.get_tool("stackone_execute_action").execute( # type: ignore[union-attr] + {"action_id": hit["action_id"], "stackone_account_id": hit["available_accounts"][0]["id"]} + ) + + assert found["hint"] == ( + "To run an action, call stackone_execute_action with its action_id exactly as shown above." + ) + assert (hit["available_accounts"], hit["provider"]) == ([{"id": "acc1"}], "mock") + assert result["result"]["account_id"] == "acc1" + # The mock refuses the trio's scope arguments, which the pair does not declare. + assert [call["arguments"] for call in _tool_calls(mcp_mock_server, "mock_acc1_search_actions")] == [ + {"query": "list items"} + ] + assert [call["arguments"] for call in _tool_calls(mcp_mock_server, "mock_acc1_execute_action")] == [ + {"action_id": "mock_list_items"} + ] + + def test_search_and_execute_look_the_same_for_one_account_or_many(self, mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server) + for scope in (["acc1"], ["acc1", "acc2"]): + [hit] = toolset.search("list items", account_ids=scope) + assert hit["action_id"] == "mock_list_items" + assert [account["id"] for account in hit["available_accounts"]] == scope + assert hit["provider"] == "mock" + assert hit["session_id"] == "mock-session-1" + result = toolset.execute(hit["action_id"], account_ids=["acc1"]) + assert result["result"]["account_id"] == "acc1" + assert toolset.submit_feedback("positive", ["mock_list_items"], account_ids=scope)["message"] == ( + "Feedback recorded" + ) + + def test_an_error_names_the_trio_tools(self, mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server, account_id="acc1") + with pytest.raises(StackOneAPIError) as excinfo: + toolset.execute("mock_not_a_real_action") + assert "Call stackone_search_actions first" in str(excinfo.value) + assert "mock_acc1" not in str(excinfo.value) + + class TestServerRefusals: """The mock refuses what the real API refuses, so a bug here cannot stay green.""" @@ -1742,7 +1841,7 @@ def test_execute_returns_the_same_shape_as_the_tool(self, mcp_mock_server: str): toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server) assert toolset.execute("mock_list_items") == { "isError": False, - "result": {"data": {"nodes": []}, "echoed_query": None}, + "result": {"data": {"nodes": []}, "echoed_query": None, "account_id": "default"}, } @@ -1906,85 +2005,6 @@ def test_every_sdk_error_is_a_stackone_error(): StackOneToolSet(api_key="k").search("x", top_k=0) -class TestExecuteRouting: - """Pins for execute()'s routing and argument handling.""" - - @staticmethod - def _toolset_with_meta_tools(monkeypatch, names, account_ids): - from stackone_ai.tools import StackOneMcpTool - - seen: dict[str, object] = {} - - def fake_execute(self, arguments): - seen["tool"] = self.name - seen["arguments"] = arguments - return {"data": {}} - - monkeypatch.setattr(StackOneMcpTool, "execute", fake_execute) - monkeypatch.setattr( - "stackone_ai.toolset.fetch_mcp_tools", - lambda _e, headers, **_k: [ - McpToolDefinition(name=name, description="", input_schema={}) - for name in names - if name.split("_execute_action")[0].endswith(headers["x-account-id"]) - ], - ) - toolset = StackOneToolSet(api_key="test-key") - toolset.set_accounts(account_ids) - return toolset, seen - - def test_a_model_supplied_action_id_cannot_override_the_pinned_one(self, monkeypatch): - """Spreading arguments over action_id let a prompt-injected call swap the action.""" - toolset, seen = self._toolset_with_meta_tools(monkeypatch, ["linear_acc1_execute_action"], ["acc1"]) - toolset.execute("linear_list_issues", {"action_id": "linear_delete_issue"}) - assert seen["arguments"]["action_id"] == "linear_list_issues" - - def test_account_ids_containing_underscores_still_route(self, monkeypatch): - """nanoid's alphabet includes "_"; splitting on it made the account unroutable.""" - toolset, seen = self._toolset_with_meta_tools(monkeypatch, ["linear_acc_1_execute_action"], ["acc_1"]) - toolset.execute("linear_list_issues") - assert seen["tool"] == "linear_acc_1_execute_action" - - def test_the_longest_matching_connector_wins(self, monkeypatch): - """With browser and browser_linkedin both linked, the first token alone misroutes.""" - toolset, seen = self._toolset_with_meta_tools( - monkeypatch, - ["browser_acc1_execute_action", "browser_linkedin_acc2_execute_action"], - ["acc1", "acc2"], - ) - toolset.execute("browser_linkedin_search_people") - assert seen["tool"] == "browser_linkedin_acc2_execute_action" - - -class TestSearchRanking: - def test_results_are_ranked_across_connectors_and_tolerate_bad_scores(self, monkeypatch): - """Concatenation left results grouped by connector; a non-numeric score crashed.""" - from stackone_ai.tools import StackOneMcpTool - - per_connector = { - "a_acc1_search_actions": [ - {"action_id": "a_low", "similarity_score": 0.2}, - {"action_id": "a_bad", "similarity_score": "0.99"}, - ], - "b_acc2_search_actions": [{"action_id": "b_high", "similarity_score": 0.9}], - } - - monkeypatch.setattr( - StackOneMcpTool, "execute", lambda self, _args: {"actions": per_connector[self.name]} - ) - monkeypatch.setattr( - "stackone_ai.toolset.fetch_mcp_tools", - lambda _e, headers, **_k: [ - McpToolDefinition(name=name, description="", input_schema={}) - for name in per_connector - if headers["x-account-id"] in name - ], - ) - toolset = StackOneToolSet(api_key="test-key") - toolset.set_accounts(["acc1", "acc2"]) - assert [a["action_id"] for a in toolset.search("x")] == ["b_high", "a_low", "a_bad"] - - class TestAdapterErrorsReachTheModel: """A rejected call must reach the agent with the server's reason, not kill the run.""" diff --git a/tests/test_integrations_pydantic_ai.py b/tests/test_integrations_pydantic_ai.py index cab20a3..3bdc1cd 100644 --- a/tests/test_integrations_pydantic_ai.py +++ b/tests/test_integrations_pydantic_ai.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +from collections.abc import Callable from typing import Any from unittest.mock import MagicMock, patch @@ -10,6 +12,14 @@ pytest.importorskip("pydantic_ai", reason="pydantic-ai not installed") from pydantic_ai import Agent # noqa: E402 +from pydantic_ai.messages import ( # noqa: E402 + ModelMessage, + ModelResponse, + TextPart, + ToolCallPart, + ToolReturnPart, +) +from pydantic_ai.models.function import AgentInfo, FunctionModel # noqa: E402 from pydantic_ai.tools import Tool # noqa: E402 from stackone_ai import StackOneToolSet # noqa: E402 @@ -112,13 +122,11 @@ def test_tools_to_pydantic_ai_with_agent(sample_tool: StackOneTool, monkeypatch: def test_toolset_pydantic_ai_default_uses_fetch_tools(sample_tool: StackOneTool): toolset = StackOneToolSet.__new__(StackOneToolSet) toolset.fetch_tools = MagicMock(return_value=Tools([sample_tool])) # type: ignore[method-assign] - toolset._build_tools = MagicMock() # type: ignore[method-assign] toolset._execute_config = None # type: ignore[attr-defined] tools = toolset.pydantic_ai() toolset.fetch_tools.assert_called_once_with(account_ids=None) - toolset._build_tools.assert_not_called() assert len(tools) == 1 assert tools[0].name == "bamboohr_list_employees" @@ -154,6 +162,71 @@ def _capture(endpoint: str, headers: dict[str, str], **_kwargs: object): assert seen == ["from-config"] +# --- An agent run against the mock MCP server --- + + +def _scripted_model(*steps: Callable[[ToolReturnPart | None], ToolCallPart]) -> FunctionModel: + """A model that makes one tool call per step, each built from the previous call's return, + then answers with the last return's content as text.""" + + def respond(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse: + returns = [part for message in messages for part in message.parts if isinstance(part, ToolReturnPart)] + if len(returns) < len(steps): + return ModelResponse(parts=[steps[len(returns)](returns[-1] if returns else None)]) + return ModelResponse(parts=[TextPart(json.dumps(returns[-1].content))]) + + return FunctionModel(respond) + + +def test_agent_runs_an_individual_tool_over_mcp(mcp_mock_server: str): + toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server, account_id="test-account") + model = _scripted_model(lambda _: ToolCallPart("dummy_action", {"foo": "bar"})) + + result = Agent(model, tools=toolset.pydantic_ai()).run_sync("run the dummy action") + + assert json.loads(result.output) == { + "isError": False, + "result": {"data": {"action": "dummy_action", "received": {"foo": "bar"}}}, + } + + +@pytest.mark.parametrize("account_ids", [["acc1"], ["acc1", "acc2"]], ids=["one-account", "two-accounts"]) +def test_agent_searches_then_executes_in_search_execute_mode(mcp_mock_server: str, account_ids: list[str]): + """The same script runs on one account's pair and on the trio over two.""" + toolset = StackOneToolSet( + api_key="test-key", + base_url=mcp_mock_server, + execute={"account_ids": account_ids}, + tool_mode="search_execute", + ) + tools = toolset.pydantic_ai() + + def execute_the_first_hit(found: ToolReturnPart | None) -> ToolCallPart: + assert found is not None + hit = found.content["actions"][0] + return ToolCallPart( + "stackone_execute_action", + { + "action_id": hit["action_id"], + "stackone_account_id": hit["available_accounts"][0]["id"], + **hit["example_request"], + }, + ) + + model = _scripted_model( + lambda _: ToolCallPart("stackone_search_actions", {"query": "list items"}), + execute_the_first_hit, + ) + + result = Agent(model, tools=tools).run_sync("list my items") + + assert {tool.name for tool in tools} >= {"stackone_search_actions", "stackone_execute_action"} + assert json.loads(result.output) == { + "isError": False, + "result": {"data": {"nodes": []}, "echoed_query": {"page_size": 25}, "account_id": "acc1"}, + } + + # --- Import error path --- diff --git a/tests/test_multi_account.py b/tests/test_multi_account.py index c4cd01c..3f27c2d 100644 --- a/tests/test_multi_account.py +++ b/tests/test_multi_account.py @@ -1,4 +1,4 @@ -"""More than one account: the catalog cache, search hits, routing and adapters. +"""More than one account: the catalog cache, the search_execute trio, search hits and adapters. With discovery as the default, the same provider linked on two accounts is common, so every surface has to say which account it means. @@ -19,6 +19,7 @@ from stackone_ai.toolset import FAILED_ACCOUNT_RETRY_SECONDS, StackOneToolSet from stackone_ai.types import ( StackOneAPIError, + ToolArgumentsError, ToolParameters, ToolsetConfigError, ToolsetLoadError, @@ -209,340 +210,430 @@ def test_nothing_is_cached(self, monkeypatch, clock): assert len(accounts.listed) == 4 -def _meta_tools(monkeypatch: pytest.MonkeyPatch, per_account: dict[str, list[str]]) -> dict[str, Any]: - """Serve ``per_account``'s meta tools, and record what each one is called with.""" - seen: dict[str, Any] = {} +_TRIO = ["stackone_list_accounts", "stackone_search_actions", "stackone_execute_action"] +_TRIO_ENDPOINT = "https://api.stackone.com/mcp?tool-mode=search_execute" - def fake_execute(self: StackOneMcpTool, arguments: Any) -> dict[str, Any]: - seen["tool"], seen["account"], seen["arguments"] = self.name, self.get_account_id(), arguments - if self.name.endswith("_search_actions"): - return {"session_id": f"s-{self.get_account_id()}", "actions": seen["hits"][self.name]} - return {"isError": False, "result": {}} - monkeypatch.setattr(StackOneMcpTool, "execute", fake_execute) - monkeypatch.setattr( - "stackone_ai.toolset.fetch_mcp_tools", - lambda _e, headers, **_k: [_tool(name) for name in per_account[headers["x-account-id"]]], - ) - return seen +class _Trio: + """A fake search_execute listing of the trio, and a record of every listing and call. + ``served`` is the tool names listed, or an exception to raise; ``hits`` what search returns. + """ -class TestSearchHits: - def test_each_hit_names_its_account_and_top_k_cuts_the_merged_ranking(self, monkeypatch): - seen = _meta_tools( - monkeypatch, - { - "acc1": ["hris_acc1_search_actions"], - "acc2": ["hris_acc2_search_actions", "crm_acc2_search_actions"], - }, - ) - seen["hits"] = { - "hris_acc1_search_actions": [ - {"action_id": "hris_list", "similarity_score": 0.9}, - {"action_id": "hris_get", "similarity_score": 0.4}, - ], - "hris_acc2_search_actions": [ - {"action_id": "hris_list", "similarity_score": 0.8}, - {"action_id": "hris_get", "similarity_score": 0.3}, - ], - "crm_acc2_search_actions": [{"action_id": "crm_list", "similarity_score": 0.7}], - } - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) + def __init__(self, monkeypatch: pytest.MonkeyPatch, served: list[str] | Exception | None = None) -> None: + self.served: list[str] | Exception = _TRIO if served is None else served + self.hits: dict[str, Any] = {"session_id": "s-1", "actions": []} + self.listings: list[tuple[str, dict[str, str]]] = [] + self.calls: list[tuple[str, str, str | None, Any]] = [] + monkeypatch.setattr("stackone_ai.toolset.fetch_mcp_tools", self._fetch) + monkeypatch.setattr(StackOneMcpTool, "execute", lambda tool, args: self._execute(tool, args)) + + def _fetch(self, endpoint: str, headers: dict[str, str], **_kwargs: object) -> list[McpToolDefinition]: + self.listings.append((endpoint, headers)) + if isinstance(self.served, Exception): + raise self.served + return [_tool(name) for name in self.served] + + def _execute(self, tool: StackOneMcpTool, arguments: Any) -> dict[str, Any]: + self.calls.append((tool.name, tool._endpoint, tool.get_account_id(), arguments)) + if tool.name == "stackone_search_actions": + return self.hits + return {"isError": False, "result": {}} - hits = toolset.search("list", top_k=3) - # top_k is the total, not per connector: 3 connectors asked for 3 each served 5. - assert seen["arguments"]["top_k"] == 3 - assert [(h["action_id"], h["account_id"], h["session_id"]) for h in hits] == [ - ("hris_list", "acc1", "s-acc1"), - ("hris_list", "acc2", "s-acc2"), - ("crm_list", "acc2", "s-acc2"), - ] +class TestSearchExecuteListing: + """One listing spans the whole scope: the trio, bound to no account.""" + def test_every_account_is_in_one_sorted_deduped_comma_list_not_a_header(self, monkeypatch): + trio = _Trio(monkeypatch) + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc2", "acc1", "acc2"]}) -class TestExecuteRouting: - PER_ACCOUNT = { - "acc1": ["hris_acc1_execute_action"], - "acc2": ["hris_acc2_execute_action"], - } + tools = toolset.fetch_tools(mode="search_execute") - def test_a_connector_on_two_accounts_is_refused(self, monkeypatch): - seen = _meta_tools(monkeypatch, self.PER_ACCOUNT) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc2", "acc1"]}) - with pytest.raises(ToolsetConfigError) as excinfo: - toolset.execute("hris_list_employees") - assert str(excinfo.value) == ( - '"hris_list_employees" matches 2 connectors on different accounts ' - "(hris_acc1_execute_action on acc1, hris_acc2_execute_action on acc2). " - "Pass the account id to use, such as a search hit's account_id." - ) - assert "tool" not in seen + assert [t.name for t in tools] == _TRIO + assert all(t.get_account_id() is None for t in tools) + [(endpoint, headers)] = trio.listings + assert endpoint == f"{_TRIO_ENDPOINT}&x-account-id=acc1%2Cacc2" + assert "x-account-id" not in {name.lower() for name in headers} - @pytest.mark.parametrize("account", ["acc1", "acc2"]) - def test_a_hits_account_id_routes_to_it(self, monkeypatch, account): - seen = _meta_tools(monkeypatch, self.PER_ACCOUNT) + def test_the_trio_executes_on_the_endpoint_that_listed_it(self, monkeypatch): + trio = _Trio(monkeypatch) toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - toolset.execute("hris_list_employees", account_ids=[account]) - assert (seen["tool"], seen["account"]) == (f"hris_{account}_execute_action", account) + toolset.fetch_tools(mode="search_execute").get_tool("stackone_list_accounts").execute({}) + [(_name, endpoint, account, _args)] = trio.calls + assert (endpoint, account) == (f"{_TRIO_ENDPOINT}&x-account-id=acc1%2Cacc2", None) + def test_it_is_cached_per_scope(self, monkeypatch): + trio = _Trio(monkeypatch) + toolset = StackOneToolSet(api_key="k") + for _ in range(2): + toolset.fetch_tools(account_ids=["acc1", "acc2"], mode="search_execute") + toolset.fetch_tools(account_ids=["acc1"], mode="search_execute") + assert [endpoint for endpoint, _ in trio.listings] == [ + f"{_TRIO_ENDPOINT}&x-account-id=acc1%2Cacc2", + f"{_TRIO_ENDPOINT}&x-account-id=acc1", + ] -def _providers(monkeypatch: pytest.MonkeyPatch, status: int = 200, **providers: str) -> list[int]: - """Answer GET /accounts with each account on its provider, or fail with ``status``.""" - body = [ - {"id": account, "provider": provider, "status": "active"} for account, provider in providers.items() - ] - calls: list[int] = [] + def test_a_failure_is_raised_and_not_cached(self, monkeypatch): + """There is one request, so no account to skip.""" + trio = _Trio(monkeypatch, served=StackOneAPIError("down", 503, None)) + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) + for _ in range(2): + with pytest.raises(StackOneAPIError) as excinfo: + toolset.fetch_tools(mode="search_execute") + assert excinfo.value.status_code == 503 + assert len(trio.listings) == 2 + + def test_providers_narrow_the_accounts_with_one_accounts_listing(self, monkeypatch): + trio = _Trio(monkeypatch) + calls = _providers(monkeypatch, acc1="HRIS", acc2="crm", acc3="hris") + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2", "acc3"]}) + + tools = toolset.fetch_tools(providers=["hris"], mode="search_execute") + toolset.fetch_tools(providers=["crm"], mode="search_execute") + + # The trio is named after no provider, so it is kept whole. + assert [t.name for t in tools] == _TRIO + assert [endpoint for endpoint, _ in trio.listings] == [ + f"{_TRIO_ENDPOINT}&x-account-id=acc1%2Cacc3", + f"{_TRIO_ENDPOINT}&x-account-id=acc2", + ] + assert len(calls) == 1 - def handle(_self: Any, request: httpx.Request) -> httpx.Response: - calls.append(1) - if status != 200: - return httpx.Response(status, text="nope", request=request) - return httpx.Response(200, json=body, request=request) + def test_no_account_on_the_providers_means_no_tools_and_no_listing(self, monkeypatch): + trio = _Trio(monkeypatch) + _providers(monkeypatch, acc1="hris") + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "unlisted"]}) + assert len(toolset.fetch_tools(providers=["crm"], mode="search_execute")) == 0 + assert trio.listings == [] - monkeypatch.setattr(httpx.HTTPTransport, "handle_request", handle) - return calls + def test_actions_still_filter_the_tool_names(self, monkeypatch): + _Trio(monkeypatch) + toolset = StackOneToolSet(api_key="k", account_id="acc1") + tools = toolset.fetch_tools(actions=["*_actions"], mode="search_execute") + assert [t.name for t in tools] == ["stackone_search_actions"] -class TestExecuteWithAFailedAccount: - """An account that failed to list could have served the action: running it on another - account instead would run it on an account the caller never chose. - """ +class TestSearchHits: + def test_one_search_spans_the_scope_and_top_k_cuts_its_hits(self, monkeypatch): + trio = _Trio(monkeypatch) + trio.hits = { + "session_id": "s-1", + "actions": [ + {"action_id": "hris_list", "available_accounts": [{"id": "acc1", "name": "A"}]}, + {"action_id": "crm_list", "available_accounts": [{"id": "acc2", "name": "B"}]}, + {"action_id": "hris_get", "available_accounts": [{"id": "acc1", "name": "A"}]}, + ], + } + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - def _execute(self, monkeypatch: pytest.MonkeyPatch, **accounts: list[str] | Exception) -> Any: - listing = _Accounts(monkeypatch, **accounts) - seen: dict[str, Any] = {} + hits = toolset.search("list", top_k=2) - def fake_execute(tool: StackOneMcpTool, arguments: Any) -> dict[str, Any]: - seen["account"] = tool.get_account_id() - return {"isError": False} + assert [(name, args) for name, _, _, args in trio.calls] == [ + ("stackone_search_actions", {"query": "list", "top_k": 2}) + ] + assert hits == [ + { + "action_id": "hris_list", + "available_accounts": [{"id": "acc1", "name": "A"}], + "session_id": "s-1", + }, + { + "action_id": "crm_list", + "available_accounts": [{"id": "acc2", "name": "B"}], + "session_id": "s-1", + }, + ] - monkeypatch.setattr(StackOneMcpTool, "execute", fake_execute) - return listing, seen + def test_account_ids_scope_the_listing_searched(self, monkeypatch): + trio = _Trio(monkeypatch) + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) + toolset.search("list", account_ids=["acc2"]) + [(_name, endpoint, _account, _args)] = trio.calls + assert endpoint == f"{_TRIO_ENDPOINT}&x-account-id=acc2" - def test_an_account_on_the_actions_connector_refuses(self, monkeypatch): - _, seen = self._execute(monkeypatch, acc1=RuntimeError("boom"), acc2=["hris_acc2_execute_action"]) - _providers(monkeypatch, acc1="HRIS", acc2="hris") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc2", "acc1"]}) - toolset.fetch_accounts() + def test_a_listing_without_the_search_tool_raises(self, monkeypatch): + _Trio(monkeypatch, served=["stackone_submit_feedback"]) + toolset = StackOneToolSet(api_key="k", account_id="acc1") with pytest.raises(ToolsetLoadError) as excinfo: - toolset.execute("hris_list_employees") + toolset.search("list") assert str(excinfo.value) == ( - '"hris_list_employees" may be served by an account that failed to list (acc1: boom). ' - "Pass the account id to use, such as a search hit's account_id." + "The server did not serve stackone_search_actions for this account scope." ) - assert seen == {} - def test_accounts_with_no_known_provider_refuse_in_account_order(self, monkeypatch): - """GET /accounts failed, so either could be on the action's connector.""" - _, seen = self._execute( + +class TestExecuteArguments: + """Pinned keys are set however the model filled the arguments, and action_id goes last, + as in Node.""" + + def _arguments(self, monkeypatch: pytest.MonkeyPatch, *args: Any, **kwargs: Any) -> Any: + trio = _Trio(monkeypatch) + toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) + toolset.execute(*args, **kwargs) + [(name, _endpoint, _account, arguments)] = trio.calls + assert name == "stackone_execute_action" + return arguments + + def test_one_account_id_becomes_stackone_account_id(self, monkeypatch): + arguments = self._arguments( monkeypatch, - acc3=RuntimeError("down"), - acc1=RuntimeError("boom"), - acc2=["hris_acc2_execute_action"], + "hris_list_employees", + {"stackone_account_id": "model", "session_id": "model", "action_id": "x", "query": {"a": 1}}, + account_ids=["acc2", "acc2"], + session_id="s-1", ) - calls = _providers(monkeypatch, status=403) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc3", "acc2", "acc1"]}) - with pytest.raises(ToolsetLoadError, match=r"failed to list \(acc1: boom; acc3: down\)\."): - toolset.execute("hris_list_employees") - assert seen == {} - assert len(calls) == 1 + assert list(arguments.items()) == [ + ("query", {"a": 1}), + ("stackone_account_id", "acc2"), + ("session_id", "s-1"), + ("action_id", "hris_list_employees"), + ] - def test_explicit_ids_learn_their_providers_with_one_accounts_listing(self, monkeypatch, clock): - """With explicit ids no GET /accounts was made, so execute() makes one to learn the - failed account's provider before deciding.""" - listing, seen = self._execute( - monkeypatch, acc1=RuntimeError("dead"), acc2=["hris_acc2_execute_action"] + def test_several_or_no_account_ids_leave_the_choice_to_the_arguments(self, monkeypatch): + arguments = self._arguments( + monkeypatch, + "hris_list_employees", + {"session_id": "s-0", "stackone_account_id": "acc1", "query": {}}, + account_ids=["acc1", "acc2"], ) - calls = _providers(monkeypatch, acc1="crm", acc2="hris") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - toolset.execute("hris_list_employees") - assert seen == {"account": "acc2"} - assert len(calls) == 1 - - # Known now: the next call neither lists accounts nor re-lists the dead one. - listing.listed.clear() - toolset.execute("hris_list_employees") - assert (len(calls), listing.listed) == (1, []) + assert list(arguments.items()) == [ + ("session_id", "s-0"), + ("stackone_account_id", "acc1"), + ("query", {}), + ("action_id", "hris_list_employees"), + ] - def test_a_rate_limited_lookup_is_raised(self, monkeypatch): - """The 429 is the key's: swallowed, it would read as a provider no lookup has named.""" - monkeypatch.setattr(tools_module, "_sleep", lambda _delay: None) - self._execute(monkeypatch, acc1=RuntimeError("boom"), acc2=["hris_acc2_execute_action"]) - _providers(monkeypatch, status=429) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - with pytest.raises(StackOneAPIError) as excinfo: + def test_a_listing_without_the_execute_tool_raises(self, monkeypatch): + _Trio(monkeypatch, served=["stackone_list_accounts"]) + toolset = StackOneToolSet(api_key="k", account_id="acc1") + with pytest.raises(ToolsetLoadError) as excinfo: toolset.execute("hris_list_employees") - assert excinfo.value.status_code == 429 - - @pytest.mark.parametrize( - "accounts", - [{"status": 403}, {"acc1": "hris"}], - ids=["the lookup fails", "the lookup does not list it"], - ) - def test_a_lookup_that_misses_waits_out_the_failure_window(self, monkeypatch, clock, accounts): - """Looking up and listing it again on every call would pay a GET /accounts each - time, and the listing's timeout if it hangs.""" - listing, seen = self._execute( - monkeypatch, acc1=["hris_acc1_execute_action"], acc2=RuntimeError("dead") + assert str(excinfo.value) == ( + "The server did not serve stackone_execute_action for this account scope." ) - calls = _providers(monkeypatch, **accounts) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - for _ in range(3): - with pytest.raises(ToolsetLoadError, match=r"failed to list \(acc2: dead\)"): - toolset.execute("hris_list_employees") - assert seen == {} - assert (len(calls), listing.listed.count("acc2")) == (1, 1) - clock[0] += FAILED_ACCOUNT_RETRY_SECONDS - with pytest.raises(ToolsetLoadError, match=r"failed to list \(acc2: dead\)"): - toolset.execute("hris_list_employees") - assert (len(calls), listing.listed.count("acc2")) == (2, 2) - def test_clearing_the_cache_forgets_a_lookups_miss(self, monkeypatch, clock): - self._execute(monkeypatch, acc1=["hris_acc1_execute_action"], acc2=RuntimeError("dead")) - calls = _providers(monkeypatch, status=403) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - with pytest.raises(ToolsetLoadError): - toolset.execute("hris_list_employees") - toolset.clear_catalog_cache() - with pytest.raises(ToolsetLoadError): - toolset.execute("hris_list_employees") - assert len(calls) == 2 +class TestTooManyAccounts: + def test_more_than_50_raise_before_any_request(self, monkeypatch): + trio = _Trio(monkeypatch) + toolset = StackOneToolSet(api_key="k", execute={"account_ids": [f"acc{i:02}" for i in range(51)]}) + with pytest.raises(ToolsetConfigError) as excinfo: + toolset.search("list") + assert str(excinfo.value) == ( + "51 accounts are in scope, but search and execute span at most 50 in one request. " + "Pass account ids to narrow the scope." + ) + assert trio.listings == [] + + def test_50_are_one_request(self, monkeypatch): + trio = _Trio(monkeypatch) + accounts = [f"acc{i:02}" for i in range(50)] + StackOneToolSet(api_key="k").fetch_tools(account_ids=accounts, mode="search_execute") + [(endpoint, _headers)] = trio.listings + assert endpoint == f"{_TRIO_ENDPOINT}&x-account-id={'%2C'.join(accounts)}" + + def test_individual_mode_has_no_cap(self, monkeypatch): + accounts = _Accounts(monkeypatch, **{f"acc{i:02}": [] for i in range(51)}) + StackOneToolSet(api_key="k").fetch_tools(account_ids=sorted(accounts.accounts)) + assert len(accounts.listed) == 51 + + +_PAIR_SEARCH = "mock_acc1_search_actions" +_PAIR_EXECUTE = "mock_acc1_execute_action" + + +class _Pair: + """A fake one-account search_execute listing: acc1's pair and the feedback tool, and a + record of every tools/call. ``answer`` is what the next call returns, or raises.""" + + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + self.answer: dict[str, Any] | Exception = {"isError": False, "result": {}} + self.calls: list[tuple[str, str, dict[str, Any]]] = [] + served = [ + McpToolDefinition( + _PAIR_SEARCH, + f"Search acc1's actions. Run a hit with {_PAIR_EXECUTE}.", + { + "type": "object", + "properties": { + "query": {"type": "string"}, + "session_id": {"type": "string", "description": f"From an earlier {_PAIR_SEARCH}."}, + }, + "required": ["query"], + }, + ), + McpToolDefinition( + _PAIR_EXECUTE, + f"Execute an action {_PAIR_SEARCH} found.", + { + "type": "object", + "properties": {"action_id": {"type": "string"}}, + "required": ["action_id"], + }, + ), + McpToolDefinition("stackone_submit_feedback", "Feedback.", {"type": "object", "properties": {}}), + ] + monkeypatch.setattr("stackone_ai.toolset.fetch_mcp_tools", lambda *_args, **_kwargs: served) + monkeypatch.setattr(tools_module, "call_mcp_tool", self._call) - def test_the_call_whose_lookup_misses_does_not_list_it_again(self, monkeypatch, clock): - """Its own miss holds it out at once, so concurrent calls sharing the lookup don't - each pay its timeout.""" - listing, _ = self._execute(monkeypatch, acc1=["hris_acc1_execute_action"], acc2=RuntimeError("dead")) - _providers(monkeypatch, status=403) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - toolset.fetch_tools(mode="search_execute") - clock[0] += 1 - with pytest.raises(ToolsetLoadError, match=r"failed to list \(acc2: dead\)"): - toolset.execute("hris_list_employees") - assert listing.listed.count("acc2") == 1 + def _call(self, endpoint: str, _headers: Any, name: str, arguments: Any, **_kwargs: Any) -> Any: + self.calls.append((name, endpoint, arguments)) + if isinstance(self.answer, Exception): + raise self.answer + return self.answer - def test_a_miss_is_not_recorded_across_a_clear_during_its_lookup(self, monkeypatch, clock): - self._execute(monkeypatch, acc1=["hris_acc1_execute_action"], acc2=RuntimeError("dead")) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - calls: list[int] = [] - def handle(_self: Any, request: httpx.Request) -> httpx.Response: - calls.append(1) - if len(calls) == 1: - toolset.clear_catalog_cache() - return httpx.Response(403, text="nope", request=request) +class TestOneAccountPair: + """One account is served its own pair, which the SDK presents as the trio's two tools.""" - monkeypatch.setattr(httpx.HTTPTransport, "handle_request", handle) - for _ in range(2): - with pytest.raises(ToolsetLoadError): - toolset.execute("hris_list_employees") - assert len(calls) == 2 + def _toolset(self) -> StackOneToolSet: + return StackOneToolSet(api_key="k", account_id="acc1") - def test_a_miss_is_forgotten_once_get_accounts_names_the_account(self, monkeypatch, clock): - listing, _ = self._execute(monkeypatch, acc1=["hris_acc1_execute_action"], acc2=RuntimeError("dead")) - _providers(monkeypatch, status=403) - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - with pytest.raises(ToolsetLoadError): - toolset.execute("hris_list_employees") - _providers(monkeypatch, acc1="hris", acc2="hris") - toolset.fetch_accounts() + def test_it_is_listed_under_the_trio_names_with_every_served_name_rewritten(self, monkeypatch): + _Pair(monkeypatch) + tools = self._toolset().fetch_tools(mode="search_execute") - clock[0] += 1 - with pytest.raises(ToolsetLoadError): - toolset.execute("hris_list_employees") - # acc2 is hris's, so it is listed again at once rather than after the window. - assert listing.listed.count("acc2") == 2 + assert [t.name for t in tools] == [ + "stackone_search_actions", + "stackone_execute_action", + "stackone_submit_feedback", + ] + search, execute = tools[0], tools[1] + assert search.description == "Search acc1's actions. Run a hit with stackone_execute_action." + assert execute.description == "Execute an action stackone_search_actions found." + assert search.to_openai_function()["function"]["parameters"]["properties"]["session_id"] == { + "type": "string", + "description": "From an earlier stackone_search_actions.", + } + assert all(t.get_account_id() is None for t in tools) - def test_an_account_on_another_provider_does_not(self, monkeypatch): - _, seen = self._execute(monkeypatch, acc1=RuntimeError("boom"), acc2=["hris_acc2_execute_action"]) - _providers(monkeypatch, acc1="crm", acc2="hris") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - toolset.fetch_accounts() - toolset.execute("hris_list_employees") - assert seen == {"account": "acc2"} - - def test_a_failed_account_on_a_shorter_provider_prefix_does_not(self, monkeypatch): - """acc1's provider ``hris`` prefixes the action, but its connector is ``hris_beta``.""" - _, seen = self._execute( - monkeypatch, acc1=RuntimeError("boom"), acc2=["hris_beta_acc2_execute_action"] - ) - _providers(monkeypatch, acc1="hris", acc2="hris_beta") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - toolset.fetch_accounts() - toolset.execute("hris_beta_list_employees") - assert seen == {"account": "acc2"} + def test_it_calls_the_served_tool_on_the_endpoint_that_listed_it(self, monkeypatch): + pair = _Pair(monkeypatch) + self._toolset().execute("mock_list_items", account_ids=["acc1"]) + assert pair.calls == [ + (_PAIR_EXECUTE, f"{_TRIO_ENDPOINT}&x-account-id=acc1", {"action_id": "mock_list_items"}) + ] - def test_a_failed_account_is_listed_again_at_once_and_then_routes_as_usual(self, monkeypatch, clock): - listing, _ = self._execute(monkeypatch, acc1=RuntimeError("boom"), acc2=["hris_acc2_execute_action"]) - _providers(monkeypatch, acc1="hris", acc2="hris") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - # Left out of the search_execute catalog, which execute() lists in, for 30s from now. - assert [t.name for t in toolset.fetch_tools(mode="search_execute")] == ["hris_acc2_execute_action"] + def test_its_results_read_in_trio_names(self, monkeypatch): + pair = _Pair(monkeypatch) + pair.answer = {"session_id": "s-1", "actions": [], "hint": f"Call {_PAIR_EXECUTE} next."} + tool = self._toolset().fetch_tools(mode="search_execute").get_tool("stackone_search_actions") + assert tool is not None + assert tool.execute({"query": "q"})["hint"] == "Call stackone_execute_action next." + + def test_its_errors_read_in_trio_names(self, monkeypatch): + pair = _Pair(monkeypatch) + pair.answer = StackOneAPIError( + f'Tool "{_PAIR_EXECUTE}" failed: call {_PAIR_SEARCH} first', 400, {"error": f"see {_PAIR_SEARCH}"} + ) + with pytest.raises(StackOneAPIError) as excinfo: + self._toolset().execute("nope") + assert str(excinfo.value) == ( + 'Tool "stackone_execute_action" failed: call stackone_search_actions first' + ) + assert (excinfo.value.status_code, excinfo.value.response_body) == ( + 400, + {"error": "see stackone_search_actions"}, + ) - listing.accounts["acc1"] = ["hris_acc1_execute_action"] - listing.listed.clear() - with pytest.raises(ToolsetConfigError, match="matches 2 connectors on different accounts"): - toolset.execute("hris_list_employees") - # Only the failed account, and without waiting for it to be due. - assert listing.listed == ["acc1"] - - # Its listing is cached for the next call, like any other. - listing.listed.clear() - toolset.fetch_tools(mode="search_execute") - assert listing.listed == [] - - def test_an_account_that_fails_during_the_call_is_not_listed_again_in_it(self, monkeypatch, clock): - """Listing it again at once, as Node once did, would only double the wait.""" - listing, _ = self._execute(monkeypatch, acc1=RuntimeError("hangs"), acc2=["hris_acc2_execute_action"]) - _providers(monkeypatch, acc1="hris", acc2="hris") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - with pytest.raises(ToolsetLoadError): - toolset.execute("hris_list_employees") - assert sorted(listing.listed) == ["acc1", "acc2"] + def test_search_hits_gain_the_account_and_connector_when_absent(self, monkeypatch): + pair = _Pair(monkeypatch) + pair.answer = { + "provider": "mock", + "session_id": "s-1", + "actions": [ + {"action_id": "mock_a"}, + { + "action_id": "mock_b", + "provider": "served", + "available_accounts": [{"id": "acc1", "name": "A"}], + }, + ], + } + assert self._toolset().search("list") == [ + { + "available_accounts": [{"id": "acc1"}], + "provider": "mock", + "action_id": "mock_a", + "session_id": "s-1", + }, + { + "action_id": "mock_b", + "provider": "served", + "available_accounts": [{"id": "acc1", "name": "A"}], + "session_id": "s-1", + }, + ] + assert pair.calls[0][:1] == (_PAIR_SEARCH,) + + def test_its_own_account_and_connector_are_dropped_from_the_arguments(self, monkeypatch): + pair = _Pair(monkeypatch) + pair.answer = {"actions": []} + tools = self._toolset().fetch_tools(mode="search_execute") + tools.get_tool("stackone_search_actions").execute( # type: ignore[union-attr] + {"query": "q", "provider": "MOCK", "stackone_account_id": "acc1"} + ) + tools.get_tool("stackone_execute_action").execute( # type: ignore[union-attr] + {"action_id": "mock_a", "stackone_account_id": "acc1"} + ) + # None is not given, as for session_id. + tools.get_tool("stackone_execute_action").execute( # type: ignore[union-attr] + {"action_id": "mock_a", "stackone_account_id": None, "provider": None} + ) + assert [arguments for _, _, arguments in pair.calls] == [ + {"query": "q"}, + {"action_id": "mock_a"}, + {"action_id": "mock_a"}, + ] - def test_a_failed_account_on_another_provider_is_not_listed_again(self, monkeypatch, clock): - """A hanging crm account must not cost every hris action its timeout.""" - listing, seen = self._execute( - monkeypatch, acc1=RuntimeError("hangs"), acc2=["hris_acc2_execute_action"] + def test_another_account_is_refused_before_any_call(self, monkeypatch): + pair = _Pair(monkeypatch) + tool = self._toolset().fetch_tools(mode="search_execute").get_tool("stackone_execute_action") + assert tool is not None + with pytest.raises(ToolArgumentsError) as excinfo: + tool.execute({"action_id": "mock_a", "stackone_account_id": "acc2"}) + assert str(excinfo.value) == ( + 'stackone_account_id "acc2" is not an account "stackone_execute_action" can run on; ' + 'it runs on "acc1".' ) - _providers(monkeypatch, acc1="crm", acc2="hris") - toolset = StackOneToolSet(api_key="k", execute={"account_ids": ["acc1", "acc2"]}) - toolset.fetch_accounts() - toolset.execute("hris_list_employees") + assert pair.calls == [] - listing.listed.clear() - toolset.execute("hris_list_employees") - assert listing.listed == [] - assert seen == {"account": "acc2"} + def test_another_provider_is_refused_before_any_call(self, monkeypatch): + pair = _Pair(monkeypatch) + tool = self._toolset().fetch_tools(mode="search_execute").get_tool("stackone_search_actions") + assert tool is not None + with pytest.raises(ToolArgumentsError) as excinfo: + tool.execute({"query": "q", "provider": "hibob"}) + assert str(excinfo.value) == 'provider "hibob" is not linked in this account scope, which has "mock".' + assert pair.calls == [] - # One that could serve the action is still listed again at once. - with pytest.raises(ToolsetLoadError, match=r"failed to list \(acc1: hangs\)"): - toolset.execute("crm_list_contacts") - assert listing.listed == ["acc1"] + def test_feedback_passes_through(self, monkeypatch): + pair = _Pair(monkeypatch) + self._toolset().submit_feedback("positive", ["stackone_search_actions"]) + [(name, _endpoint, arguments)] = pair.calls + assert (name, arguments["tool_names"]) == ("stackone_submit_feedback", ["stackone_search_actions"]) + def test_a_tool_named_for_another_account_is_left_as_served(self, monkeypatch): + _Pair(monkeypatch) + tools = StackOneToolSet(api_key="k", account_id="acc2").fetch_tools(mode="search_execute") + assert [t.name for t in tools] == [_PAIR_SEARCH, _PAIR_EXECUTE, "stackone_submit_feedback"] -class TestPinnedArgumentsGoLast: - """A pinned session_id and action_id are moved to the end, as in Node.""" - def test_a_supplied_session_id_is_moved_before_action_id(self, monkeypatch): - seen = _meta_tools(monkeypatch, {"acc1": ["hris_acc1_execute_action"]}) - toolset = StackOneToolSet(api_key="k", account_id="acc1") - toolset.execute( - "hris_list_employees", - {"session_id": "model-chosen", "action_id": "x", "query": {"a": 1}}, - session_id="s-1", - ) - assert list(seen["arguments"].items()) == [ - ("query", {"a": 1}), - ("session_id", "s-1"), - ("action_id", "hris_list_employees"), - ] +def _providers(monkeypatch: pytest.MonkeyPatch, status: int = 200, **providers: str) -> list[int]: + """Answer GET /accounts with each account on its provider, or fail with ``status``.""" + body = [ + {"id": account, "provider": provider, "status": "active"} for account, provider in providers.items() + ] + calls: list[int] = [] - def test_an_unpinned_session_id_stays_where_it_was(self, monkeypatch): - seen = _meta_tools(monkeypatch, {"acc1": ["hris_acc1_execute_action"]}) - toolset = StackOneToolSet(api_key="k", account_id="acc1") - toolset.execute("hris_list_employees", {"session_id": "s-0", "query": {}}) - assert list(seen["arguments"]) == ["session_id", "query", "action_id"] + def handle(_self: Any, request: httpx.Request) -> httpx.Response: + calls.append(1) + if status != 200: + return httpx.Response(status, text="nope", request=request) + return httpx.Response(200, json=body, request=request) + + monkeypatch.setattr(httpx.HTTPTransport, "handle_request", handle) + return calls class TestExtraHeadersCannotReplaceTheSdksOwn: diff --git a/tests/test_shared_vectors.py b/tests/test_shared_vectors.py index a1556da..1ac2dad 100644 --- a/tests/test_shared_vectors.py +++ b/tests/test_shared_vectors.py @@ -27,7 +27,6 @@ from stackone_ai.tools import ( McpToolDefinition, RateLimitRetryingClient, - StackOneMcpTool, StackOneTool, Tools, _backoff_delay, @@ -463,19 +462,6 @@ def _skip_account(mp, log) -> Emitted: return _warnings(log)[0], {"account_id": "acc-2", "error": "boom"} -def _skip_connector(mp, log) -> Emitted: - mp.setattr("stackone_ai.toolset.fetch_mcp_tools", _listing("c_{account}_search_actions")) - - def search(self: StackOneTool, _arguments: Any = None) -> dict[str, Any]: - if self.name == "c_acc-2_search_actions": - raise StackOneAPIError("boom", 500, None) - return {"actions": [{"action_id": "c_a"}]} - - mp.setattr(StackOneMcpTool, "execute", search) - StackOneToolSet(api_key="k").search("q", account_ids=["acc-1", "acc-2"]) - return _warnings(log)[0], {"tool": "c_acc-2_search_actions", "error": "boom"} - - def _duplicate_tool_names(mp, log) -> Emitted: tools = Tools([_served_tool({"type": "object", "properties": {}}, "t") for _ in range(2)]) # Once by the collection, and again by each adapter that keeps the first of each name. @@ -485,17 +471,6 @@ def _duplicate_tool_names(mp, log) -> Emitted: return adapter, {"count": 1, "names": "t"} -def _ambiguous_connector(mp, log) -> Emitted: - mp.setattr("stackone_ai.toolset.fetch_mcp_tools", _listing("linear_{account}_execute_action")) - mp.setattr(StackOneMcpTool, "execute", lambda _self, _arguments=None: {}) - error = _raised( - ToolsetConfigError, - lambda: StackOneToolSet(api_key="k").execute("linear_list_issues", account_ids=["acc-2", "acc-1"]), - ) - tools = "linear_acc-1_execute_action on acc-1, linear_acc-2_execute_action on acc-2" - return error, {"action_id": "linear_list_issues", "count": 2, "tools": tools} - - def _account_id_env_ignored(mp, log) -> Emitted: mp.setenv("STACKONE_ACCOUNT_ID", "acc-1") StackOneToolSet(api_key="k") @@ -503,21 +478,6 @@ def _account_id_env_ignored(mp, log) -> Emitted: return warning, {} -def _connector_account_unavailable(mp, log) -> Emitted: - mp.setattr( - "stackone_ai.toolset.fetch_mcp_tools", - _failing_for({"acc-2"}, ToolsetLoadError("boom"), "linear_{account}_execute_action"), - ) - # Explicit ids, so execute() makes one GET /accounts to learn acc-2's provider. It does - # not list acc-2, so its provider stays unknown and it blocks. - _accounts_answer(mp, lambda _r: httpx.Response(200, json=[{"id": "acc-1", "provider": "linear"}])) - error = _raised( - ToolsetLoadError, - lambda: StackOneToolSet(api_key="k").execute("linear_list_issues", account_ids=["acc-1", "acc-2"]), - ) - return error, {"action_id": "linear_list_issues", "failures": "acc-2: boom"} - - def _non_shared_accounts_skipped(mp, log) -> Emitted: accounts = [ {"id": "acc-1", "provider": "linear", "status": "active", "shared": True}, @@ -638,19 +598,6 @@ def fail(_endpoint: str, headers: dict[str, str], **_kwargs: object) -> list[Mcp return error, {"failures": "acc-1: gone; acc-2: boom"} -def _no_connector_returned_results(mp, log) -> Emitted: - mp.setattr("stackone_ai.toolset.fetch_mcp_tools", _listing("c_{account}_search_actions")) - - def fail(self: StackOneTool, _arguments: Any = None) -> dict[str, Any]: - raise StackOneAPIError("boom", 500, None) - - mp.setattr(StackOneMcpTool, "execute", fail) - error = _raised( - ToolsetLoadError, lambda: StackOneToolSet(api_key="k").search("q", account_ids=["acc-1", "acc-2"]) - ) - return error, {"failures": "c_acc-1_search_actions: boom | c_acc-2_search_actions: boom"} - - def _fetch_tools_failed(mp, log) -> Emitted: def explode(*_args: object, **_kwargs: object) -> list[McpToolDefinition]: raise RuntimeError("kaput") @@ -720,12 +667,42 @@ def _feedback_not_enabled(mp, log) -> Emitted: return _raised(ToolsetLoadError, lambda: toolset.submit_feedback("positive", ["t"])), {} -def _no_connector_for_action(mp, log) -> Emitted: - mp.setattr("stackone_ai.toolset.fetch_mcp_tools", _listing("jira_{account}_execute_action")) +def _trio_tool_not_served(mp, log) -> Emitted: + # The policy-disabled shape: a search_execute request answered with individual tools. + mp.setattr("stackone_ai.toolset.fetch_mcp_tools", _listing("jira_list_issues")) toolset = StackOneToolSet(api_key="k", account_id="acc-1") - return _raised(ToolsetLoadError, lambda: toolset.execute("linear_list_issues")), { - "action_id": "linear_list_issues" - } + return _raised(ToolsetLoadError, lambda: toolset.search("issues")), {"tool": "stackone_search_actions"} + + +def _too_many_accounts(mp, log) -> Emitted: + mp.setattr("stackone_ai.toolset.fetch_mcp_tools", _listing("stackone_search_actions")) + toolset = StackOneToolSet(api_key="k", execute={"account_ids": [f"acc-{i}" for i in range(51)]}) + return _raised(ToolsetConfigError, lambda: toolset.search("issues")), {"count": 51} + + +def _one_account_pair_tool(mp, name: str) -> StackOneTool: + """``name`` of the pair a one-account search_execute listing serves acc-1, as presented.""" + served = [ + McpToolDefinition(f"jira_acc-1_{suffix}", "", {"type": "object", "properties": {}}) + for suffix in ("search_actions", "execute_action") + ] + mp.setattr("stackone_ai.toolset.fetch_mcp_tools", lambda *_args, **_kwargs: served) + tool = StackOneToolSet(api_key="k", account_id="acc-1").fetch_tools(mode="search_execute").get_tool(name) + assert tool is not None + return tool + + +def _account_outside_scope(mp, log) -> Emitted: + tool = _one_account_pair_tool(mp, "stackone_execute_action") + arguments = {"action_id": "a", "stackone_account_id": "acc-2"} + error = _raised(ToolArgumentsError, lambda: tool.execute(arguments)) + return error, {"value": "acc-2", "tool": "stackone_execute_action", "account_id": "acc-1"} + + +def _provider_outside_scope(mp, log) -> Emitted: + tool = _one_account_pair_tool(mp, "stackone_search_actions") + error = _raised(ToolArgumentsError, lambda: tool.execute({"query": "q", "provider": "linear"})) + return error, {"value": "linear", "provider": "jira"} def _arguments_not_finite(mp, log) -> Emitted: @@ -785,11 +762,8 @@ def _unknown_tool(mp, log) -> Emitted: "rate-limit-retry": _rate_limit_retry, "rate-limit-deadline": _rate_limit_deadline, "skip-account": _skip_account, - "skip-connector": _skip_connector, "duplicate-tool-names": _duplicate_tool_names, - "ambiguous-connector": _ambiguous_connector, "account-id-env-ignored": _account_id_env_ignored, - "connector-account-unavailable": _connector_account_unavailable, "non-shared-accounts-skipped": _non_shared_accounts_skipped, "mcp-timeout": _mcp_timeout, "mcp-http-failure": _mcp_http_failure, @@ -804,7 +778,6 @@ def _unknown_tool(mp, log) -> Emitted: "no-active-accounts": _no_active_accounts, "no-shared-accounts": _no_shared_accounts, "all-accounts-failed": _all_accounts_failed, - "no-connector-returned-results": _no_connector_returned_results, "fetch-tools-failed": _fetch_tools_failed, "missing-api-key": _missing_api_key, "empty-account-id": _empty_account_id, @@ -817,7 +790,10 @@ def _unknown_tool(mp, log) -> Emitted: "top-k-out-of-range": _top_k_out_of_range, "tool-names-not-a-list": _tool_names_not_a_list, "feedback-not-enabled": _feedback_not_enabled, - "no-connector-for-action": _no_connector_for_action, + "trio-tool-not-served": _trio_tool_not_served, + "too-many-accounts": _too_many_accounts, + "account-outside-scope": _account_outside_scope, + "provider-outside-scope": _provider_outside_scope, "arguments-not-finite": _arguments_not_finite, "arguments-not-encodable": _arguments_not_encodable, "arguments-invalid-json": _arguments_invalid_json, diff --git a/tests/vectors/messages.json b/tests/vectors/messages.json index 868235b..b5a1d47 100644 --- a/tests/vectors/messages.json +++ b/tests/vectors/messages.json @@ -168,28 +168,7 @@ "python": true, "node": true }, - "notes": "The error part is whichever message the failure carries, so the whole text is the same in both SDKs only where that message is." - }, - { - "id": "skip-connector", - "kind": "warning", - "template": "Skipping connector that failed to search — {tool}: {error}", - "placeholders": { - "tool": { - "format": "text", - "description": "the *_search_actions tool's name" - }, - "error": { - "format": "text", - "description": "the failure's own message" - } - }, - "python": "Skipping connector that failed to search — {tool}: {error}", - "node": "Skipping connector that failed to search — {tool}: {error}", - "matches": { - "python": true, - "node": true - } + "notes": "Individual mode only: a search_execute listing is one request for the whole scope, and its failure raises. The error part is whichever message the failure carries, so the whole text is the same in both SDKs only where that message is." }, { "id": "duplicate-tool-names", @@ -213,36 +192,6 @@ }, "notes": "Language-neutral wording: neither SDK's method or parameter names. Emitted once when a fetched collection holds a clashing name, and again by every adapter that keeps the first tool per name and drops the rest: OpenAI, LangChain, Pydantic AI, AI SDK and Claude Agent SDK. Listing order is ascending account id, so the first is the lowest account id's." }, - { - "id": "ambiguous-connector", - "kind": "error", - "error": { - "python": "ToolsetConfigError", - "node": "ToolSetConfigError" - }, - "template": "{action_id} matches {count} connectors on different accounts ({tools}). Pass the account id to use, such as a search hit's account_id.", - "placeholders": { - "action_id": { - "format": "json", - "description": "the action id" - }, - "count": { - "format": "integer", - "description": "how many connectors match: the longest connector prefix, linked on more than one account" - }, - "tools": { - "format": "text", - "description": "each matching *_execute_action tool as \"{tool} on {account_id}\", in ascending account-id order, joined by \", \"" - } - }, - "python": "{action_id} matches {count} connectors on different accounts ({tools}). Pass the account id to use, such as a search hit's account_id.", - "node": "{action_id} matches {count} connectors on different accounts ({tools}). Pass the account id to use, such as a search hit's account_id.", - "matches": { - "python": true, - "node": true - }, - "notes": "Raised by execute() when the action's connector is linked on more than one account in scope — no account id passed, or several; nothing is called. Was a warning that ran the first match." - }, { "id": "mcp-timeout", "kind": "error", @@ -254,7 +203,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "timeout": { "format": "seconds", @@ -280,7 +229,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "timeout": { "format": "seconds", @@ -332,7 +281,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "status": { "format": "integer", @@ -366,7 +315,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "error_type": { "format": "text", @@ -555,27 +504,6 @@ }, "notes": "Only when every account in scope failed to list, and only when the failures do not all share one HTTP status: if they do, the SDK rethrows the StackOneAPIError itself (e.g. two accounts both answering 401 raise StackOneAPIError 401). The failures are the error's cause: Python raises it `from ExceptionGroup(...)` of them, Node passes `{ cause: new AggregateError(...) }`. An account that listed zero tools has not failed. Both SDKs join the failures with \"; \", and also raise when some accounts failed and the rest listed zero tools. Replaces no-account-returned-tools." }, - { - "id": "no-connector-returned-results", - "kind": "error", - "error": { - "python": "ToolsetLoadError", - "node": "ToolSetLoadError" - }, - "template": "No connector returned results. {failures}", - "placeholders": { - "failures": { - "format": "text", - "description": "each as \"{tool}: {error}\", joined by \" | \"" - } - }, - "python": "No connector returned results. {failures}", - "node": "No connector returned results. {failures}", - "matches": { - "python": true, - "node": true - } - }, { "id": "fetch-tools-failed", "kind": "error", @@ -849,25 +777,104 @@ } }, { - "id": "no-connector-for-action", + "id": "trio-tool-not-served", "kind": "error", "error": { "python": "ToolsetLoadError", "node": "ToolSetLoadError" }, - "template": "No connector found for {action_id}. Use search() to discover valid action ids.", + "template": "The server did not serve {tool} for this account scope.", "placeholders": { - "action_id": { + "tool": { + "format": "text", + "description": "the tool the call needs and the search_execute listing lacks: stackone_search_actions for search(), stackone_execute_action for execute()" + } + }, + "python": "The server did not serve {tool} for this account scope.", + "node": "The server did not serve {tool} for this account scope.", + "matches": { + "python": true, + "node": true + }, + "notes": "Raised by search() and execute() before any tools/call, when the account scope's search_execute listing does not carry the tool — the project's mcp_search_execute policy is disabled, say, so individual tools are served." + }, + { + "id": "too-many-accounts", + "kind": "error", + "error": { + "python": "ToolsetConfigError", + "node": "ToolSetConfigError" + }, + "template": "{count} accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.", + "placeholders": { + "count": { + "format": "integer", + "description": "how many distinct accounts are in scope" + } + }, + "python": "{count} accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.", + "node": "{count} accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.", + "matches": { + "python": true, + "node": true + }, + "notes": "Raised before any request when a search_execute listing would name more than 50 accounts, which UCA refuses with a 400." + }, + { + "id": "account-outside-scope", + "kind": "error", + "error": { + "python": "ToolArgumentsError", + "node": "ToolArgumentsError" + }, + "template": "stackone_account_id {value} is not an account {tool} can run on; it runs on {account_id}.", + "placeholders": { + "value": { + "format": "json", + "description": "the stackone_account_id given" + }, + "tool": { + "format": "json", + "description": "the tool's name as exposed: stackone_search_actions or stackone_execute_action" + }, + "account_id": { "format": "json", - "description": "the action id" + "description": "the one account the served pair is bound to" } }, - "python": "No connector found for {action_id}. Use search() to discover valid action ids.", - "node": "No connector found for {action_id}. Use search() to discover valid action ids.", + "python": "stackone_account_id {value} is not an account {tool} can run on; it runs on {account_id}.", + "node": "stackone_account_id {value} is not an account {tool} can run on; it runs on {account_id}.", "matches": { "python": true, "node": true - } + }, + "notes": "Raised before any tools/call by a one-account scope's search or execute tool, which runs the account's own served pair: a stackone_account_id naming that account is dropped, any other raises this." + }, + { + "id": "provider-outside-scope", + "kind": "error", + "error": { + "python": "ToolArgumentsError", + "node": "ToolArgumentsError" + }, + "template": "provider {value} is not linked in this account scope, which has {provider}.", + "placeholders": { + "value": { + "format": "json", + "description": "the provider given" + }, + "provider": { + "format": "json", + "description": "the account's connector: the served search tool's name minus __search_actions" + } + }, + "python": "provider {value} is not linked in this account scope, which has {provider}.", + "node": "provider {value} is not linked in this account scope, which has {provider}.", + "matches": { + "python": true, + "node": true + }, + "notes": "Raised before any tools/call by a one-account scope's search tool: a provider equal to the account's connector, compared case-insensitively, is dropped, any other raises this." }, { "id": "arguments-not-finite", @@ -1050,32 +1057,6 @@ }, "notes": "Emitted once at construction when the toolset's headers option names Authorization, x-account-id or User-Agent (any case, surrounding whitespace ignored). x-end-user-id is allowed in this option and is not warned about. The option's other headers go on every request, GET /accounts included, beneath the SDK's own." }, - { - "id": "connector-account-unavailable", - "kind": "error", - "error": { - "python": "ToolsetLoadError", - "node": "ToolSetLoadError" - }, - "template": "{action_id} may be served by an account that failed to list ({failures}). Pass the account id to use, such as a search hit's account_id.", - "placeholders": { - "action_id": { - "format": "json", - "description": "the action id execute() was called with, JSON-quoted as in ambiguous-connector" - }, - "failures": { - "format": "text", - "description": "each failed account as \"{account_id}: {error}\", where {error} is the failure's own message (as in skip-account), in ascending account-id order, joined by \"; \" — same format as all-accounts-failed" - } - }, - "python": "{action_id} may be served by an account that failed to list ({failures}). Pass the account id to use, such as a search hit's account_id.", - "node": "{action_id} may be served by an account that failed to list ({failures}). Pass the account id to use, such as a search hit's account_id.", - "matches": { - "python": true, - "node": true - }, - "notes": "Raised by execute() when an account in scope is marked failed and, after an immediate re-list, still fails, and that account's provider (from GET /accounts) matches the action's connector or is unknown (no GET /accounts record). A failed account on a different known provider does not block routing to the remaining account." - }, { "id": "non-shared-accounts-skipped", "kind": "warning",