Coverage for stackone_ai / tools.py: 96%
739 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-09 08:03 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-09 08:03 +0000
1"""Tools served by the StackOne MCP endpoint, and their execution over MCP ``tools/call``.
3The guiding property of this module is that a tool is the served catalog entry:
4the schema listed to a model is the schema the MCP server sent, and a call sends the
5model's arguments back to the endpoint that listed it, as given. Nothing is invented,
6nothing is dropped, nothing is rebuilt.
7"""
9from __future__ import annotations
11import asyncio
12import base64
13import calendar
14import json
15import logging
16import math
17import random
18import re
19import threading
20import time
21from collections import Counter
22from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Sequence
23from contextlib import asynccontextmanager
24from dataclasses import dataclass
25from datetime import UTC, datetime, timedelta
26from decimal import Decimal
27from importlib import metadata
28from typing import Any, NoReturn, TypeVar
30import anyio
31import httpx
32from pydantic import BaseModel, Field, PrivateAttr
34from stackone_ai.types import (
35 ExecuteConfig,
36 Headers,
37 JsonDict,
38 StackOneAPIError,
39 StackOneError,
40 ToolArgumentsError,
41 ToolParameters,
42 ToolsetConfigError,
43 ToolsetError,
44 ToolsetLoadError,
45)
47logger = logging.getLogger("stackone.tools")
49T = TypeVar("T")
51try:
52 _SDK_VERSION = metadata.version("stackone-ai")
53except metadata.PackageNotFoundError: # pragma: no cover - best-effort fallback when running from source
54 _SDK_VERSION = "dev"
56USER_AGENT = f"stackone-ai-python/{_SDK_VERSION}"
58_HEADER_NAME_PATTERN = re.compile(r"[A-Za-z0-9!#$%&'*+.^_`|~-]+")
59_HEADER_VALUE_PATTERN = re.compile(r"[\x20-\x7e\t\x80-\xff]*")
61# What JavaScript's String.prototype.trim() strips: its WhiteSpace and LineTerminator
62# characters. str.strip() also strips \x1c-\x1f and \x85, which made "x-custom\x1f" a
63# valid name here when Node refuses it as malformed, and misses the byte order mark.
64_JS_WHITESPACE = (
65 "\t\n\v\f\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a"
66 "\u2028\u2029\u202f\u205f\u3000\ufeff"
67)
70def _trim_header_name(name: str) -> str:
71 """A header name trimmed as Node trims it."""
72 return name.strip(_JS_WHITESPACE)
75def is_sdk_owned_header(name: str) -> bool:
76 """Whether a caller-supplied header name is one the SDK owns and will override."""
77 return _ascii_lower(_trim_header_name(name)) in _SDK_OWNED_HEADERS
80def build_request_headers(
81 *,
82 api_key: str,
83 account_id: str | None = None,
84 end_user_id: str | None = None,
85 extra_headers: Headers | None = None,
86) -> Headers:
87 """The HTTP headers for a request to StackOne: the caller's extra headers first, then
88 the SDK's own, so Authorization, x-account-id and User-Agent are always the SDK's —
89 and so is x-end-user-id when an ``end_user_id`` is given.
91 Case variants of the owned names are dropped first — a server would otherwise see
92 both. With no ``account_id``, no x-account-id is sent at all; with no
93 ``end_user_id``, a caller's x-end-user-id is passed through as given.
94 """
95 replaced = _SDK_SET_HEADER_ARGUMENTS if end_user_id else _SDK_OWNED_HEADERS
96 headers: Headers = {
97 name: value
98 for name, value in (extra_headers or {}).items()
99 if _ascii_lower(_trim_header_name(name)) not in replaced
100 }
101 headers["User-Agent"] = USER_AGENT
102 headers["Authorization"] = build_auth_header(api_key)
103 if account_id:
104 headers["x-account-id"] = account_id
105 if end_user_id:
106 headers[END_USER_ID_HEADER] = end_user_id
107 return headers
110# Header names the SDK sets itself, after every other header. A tool call may not supply
111# them even when a served schema declares them: they are the credential, the tenant
112# selector and the client identity.
113_SDK_OWNED_HEADERS = frozenset({"authorization", "x-account-id", "user-agent"})
115# The end user a non-shared account belongs to, which the API requires on every MCP request
116# for that account. The SDK sets it when GET /accounts recorded one for the account; a
117# caller's configured headers may carry it otherwise. A model may never supply it.
118END_USER_ID_HEADER = "x-end-user-id"
120# Header names a tool call's header arguments may not carry, even when declared.
121_SDK_SET_HEADER_ARGUMENTS = _SDK_OWNED_HEADERS | {END_USER_ID_HEADER}
123# A top-level argument with this prefix is a header argument, as is an entry of a nested
124# `headers` object argument.
125_FLAT_HEADER_PREFIX = "headers_"
127# Why a header argument was dropped: each reason code, and the text its warning gives.
128_HEADER_REASONS = {
129 "set-by-sdk": "set by the SDK",
130 "not-declared": "not declared by the schema",
131 "malformed": "malformed",
132 "not-an-object": "not an object",
133 "not-a-scalar": "not a string, number or boolean",
134}
137def _warn_header_dropped(argument: str, header: str | None, reason: str) -> None:
138 """Warn that a header argument, or one entry of a ``headers`` object, was dropped."""
139 if header is None:
140 logger.warning(
141 "Dropping header argument %s from a tool call: %s", _json_text(argument), _HEADER_REASONS[reason]
142 )
143 else:
144 logger.warning("Dropping header %s from a tool call: %s", _json_text(header), _HEADER_REASONS[reason])
147# How a 429 is retried, on every request the SDK makes: up to three more attempts, each
148# after the server's Retry-After (capped) or else an exponential backoff with jitter. A
149# wait that would not end before the request's deadline is not started: the 429 is
150# returned at once instead.
151RATE_LIMIT_MAX_RETRIES = 3
152RATE_LIMIT_BACKOFF_SECONDS = (1.0, 2.0, 4.0)
153RATE_LIMIT_JITTER = (0.5, 1.0)
154RATE_LIMIT_MAX_DELAY_SECONDS = 30.0
156# Looked up at call time, so tests can replace them and not actually wait.
157_sleep = time.sleep
158_async_sleep = anyio.sleep
159_clock = time.monotonic
160_async_clock = anyio.current_time
163@dataclass
164class McpToolDefinition:
165 """A tool exactly as the MCP server listed it."""
167 name: str
168 description: str | None
169 input_schema: dict[str, Any]
172def run_async(awaitable: Coroutine[Any, Any, T]) -> T:
173 """Run a coroutine, even when called from an existing event loop."""
175 try:
176 asyncio.get_running_loop()
177 except RuntimeError:
178 in_a_loop = False
179 else:
180 in_a_loop = True
181 # Run outside the except block, or every failure would carry the probe's
182 # "no running event loop" as its __context__.
183 if not in_a_loop:
184 return asyncio.run(awaitable)
186 result: dict[str, T] = {}
187 error: dict[str, BaseException] = {}
189 def runner() -> None:
190 try:
191 result["value"] = asyncio.run(awaitable)
192 except BaseException as exc: # pragma: no cover - surfaced in caller context
193 error["error"] = exc
195 thread = threading.Thread(target=runner, daemon=True)
196 thread.start()
197 thread.join()
199 if "error" in error:
200 raise error["error"]
202 return result["value"]
205def _js_number(value: float) -> str:
206 """A float as JavaScript's ``String(number)`` writes it: ``1.0`` is "1", ``1e-7`` is "1e-7".
208 Both languages print the shortest digits that round-trip, so only the layout differs:
209 the cut-offs for exponent notation, and the exponent's sign and padding.
210 """
211 if math.isnan(value):
212 return "NaN"
213 if math.isinf(value):
214 return "Infinity" if value > 0 else "-Infinity"
215 if value == 0:
216 return "0"
217 sign = "-" if value < 0 else ""
218 parsed = Decimal(repr(abs(value))).normalize().as_tuple()
219 digits = "".join(map(str, parsed.digits))
220 k = len(digits)
221 n = int(parsed.exponent) + k
222 if k <= n <= 21:
223 return sign + digits + "0" * (n - k)
224 if 0 < n <= 21:
225 return sign + digits[:n] + "." + digits[n:]
226 if -6 < n <= 0:
227 return sign + "0." + "0" * -n + digits
228 mantissa = digits if k == 1 else f"{digits[0]}.{digits[1:]}"
229 return f"{sign}{mantissa}e{'+' if n > 0 else '-'}{abs(n - 1)}"
232def _js_json(value: Any) -> str:
233 """``value`` as JavaScript's ``JSON.stringify`` writes it: compact, numbers as JS prints them.
235 Raises:
236 TypeError: If ``value`` holds something JSON cannot represent, such as a set.
237 """
238 if value is None:
239 return "null"
240 if isinstance(value, bool):
241 return "true" if value else "false"
242 if isinstance(value, int):
243 return str(value)
244 if isinstance(value, float):
245 return _js_number(value) if math.isfinite(value) else "null"
246 if isinstance(value, str):
247 return json.dumps(value, ensure_ascii=False)
248 if isinstance(value, dict):
249 entries = (
250 f"{json.dumps(key if isinstance(key, str) else _header_text(key) or 'null', ensure_ascii=False)}:"
251 f"{_js_json(item)}"
252 for key, item in value.items()
253 )
254 return "{" + ",".join(entries) + "}"
255 if isinstance(value, list | tuple):
256 return "[" + ",".join(_js_json(item) for item in value) + "]"
257 raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
260def _json_text(value: Any) -> str:
261 """A value as compact JSON, the way every message quotes a name or a value.
263 ``repr`` wrote Python's spelling (``'x'``, ``None``, ``True``); JSON reads the same from
264 either SDK. A value JSON cannot hold, such as a set, falls back to its ``repr``.
265 """
266 try:
267 return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
268 except (TypeError, ValueError):
269 return repr(value)
272def _json_type(value: Any) -> str:
273 """The JSON type of a value: object, array, string, number, boolean or null.
275 A value with no JSON type, such as a set, is named by its Python type.
276 """
277 if value is None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 return "null"
279 if isinstance(value, bool): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true
280 return "boolean"
281 if isinstance(value, int | float): 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 return "number"
283 if isinstance(value, str):
284 return "string"
285 if isinstance(value, list | tuple):
286 return "array"
287 if isinstance(value, dict): 287 ↛ 289line 287 didn't jump to line 289 because the condition on line 287 was always true
288 return "object"
289 return type(value).__name__
292def _seconds_2dp(seconds: float) -> str:
293 """Seconds rounded half up to two decimals, then written as JavaScript writes a number."""
294 return _js_number(math.floor(seconds * 100 + 0.5) / 100)
297_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
300def _ascii_lower(name: str) -> str:
301 """A header name lower-cased in ASCII only, as HTTP compares names.
303 ``casefold()`` folded "ſ" (U+017F) to "s", so "uſer-agent" read as User-Agent. It is
304 not a token at all, and is refused as malformed instead.
305 """
306 return name.translate(_ASCII_LOWER)
309def _declares_object_type(schema_type: Any) -> bool:
310 """Whether a JSON Schema ``type`` declares (among others) ``"object"``.
312 ``type`` is either a single string or, per the JSON Schema spec, a list of strings
313 naming every allowed type, e.g. ``["object", "null"]``.
314 """
315 if isinstance(schema_type, str):
316 return schema_type == "object"
317 if isinstance(schema_type, list):
318 return "object" in schema_type
319 return False
322def _header_text(value: Any) -> str | None:
323 """A header argument's value as text, as Node's ``headerText`` writes it; ``None`` if null.
325 Strings as they are, booleans as ``true``/``false``, numbers as JavaScript prints them
326 and anything else as compact JSON. ``str()`` sent Python's spelling instead: ``True``,
327 ``1.0``, ``['a', 'b']``.
328 """
329 if value is None:
330 return None
331 if isinstance(value, str):
332 return value
333 if isinstance(value, bool | int):
334 return _js_json(value)
335 if isinstance(value, float):
336 return _js_number(value)
337 return _js_json(value)
340def build_auth_header(api_key: str) -> str:
341 token = base64.b64encode(f"{api_key}:".encode()).decode()
342 return f"Basic {token}"
345async def _buffer_error_body(response: httpx.Response) -> None:
346 """Read an error response's body while its stream is still open.
348 The ``mcp`` client raises on the status after the streamed response is closed, so
349 reading the body afterwards got nothing and every failure said only "400 Bad
350 Request". The body is where the server explains itself: "Legacy accounts cannot be
351 used with MCP", or a 412 asking for the account to be re-linked. Node already
352 carries it.
353 """
354 if response.is_error:
355 await response.aread()
358# The three HTTP-date forms of RFC 9110 §5.6.7, exactly: case-sensitive, single spaces, and
359# GMT only. re.ASCII, or \d would also match "٣".
360_DAY = r"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)"
361_MONTH = r"(?P<month>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
362_TIME = r"(?P<hour>\d\d):(?P<minute>\d\d):(?P<second>\d\d)"
363_IMF_FIXDATE = re.compile(rf"{_DAY}, (?P<day>\d\d) {_MONTH} (?P<year>\d{{4}}) {_TIME} GMT", re.ASCII)
364_RFC850_DATE = re.compile(
365 rf"(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), "
366 rf"(?P<day>\d\d)-{_MONTH}-(?P<year>\d\d) {_TIME} GMT",
367 re.ASCII,
368)
369_ASCTIME_DATE = re.compile(rf"{_DAY} {_MONTH} (?P<day>\d\d| \d) {_TIME} (?P<year>\d{{4}})", re.ASCII)
370_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
373def _add_years(when: datetime, years: int) -> datetime:
374 """``when`` moved by whole years; 29 February lands on the 28th in a common year."""
375 try:
376 return when.replace(year=when.year + years)
377 except ValueError:
378 return when.replace(year=when.year + years, day=28)
381def _parse_http_date(value: str, now: datetime) -> float | None:
382 """An RFC 9110 HTTP-date as seconds since the epoch, or ``None`` if it is not one.
384 Written out rather than left to ``email.utils.parsedate_to_datetime``, which reads
385 far more than an HTTP-date: trailing text, numeric zones, two-digit years in any form.
386 The weekday is not checked against the date, as RFC 9110 does not ask it to be.
388 Seconds since the epoch, not a ``datetime``, because a leap second (second 60) on
389 the last representable instant, e.g. ``Fri, 31 Dec 9999 23:59:60 GMT``, rolls over
390 into year 10000, which ``datetime`` cannot hold. ``calendar.timegm`` takes the date
391 fields as plain integers and does the arithmetic without that ceiling.
392 """
393 match = _IMF_FIXDATE.fullmatch(value) or _ASCTIME_DATE.fullmatch(value)
394 two_digit_year = False
395 if match is None:
396 match = _RFC850_DATE.fullmatch(value)
397 two_digit_year = True
398 if match is None:
399 return None
400 year = int(match["year"])
401 month = _MONTHS.index(match["month"]) + 1
402 day = int(match["day"])
403 hour = int(match["hour"])
404 minute = int(match["minute"])
405 second = int(match["second"])
406 if second > 60:
407 return None
408 if two_digit_year:
409 # §5.6.7: a two-digit year more than 50 years ahead is the most recent past year
410 # with those digits.
411 year += now.year - now.year % 100
412 try:
413 # Second 60 is a leap second: validated as if it were 59, the instant before the
414 # next minute starts. Also catches day 32, hour 25, 30 February, year 0.
415 datetime(year, month, day, hour, minute, min(second, 59), tzinfo=UTC)
416 except ValueError:
417 return None
418 if two_digit_year:
419 # Bounded to a century around `now`, so no overflow risk going through datetime.
420 when = datetime(year, month, day, hour, minute, min(second, 59), tzinfo=UTC)
421 if second == 60: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true
422 when += timedelta(seconds=1)
423 if when > _add_years(now, 50):
424 when = _add_years(when, -100)
425 elif _add_years(when, 100) <= _add_years(now, 50): 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 when = _add_years(when, 100)
427 return calendar.timegm(when.utctimetuple())
428 return calendar.timegm((year, month, day, hour, minute, second))
431def _retry_after_seconds(value: str | None, now: datetime | None = None) -> float | None:
432 """A Retry-After header as seconds from ``now``: delta-seconds or an HTTP-date.
434 ``None`` when the header is absent or unreadable, so the caller falls back to its
435 own backoff. A date in the past means retry now. ``now`` defaults to the current time.
436 """
437 if value is None:
438 return None
439 value = value.strip(_JS_WHITESPACE)
440 # ASCII digits only: str.isdigit() also accepts "²", which float() then refuses.
441 if re.fullmatch(r"[0-9]+", value):
442 return float(value)
443 now = now or datetime.now(UTC)
444 when_epoch = _parse_http_date(value, now)
445 if when_epoch is None:
446 return None
447 now_epoch = calendar.timegm(now.utctimetuple()) + now.microsecond / 1e6
448 return max(0.0, when_epoch - now_epoch)
451def _backoff_delay(retry: int, retry_after: float | None, random_value: float | None = None) -> float:
452 """How long to wait before retry number ``retry`` (1-based) of a 429.
454 ``retry_after`` is the 429's Retry-After in seconds, or ``None`` if it had none readable;
455 then the backoff for ``retry`` is jittered by ``random_value``, uniform in [0, 1).
456 """
457 if retry_after is None:
458 r = random.random() if random_value is None else random_value
459 low, high = RATE_LIMIT_JITTER
460 retry_after = RATE_LIMIT_BACKOFF_SECONDS[retry - 1] * (low + (high - low) * r)
461 return min(retry_after, RATE_LIMIT_MAX_DELAY_SECONDS)
464def _rate_limit_delay(response: httpx.Response, retry: int) -> float:
465 """How long to wait before retry number ``retry`` (1-based) of this 429."""
466 return _backoff_delay(retry, _retry_after_seconds(response.headers.get("retry-after")))
469def _waits_for_retry(delay: float, remaining: float) -> bool:
470 """Whether a wait of ``delay`` ends before a deadline ``remaining`` seconds away."""
471 return delay < remaining
474def _log_rate_limit_retry(request: httpx.Request, attempt: int, delay: float) -> None:
475 logger.warning(
476 "%s %s was rate limited (429) on attempt %d of %d; retrying in %ss",
477 request.method,
478 request.url,
479 attempt,
480 RATE_LIMIT_MAX_RETRIES + 1,
481 _seconds_2dp(delay),
482 )
485def _outlasts_deadline(request: httpx.Request, attempt: int, delay: float, remaining: float) -> bool:
486 """Whether waiting ``delay`` would not end before the deadline, logging when so.
488 Such a wait is not started. Sleeping into the deadline would turn the 429 into a
489 timeout, which a multi-account call skips like any per-account failure, and hand back
490 a partial catalog that looks complete. The 429 itself ends the call.
491 """
492 if _waits_for_retry(delay, remaining):
493 return False
494 logger.warning(
495 "%s %s was rate limited (429) on attempt %d of %d; not retrying, because waiting %ss "
496 "would pass the deadline",
497 request.method,
498 request.url,
499 attempt,
500 RATE_LIMIT_MAX_RETRIES + 1,
501 _seconds_2dp(delay),
502 )
503 return True
506class RateLimitRetryingClient(httpx.Client):
507 """An ``httpx.Client`` that retries a 429 before the caller ever sees it.
509 Retried in ``send`` rather than in a wrapping transport: passing a custom transport
510 makes httpx ignore the proxy environment variables, which would quietly break the SDK
511 behind a corporate proxy. The last 429 is returned as it came, so the caller reports
512 the server's body.
514 ``retry_within`` is the deadline in seconds, measured from the first attempt: a retry
515 whose wait would not end before it is not made, and the 429 is returned instead. A
516 retry that times out raises :class:`RateLimitTimeout`: the account is rate limited, and
517 an ordinary timeout would let a caller skip it like any other failure.
518 """
520 def __init__(self, *args: Any, retry_within: float | None = None, **kwargs: Any) -> None:
521 super().__init__(*args, **kwargs)
522 self._retry_within = retry_within
524 def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
525 deadline = _clock() + self._retry_within if self._retry_within is not None else None
526 attempt = 1
527 response = super().send(request, **kwargs)
528 while response.status_code == 429 and attempt <= RATE_LIMIT_MAX_RETRIES:
529 delay = _rate_limit_delay(response, attempt)
530 if deadline is not None and _outlasts_deadline(request, attempt, delay, deadline - _clock()):
531 break
532 # Read before closing, so the connection goes back to the pool.
533 response.read()
534 response.close()
535 _log_rate_limit_retry(request, attempt, delay)
536 _sleep(delay)
537 attempt += 1
538 try:
539 response = super().send(request, **kwargs)
540 except httpx.TimeoutException as exc:
541 raise RateLimitTimeout(str(exc), request=request) from exc
542 return response
545class RateLimitTimeout(httpx.TimeoutException):
546 """A request retried after a 429 timed out: the 429's doing, not an ordinary timeout."""
549@dataclass
550class _Throttle:
551 """The last 429 an MCP exchange retried, while any request is still retrying one.
553 Counted per request because an exchange's requests overlap: the MCP client's event-stream
554 GET, say, answered while a ``tools/list`` retry hangs, must not clear that retry's 429.
555 """
557 response: httpx.Response | None = None
558 retrying: int = 0
561class RateLimitRetryingAsyncClient(httpx.AsyncClient):
562 """The async counterpart of :class:`RateLimitRetryingClient`, used for every MCP request.
564 The MCP client streams its responses; a discarded 429 is read and closed here, and
565 the final one still passes through the response hooks, so its body is buffered.
567 The deadline is the enclosing cancel scope's: the ``anyio.fail_after(timeout)`` that
568 bounds the whole MCP exchange. A retry whose wait would not end before it is not made.
570 ``throttle`` records each 429 that is retried. The exchange's timeout cancels the retry
571 rather than raising in it, so the caller reads this afterwards to report a timeout
572 after a retried 429 as that 429 (see :func:`_raise_mcp_failure`).
573 """
575 def __init__(self, *args: Any, throttle: _Throttle | None = None, **kwargs: Any) -> None:
576 super().__init__(*args, **kwargs)
577 self._throttle = throttle
579 async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
580 attempt = 1
581 retried = False
582 response = await super().send(request, **kwargs)
583 while response.status_code == 429 and attempt <= RATE_LIMIT_MAX_RETRIES:
584 delay = _rate_limit_delay(response, attempt)
585 remaining = anyio.current_effective_deadline() - _async_clock()
586 if _outlasts_deadline(request, attempt, delay, remaining):
587 break
588 await response.aread()
589 await response.aclose()
590 if self._throttle is not None:
591 self._throttle.response = response
592 if not retried:
593 retried = True
594 self._throttle.retrying += 1
595 _log_rate_limit_retry(request, attempt, delay)
596 await _async_sleep(delay)
597 attempt += 1
598 response = await super().send(request, **kwargs)
599 if self._throttle is not None and retried and response.status_code != 429:
600 self._throttle.retrying -= 1
601 if self._throttle.retrying == 0: 601 ↛ 603line 601 didn't jump to line 603 because the condition on line 601 was always true
602 self._throttle.response = None
603 return response
606def is_rate_limited(exc: BaseException) -> bool:
607 """Whether a failure is a 429 that outlasted every retry.
609 Such a failure ends the whole call: skipping the account and carrying on would hand
610 back a partial catalog that looks complete. Only an HTTP 429 counts, as in Node: a
611 tool result whose payload says 429 was never retried, so it is that connector's
612 failure, skipped like any other.
613 """
614 if not (isinstance(exc, StackOneAPIError) and exc.status_code == 429):
615 return False
616 seen: set[int] = set()
617 stack: list[BaseException] = [exc]
618 while stack:
619 current = stack.pop()
620 if id(current) in seen: 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true
621 continue
622 seen.add(id(current))
623 if isinstance(current, httpx.HTTPStatusError) and current.response.status_code == 429:
624 return True
625 stack.extend(getattr(current, "exceptions", None) or [])
626 stack.extend(e for e in (current.__cause__, current.__context__) if e is not None)
627 return False
630@asynccontextmanager
631async def _mcp_transport(
632 endpoint: str, headers: dict[str, str], timeout: float, throttle: _Throttle | None = None
633) -> AsyncIterator[tuple[Any, Any, Any]]:
634 """Open the streamable-HTTP transport with the caller's timeout on every leg.
636 The client's own defaults are a 30s connect and a 300s SSE read, and neither was
637 overridden — so ``StackOneToolSet(timeout=2)`` against a host that accepts and never
638 answers hung for five minutes. The execution path honoured ``timeout``; the MCP
639 path, which search() and execute() and every listing use, did not.
641 The HTTP client is built here with the settings the ``mcp`` package's own factory
642 uses (redirects followed), because ``streamable_http_client`` takes a client rather
643 than headers and timeouts. It retries a 429, so the MCP client never sees one that
644 a retry got past.
645 """
646 from mcp.client.streamable_http import streamable_http_client # ty: ignore[unresolved-import]
648 async with RateLimitRetryingAsyncClient(
649 throttle=throttle,
650 headers=headers,
651 timeout=httpx.Timeout(timeout),
652 follow_redirects=True,
653 event_hooks={"response": [_buffer_error_body]},
654 ) as client:
655 async with streamable_http_client(endpoint, http_client=client) as streams:
656 yield streams
659def fetch_mcp_tools(
660 endpoint: str, headers: dict[str, str], *, timeout: float = 60.0
661) -> list[McpToolDefinition]:
662 """List every tool the MCP endpoint serves, following pagination.
664 ``timeout`` bounds the whole exchange, handshake included.
665 """
666 try:
667 from mcp import types as mcp_types # ty: ignore[unresolved-import]
668 from mcp.client.session import ClientSession # ty: ignore[unresolved-import]
669 except ImportError as exc: # pragma: no cover - depends on optional extra
670 raise ToolsetConfigError(
671 "mcp is a core dependency of stackone-ai but could not be imported — reinstall the package."
672 ) from exc
674 throttle = _Throttle()
676 async def _list() -> list[McpToolDefinition]:
677 with anyio.fail_after(timeout):
678 return await _list_within_deadline()
680 async def _list_within_deadline() -> list[McpToolDefinition]:
681 async with _mcp_transport(endpoint, headers, timeout, throttle) as (
682 read_stream,
683 write_stream,
684 _,
685 ):
686 session = ClientSession(
687 read_stream,
688 write_stream,
689 client_info=mcp_types.Implementation(name="stackone-ai-python", version=_SDK_VERSION),
690 )
691 async with session:
692 await session.initialize()
693 cursor: str | None = None
694 collected: list[McpToolDefinition] = []
695 while True:
696 result = await session.list_tools(cursor)
697 for tool in result.tools:
698 input_schema = tool.inputSchema or {}
699 collected.append(
700 McpToolDefinition(
701 name=tool.name,
702 description=tool.description,
703 input_schema=dict(input_schema),
704 )
705 )
706 cursor = result.nextCursor
707 if cursor is None:
708 break
709 return collected
711 try:
712 return run_async(_list())
713 except Exception as exc:
714 # Exception, not BaseException: Ctrl-C and SystemExit must stop the caller, not come
715 # back as a ToolsetLoadError that an agent framework hands the model as a tool result.
716 _raise_mcp_failure(exc, endpoint, timeout, throttle)
719def _response_body(response: httpx.Response) -> str:
720 """Best-effort read of an error body.
722 The MCP transport streams, so `.text` raises until the stream is read, and by
723 the time the error surfaces the stream may already be closed. The body is the
724 only place the server explains itself, so it is worth trying; an empty string
725 just means the message falls back to the status code.
726 """
727 try:
728 return response.text.strip()
729 except Exception:
730 pass
731 try:
732 response.read()
733 return response.text.strip()
734 except Exception:
735 return ""
738def _describe_mcp_failure(exc: BaseException, endpoint: str, timeout: float) -> Exception:
739 """Turn the MCP client's nested failure into something a caller can act on.
741 The listing runs inside a TaskGroup, so any HTTP error arrives wrapped in an
742 ExceptionGroup whose str() is "unhandled errors in a TaskGroup (1 sub-exception)".
743 Unwrap to the underlying error and carry the status code and response body,
744 which is where the server explains itself — a dead account, for example,
745 answers 412 with "re-link the account to resume". A timeout says so, as in Node.
746 """
747 seen: set[int] = set()
748 stack: list[BaseException] = [exc]
749 while stack:
750 current = stack.pop()
751 if current is None or id(current) in seen:
752 continue
753 seen.add(id(current))
754 # Our own errors are already descriptive. They reach here wrapped in an
755 # ExceptionGroup because they are raised inside the session's TaskGroup.
756 if isinstance(current, StackOneError | ToolsetError):
757 return current
758 if isinstance(current, httpx.HTTPStatusError):
759 body = _response_body(current.response)
760 detail = f": {body}" if body else ""
761 status = f"{current.response.status_code} {current.response.reason_phrase}".rstrip()
762 # StackOneAPIError carries the status and body as attributes, so a caller
763 # can branch on 412 rather than pattern-matching the message.
764 return StackOneAPIError(
765 f"MCP request to {endpoint} failed with {status}{detail}",
766 current.response.status_code,
767 body or None,
768 )
769 stack.extend(getattr(current, "exceptions", None) or [])
770 stack.extend(e for e in (current.__cause__, current.__context__) if e is not None)
772 # The innermost cause, through group members and __cause__ only. An ExceptionGroup's
773 # own str() is the "unhandled errors in a TaskGroup" boilerplate that hides the real
774 # cause, and a __context__ is only what was being handled when the error was raised:
775 # for the deadline's TimeoutError that is the cancellation, or whatever the
776 # cancelled task happened to be catching.
777 leaf = _leaf_failure(exc)
778 if isinstance(leaf, TimeoutError | httpx.TimeoutException):
779 return ToolsetLoadError(f"MCP request to {endpoint} timed out after {_js_number(timeout)}s")
780 return ToolsetLoadError(f"MCP request to {endpoint} failed: {type(leaf).__name__}: {leaf}")
783def _leaf_failure(exc: BaseException) -> BaseException:
784 """The innermost cause through group members and ``__cause__``, stopping at a timeout."""
785 leaf = exc
786 while not isinstance(leaf, TimeoutError | httpx.TimeoutException):
787 members = getattr(leaf, "exceptions", None)
788 if members:
789 leaf = members[0]
790 elif leaf.__cause__ is not None: 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true
791 leaf = leaf.__cause__
792 else:
793 break
794 return leaf
797def _raise_mcp_failure(exc: Exception, endpoint: str, timeout: float, throttle: _Throttle) -> NoReturn:
798 """Raise an MCP exchange's failure as :func:`_describe_mcp_failure` describes it.
800 Except a timeout after a retried 429, which is raised as that 429. The retry's wait
801 ended before the deadline, but the retried request itself could still run past it, and
802 as a timeout a multi-account call skipped the account like any other failure and handed
803 back a partial catalog. Chained through an ``HTTPStatusError``, so
804 :func:`is_rate_limited` reads it as the HTTP 429 it is.
805 """
806 throttled = throttle.response
807 if throttled is not None and isinstance(_leaf_failure(exc), TimeoutError | httpx.TimeoutException):
808 status = httpx.HTTPStatusError(
809 f"429 {throttled.reason_phrase}", request=throttled.request, response=throttled
810 )
811 status.__cause__ = exc
812 raise StackOneAPIError(
813 f"MCP request to {endpoint} was rate limited (429) and timed out after "
814 f"{_js_number(timeout)}s while retrying",
815 429,
816 None,
817 ) from status
818 raise _describe_mcp_failure(exc, endpoint, timeout) from exc
821def _status_of(parsed: JsonDict) -> int:
822 """Dig the HTTP status out of an MCP error payload.
824 The transport succeeded, so there is no status on the response itself — but the
825 payload carries one, and a caller cannot branch on 0. Both spellings and all the
826 wrapper keys the API actually uses are checked: `statusCode` is what this repo's
827 own mock emits, and `error`/`data` are real wrappers, so looking only for
828 `status_code` under `result` reported 0 for the most likely shapes.
829 """
830 for candidate in (parsed, parsed.get("result"), parsed.get("error"), parsed.get("data")):
831 if not isinstance(candidate, dict):
832 continue
833 for key in ("status_code", "statusCode"):
834 status = candidate.get(key)
835 # bool is an int subclass; True is not a status code.
836 if isinstance(status, int) and not isinstance(status, bool):
837 return status
838 return 0
841def _reject_json_constant(constant: str) -> Any:
842 raise ValueError(f"{constant} is not valid JSON")
845def _first_non_finite(value: Any) -> float | None:
846 """The first NaN or infinity in ``value``, depth first in key order, or ``None``.
848 A container already on the path is skipped, so a cycle ends here and is reported by
849 ``json.dumps`` as a circular reference.
850 """
851 path: set[int] = set()
853 def walk(item: Any) -> float | None:
854 if isinstance(item, float):
855 return None if math.isfinite(item) else item
856 if not isinstance(item, dict | list | tuple) or id(item) in path:
857 return None
858 path.add(id(item))
859 try:
860 for child in item.values() if isinstance(item, dict) else item:
861 found = walk(child)
862 if found is not None:
863 return found
864 return None
865 finally:
866 path.discard(id(item))
868 return walk(value)
871def _plain_part(part: Any) -> Any:
872 """A content part as the JSON it arrived as: wire field names, unset fields left out."""
873 if isinstance(part, BaseModel): 873 ↛ 875line 873 didn't jump to line 875 because the condition on line 873 was always true
874 return part.model_dump(mode="json", by_alias=True, exclude_none=True)
875 return part
878def parse_tool_result(result: Any, name: str) -> JsonDict:
879 """Turn an MCP ``CallToolResult`` into a plain dict.
881 Text parts are joined and parsed as JSON; a non-object is wrapped as
882 ``{"result": ...}``. With no text at all, ``structuredContent`` is used, and text
883 wins when both are present. Parts that are not text (images, embedded resources)
884 are kept under ``content_parts`` as plain JSON, the form they arrived in.
886 The result is returned as the server wrote it. For an action tool, ``*_execute_action``
887 and feedback that is ``{"isError": false, "result": ..., "defenderMetadata"?: ...,
888 "policyMetadata"?: ...}``; a search result is bare JSON.
890 Raises:
891 StackOneAPIError: If the result carries ``isError``. A failed tool call comes
892 back as an ordinary response with that flag set, so without this check the
893 error body is handed to the caller as though it were a success.
894 """
895 texts = [getattr(part, "text", "") for part in result.content]
896 payload = "".join(t for t in texts if t)
897 # Parts that are not text (images, embedded resources) have no `.text`; keep them
898 # rather than silently returning an empty dict. As plain JSON, as Node returns them:
899 # the mcp package's pydantic objects broke json.dumps on the result, and
900 # execute_openai_tool_calls() sent the model their repr.
901 non_text = [_plain_part(part) for part in result.content if not getattr(part, "text", "")]
903 parsed: JsonDict = {}
904 structured = getattr(result, "structuredContent", None)
905 if payload:
906 try:
907 # NaN and Infinity are not JSON; json.loads accepts them, JSON.parse does not,
908 # and the Node SDK hands such a payload back as text.
909 loaded = json.loads(payload, parse_constant=_reject_json_constant)
910 except ValueError:
911 loaded = payload
912 parsed = loaded if isinstance(loaded, dict) else {"result": loaded}
913 elif isinstance(structured, dict):
914 # A result may carry only structuredContent. Without this it came back as `{}`,
915 # a success with the whole payload missing. A copy, so content_parts below does
916 # not write into the caller's result.
917 parsed = dict(structured)
919 if getattr(result, "isError", False):
920 # The transport succeeded, so there is no HTTP status here — but the payload
921 # carries the real one, and a caller cannot branch on 0.
922 detail = payload or json.dumps(parsed, ensure_ascii=False, separators=(",", ":"), default=str)
923 raise StackOneAPIError(f"Tool {_json_text(name)} failed: {detail}", _status_of(parsed), parsed)
925 if non_text:
926 parsed["content_parts"] = non_text
927 return parsed
930def call_mcp_tool(
931 endpoint: str, headers: dict[str, str], name: str, arguments: JsonDict, *, timeout: float = 60.0
932) -> JsonDict:
933 """Invoke a tool over MCP ``tools/call``, the one way every tool is executed.
935 ``arguments`` are sent exactly as given; the server maps them onto the action.
936 """
937 from mcp import types as mcp_types # ty: ignore[unresolved-import]
938 from mcp.client.session import ClientSession # ty: ignore[unresolved-import]
940 throttle = _Throttle()
942 async def _call() -> JsonDict:
943 with anyio.fail_after(timeout):
944 return await _call_within_deadline()
946 async def _call_within_deadline() -> JsonDict:
947 async with _mcp_transport(endpoint, headers, timeout, throttle) as (
948 read_stream,
949 write_stream,
950 _,
951 ):
952 session = ClientSession(
953 read_stream,
954 write_stream,
955 client_info=mcp_types.Implementation(name="stackone-ai-python", version=_SDK_VERSION),
956 )
957 async with session:
958 await session.initialize()
959 # send_request rather than call_tool: call_tool validates structuredContent
960 # against the tool's output schema, and on a fresh session that means a
961 # tools/list first — relisting the whole catalog on every call, which the
962 # Node SDK never does. The result is parsed below either way.
963 request = mcp_types.ClientRequest(
964 mcp_types.CallToolRequest(
965 params=mcp_types.CallToolRequestParams(name=name, arguments=arguments),
966 )
967 )
968 result = await session.send_request(request, mcp_types.CallToolResult)
969 return parse_tool_result(result, name)
971 try:
972 return run_async(_call())
973 except Exception as exc:
974 # Not BaseException, as in fetch_mcp_tools: KeyboardInterrupt and SystemExit propagate.
975 _raise_mcp_failure(exc, endpoint, timeout, throttle)
978def is_end_user_refusal(exc: BaseException, account_id: str) -> bool:
979 """Whether ``exc`` is the API's 400 for an account's request without its end user.
981 The API answers an MCP request for a non-shared account whose ``x-end-user-id`` does not
982 name the account's end user with a 400 whose message names the account, as in Node.
983 """
984 if not (isinstance(exc, StackOneAPIError) and exc.status_code == 400):
985 return False
986 body = exc.response_body
987 if isinstance(body, str): 987 ↛ 992line 987 didn't jump to line 992 because the condition on line 987 was always true
988 try:
989 body = json.loads(body)
990 except ValueError:
991 pass
992 message = body.get("message") if isinstance(body, dict) else body
993 # The guard's fixed wording, ending with this account's id, quoted or not: not an exact
994 # string, so a change to how the id is written does not silently turn the lookup off.
995 if not isinstance(message, str) or not message.startswith(
996 "x-end-user-id header does not match account end user id"
997 ):
998 return False
999 return message.rstrip().rsplit(" ", 1)[-1].strip("\"'") == account_id
1002def _strip_internal_keys(schema: Any) -> Any:
1003 """Recursively drop the SDK's internal markers from a property schema.
1005 Everything else is preserved verbatim: ``format``, ``pattern``, ``default``,
1006 ``minimum``/``maximum``, ``oneOf``/``anyOf``, nested ``required`` and any
1007 keyword the server sends that this SDK has never heard of.
1008 """
1009 if isinstance(schema, dict):
1010 return {
1011 key: _strip_internal_keys(value)
1012 for key, value in schema.items()
1013 if not (key == "nullable" and isinstance(value, bool))
1014 }
1015 if isinstance(schema, list):
1016 return [_strip_internal_keys(item) for item in schema]
1017 return schema
1020class StackOneTool(BaseModel):
1021 """A single tool: its served schema, and how to call it.
1023 The base class describes a tool and converts it for agent frameworks, but cannot run
1024 it: :meth:`execute` raises. Tools from :meth:`StackOneToolSet.fetch_tools` are
1025 :class:`StackOneMcpTool` instances, which execute over MCP ``tools/call``. A hand-built
1026 tool must override :meth:`execute`.
1027 """
1029 name: str = Field(description="Tool name")
1030 description: str = Field(description="Tool description")
1031 parameters: ToolParameters = Field(description="Tool parameters")
1032 _execute_config: ExecuteConfig = PrivateAttr()
1033 _api_key: str | None = PrivateAttr(default=None)
1034 _account_id: str | None = PrivateAttr(default=None)
1036 def __init__(
1037 self,
1038 description: str,
1039 parameters: ToolParameters,
1040 _execute_config: ExecuteConfig,
1041 # Optional: the base class cannot execute, so it has nothing to authenticate. Kept
1042 # as a parameter so existing hand-built tools and overrides that read it still work.
1043 _api_key: str | None = None,
1044 _account_id: str | None = None,
1045 ) -> None:
1046 super().__init__(
1047 name=_execute_config.name,
1048 description=description,
1049 parameters=parameters,
1050 )
1051 self._execute_config = _execute_config
1052 self._api_key = _api_key
1053 self._account_id = _account_id
1055 def _parse_arguments(self, arguments: str | JsonDict | None) -> JsonDict:
1056 """Arguments as a dict, from a dict, a JSON string, or nothing.
1058 Raises:
1059 ToolArgumentsError: If the string is not JSON, or the value is not a JSON object.
1060 """
1061 if arguments is None:
1062 return {}
1063 if isinstance(arguments, str):
1064 try:
1065 # NaN and Infinity are not JSON: json.loads accepts them, JSON.parse does not.
1066 parsed = json.loads(arguments, parse_constant=_reject_json_constant)
1067 except ValueError as exc:
1068 raise ToolArgumentsError(
1069 f"Invalid JSON in arguments for {_json_text(self.name)}: {exc}"
1070 ) from exc
1071 else:
1072 parsed = arguments
1073 if not isinstance(parsed, dict):
1074 raise ToolArgumentsError(f"Tool arguments for {_json_text(self.name)} must be a JSON object")
1075 return dict(parsed)
1077 def _declared_headers(self) -> tuple[set[str] | None, set[str], bool]:
1078 """The header names this tool's served schema declares.
1080 Returns ``(nested, flat, ordinary_headers_field)``: the names under a nested
1081 ``headers`` object, lower-cased in ASCII, each flat ``headers_<name>`` property exactly as
1082 served — a flat header is a top-level argument, so it is declared only under its
1083 own key, as in Node — and whether the schema declares a top-level ``headers``
1084 property as something other than an object, an ordinary field that happens to be
1085 named ``headers`` rather than a header container.
1086 ``nested`` is ``None`` when the ``headers`` object is open — ``type: "object"`` with
1087 no ``properties`` and ``additionalProperties`` not ``false``, as ``*_execute_action``
1088 serves it — which declares every name. Any other schema with no ``properties``
1089 declares none.
1090 """
1091 properties = self.parameters.properties or {}
1092 flat = {prop for prop in properties if prop.startswith(_FLAT_HEADER_PREFIX)}
1093 nested: set[str] | None = set()
1094 schema = properties.get("headers")
1095 schema_type = schema.get("type") if isinstance(schema, dict) else None
1096 declares_object = _declares_object_type(schema_type)
1097 # A declared type of `["string", "null"]` is a plain field that happens to be
1098 # named `headers`; `["object", "null"]` is still a header container, same as a
1099 # bare "object". Only a declared, non-object type makes it ordinary.
1100 ordinary_headers_field = (
1101 isinstance(schema, dict) and isinstance(schema_type, str | list) and not declares_object
1102 )
1103 if isinstance(schema, dict):
1104 if "properties" not in schema:
1105 if declares_object and schema.get("additionalProperties") is not False:
1106 nested = None
1107 elif isinstance(schema["properties"], dict):
1108 nested = {_ascii_lower(str(name)) for name in schema["properties"]}
1109 return nested, flat, ordinary_headers_field
1111 @staticmethod
1112 def _header_refusal(name: str, text: str, declared: bool) -> str | None:
1113 """The reason code a header argument may not be forwarded for, or ``None`` if it may."""
1114 # Normalise before comparing: " x-foo" and "X-FOO\t" are the same header to any
1115 # server. ASCII only: a name that differs from an owned one outside ASCII is not a
1116 # token, and is refused as malformed below.
1117 if _ascii_lower(_trim_header_name(name)) in _SDK_SET_HEADER_ARGUMENTS:
1118 return "set-by-sdk"
1119 if not declared:
1120 return "not-declared"
1121 # Defence in depth on a declared header's model-supplied value. fullmatch, not
1122 # match: `$` also matches just before a trailing newline, so `match` let "value\n"
1123 # — the one character class this rejects — straight through.
1124 if not (_HEADER_NAME_PATTERN.fullmatch(name) and _HEADER_VALUE_PATTERN.fullmatch(text)):
1125 return "malformed"
1126 return None
1128 def _sanitise_headers(self, supplied: dict[str, Any] | None) -> dict[str, str]:
1129 """Keep only the entries of a nested ``headers`` argument the served schema declares.
1131 An allowlist, not a denylist. Tool arguments are model-controlled, so a
1132 prompt-injected call reaches this dict directly — and a denylist has to
1133 enumerate every synonym of "credential" and "tenant selector" in every
1134 provider's vocabulary (Proxy-Authorization, x-stackone-account-id, Cookie,
1135 X-Api-Key, ...) and is wrong the moment one is missed.
1137 The allowlist is the served schema itself, so this needs no maintenance. An open
1138 ``headers`` object declares every name. Authorization, x-account-id, User-Agent and
1139 x-end-user-id are refused even when declared: the SDK sets them itself.
1140 """
1141 declared, _, _ = self._declared_headers()
1143 clean: dict[str, str] = {}
1144 for key, value in (supplied or {}).items():
1145 text = _header_text(value)
1146 if text is None or not isinstance(key, str):
1147 continue
1148 name = _trim_header_name(key)
1149 reason = self._header_refusal(name, text, declared is None or _ascii_lower(name) in declared)
1150 if reason:
1151 _warn_header_dropped("headers", name, reason)
1152 continue
1153 clean[name] = text
1154 return clean
1156 def _sanitise_header_arguments(self, arguments: JsonDict) -> JsonDict:
1157 """Filter the header arguments in a call; every other argument is kept unchanged.
1159 A header argument is an entry of a nested ``headers`` object, or a top-level
1160 ``headers_<name>``. Each is forwarded only if the served schema declares it in the
1161 same form, and never if it is one the SDK sets itself. A declared ``headers_<name>``
1162 keeps its value as given, as every other top-level argument does; nested entries
1163 are written as text, as Node writes them.
1165 A top-level ``headers`` argument that isn't a plain object is forwarded unchanged
1166 when the schema declares ``headers`` as something other than an object (it's an
1167 ordinary field that happens to be named ``headers``), and dropped otherwise. A flat
1168 ``headers_<name>`` whose value is a list or dict is dropped too — only a string,
1169 number or boolean can be a header value.
1171 Raises:
1172 TypeError: If a header value holds something JSON cannot represent.
1173 """
1174 _, declared_flat, ordinary_headers_field = self._declared_headers()
1175 clean: JsonDict = {}
1176 for key, value in arguments.items():
1177 if key == "headers":
1178 if ordinary_headers_field:
1179 clean[key] = value
1180 elif isinstance(value, dict):
1181 clean[key] = self._sanitise_headers(value)
1182 else:
1183 _warn_header_dropped(key, None, "not-an-object")
1184 continue
1185 if not key.startswith(_FLAT_HEADER_PREFIX):
1186 clean[key] = value
1187 continue
1188 if isinstance(value, dict | list):
1189 _warn_header_dropped(key, None, "not-a-scalar")
1190 continue
1191 text = _header_text(value)
1192 if text is None:
1193 continue
1194 reason = self._header_refusal(key[len(_FLAT_HEADER_PREFIX) :], text, key in declared_flat)
1195 if reason:
1196 _warn_header_dropped(key, None, reason)
1197 continue
1198 clean[key] = value
1199 return clean
1201 def execute(self, arguments: str | JsonDict | None = None) -> JsonDict:
1202 """Execute the tool. Not implemented on the base class.
1204 Tools from :meth:`StackOneToolSet.fetch_tools` execute over MCP ``tools/call``.
1205 A hand-built ``StackOneTool`` has nothing to call, so override this to run one.
1207 Raises:
1208 StackOneError: Always, on the base class.
1209 """
1210 raise StackOneError(
1211 f"Tool {_json_text(self.name)} has no executor. Override execute() to run a hand-built tool."
1212 )
1214 def call(self, *args: Any, **kwargs: Any) -> JsonDict:
1215 """Call the tool with the given arguments
1217 Examples:
1218 >>> tool.call({"name": "John", "email": "john@example.com"})
1219 >>> tool.call(name="John", email="john@example.com")
1220 """
1221 if args and kwargs:
1222 raise ToolArgumentsError("Cannot provide both positional and keyword arguments")
1224 if args:
1225 if len(args) > 1:
1226 raise ToolArgumentsError("Only one positional argument is allowed")
1227 return self.execute(args[0])
1229 return self.execute(kwargs if kwargs else None)
1231 def __call__(self, *args: Any, **kwargs: Any) -> JsonDict:
1232 """Make the tool directly callable. Alias for :meth:`call`."""
1233 return self.call(*args, **kwargs)
1235 def to_openai_function(self) -> JsonDict:
1236 """Convert this tool to OpenAI's function format.
1238 The served schema is passed through verbatim apart from the SDK's internal
1239 ``nullable`` marker, which is stripped. Constraints such as ``format``,
1240 ``pattern``, ``default``, ``minimum``/``maximum`` and ``oneOf``/``anyOf``
1241 reach the model intact — without them a model cannot generate valid arguments
1242 for a constrained field. ``required`` is the served list, in the served order,
1243 and is omitted when the server sent none or an empty one.
1244 """
1245 properties: JsonDict = {}
1246 for name, prop in self.parameters.properties.items():
1247 properties[name] = _strip_internal_keys(prop) if isinstance(prop, dict) else {"type": "string"}
1249 parameters: JsonDict = self.parameters.model_dump()
1250 parameters["properties"] = properties
1252 # Taken from the served root, not rebuilt from the per-property markers: that
1253 # rebuild re-sorted `required` into property order, so the model saw a list the
1254 # server never sent. A non-list `required`, or a non-string entry in one, is
1255 # malformed and dropped rather than iterated as characters or sent on.
1256 required = parameters.pop("required", None)
1257 names = [name for name in required if isinstance(name, str)] if isinstance(required, list) else []
1258 if names:
1259 parameters["required"] = names
1261 return {
1262 "type": "function",
1263 "function": {
1264 "name": self.name,
1265 "description": self.description,
1266 "parameters": parameters,
1267 },
1268 }
1270 def to_langchain(self) -> Any:
1271 """Convert this tool to LangChain format.
1273 Returns a ``langchain_core.tools.BaseTool``, typed as ``Any`` because
1274 ``langchain-core`` is an optional dependency and must not be imported at
1275 module level.
1277 Requires ``stackone-ai[langchain]``.
1278 """
1279 try:
1280 from langchain_core.tools import BaseTool, ToolException
1281 except ImportError as e:
1282 raise ImportError(
1283 "Install `langchain-core` (or `stackone-ai[langchain]`) to use the LangChain integration."
1284 ) from e
1286 # Hand the served JSON Schema over as-is. The previous version rebuilt a
1287 # pydantic model from the top-level `type` of each property, which threw away
1288 # every nested object's fields, every enum, format, bound, item type and union —
1289 # roughly half the schema, so the model was told "pass an object" with no field
1290 # names. langchain-core accepts a JSON Schema dict directly, which makes this
1291 # surface byte-equivalent to to_openai_function() for free.
1292 args_json_schema = self.to_openai_function()["function"]["parameters"]
1294 parent_tool = self
1296 class StackOneLangChainTool(BaseTool):
1297 name: str = parent_tool.name
1298 description: str = parent_tool.description
1299 args_schema: dict[str, Any] = args_json_schema # ty: ignore[invalid-assignment]
1301 def _run(self, **kwargs: Any) -> Any:
1302 # Drop unsupplied optionals. pydantic materialises every optional field
1303 # as None and BaseTool passes them all through, and the API reads an
1304 # explicit null as "required field missing" — which 400'd every single
1305 # tool call made through this adapter.
1306 supplied = {key: value for key, value in kwargs.items() if value is not None}
1307 try:
1308 return parent_tool.execute(supplied)
1309 except (StackOneError, ValueError) as exc:
1310 # LangChain's handle_tool_error only catches ToolException, so a
1311 # StackOneError would kill the graph rather than reaching the
1312 # agent. Models guess arguments wrong and StackOne's 400 names the
1313 # offending field — re-raising in LangChain's own vocabulary lets
1314 # a caller opt into feeding that back and retrying.
1315 # Carry the structured fields across: without them handle_tool_error
1316 # leaves a caller with only str(exc), unable to tell 401 from 429.
1317 # The field that is actually wrong is often only in response_body.
1318 # Without it the agent retries blind, which is the whole point of
1319 # handing the error back.
1320 body = getattr(exc, "response_body", None)
1321 failure = ToolException(f"{exc}: {body}" if body else str(exc))
1322 failure.status_code = getattr(exc, "status_code", None) # type: ignore[attr-defined]
1323 failure.response_body = getattr(exc, "response_body", None) # type: ignore[attr-defined]
1324 raise failure from exc
1326 return StackOneLangChainTool()
1328 def to_pydantic_ai_tool(self) -> Any:
1329 """Convert this tool to a Pydantic AI ``Tool``.
1331 Returns ``pydantic_ai.tools.Tool``, typed as ``Any`` because ``pydantic-ai``
1332 is an optional dependency and must not be imported at module level.
1334 Requires ``stackone-ai[pydantic-ai]`` (installs ``pydantic-ai-slim``).
1335 """
1336 try:
1337 from pydantic_ai.exceptions import ModelRetry
1338 from pydantic_ai.tools import Tool
1339 except ImportError as e:
1340 raise ImportError(
1341 "Install `pydantic-ai-slim` (or `stackone-ai[pydantic-ai]`) "
1342 "to use the Pydantic AI integration."
1343 ) from e
1345 openai_function = self.to_openai_function()
1346 json_schema = openai_function["function"]["parameters"]
1347 parent_tool = self
1349 def implementation(**kwargs: Any) -> Any:
1350 try:
1351 return parent_tool.execute(kwargs)
1352 except (StackOneError, ValueError) as exc:
1353 # Tool.from_schema skips argument validation entirely, so every wrong
1354 # guess the model makes reaches the API — and a raised StackOneError
1355 # escaped the agent loop and ended the run. ModelRetry hands the
1356 # server's explanation back to the model so it can correct itself,
1357 # which is what the LangChain adapter already does via ToolException.
1358 body = getattr(exc, "response_body", None)
1359 raise ModelRetry(f"{exc}: {body}" if body else str(exc)) from exc
1361 return Tool.from_schema(
1362 function=implementation,
1363 name=self.name,
1364 description=self.description,
1365 json_schema=json_schema,
1366 )
1368 def set_account_id(self, account_id: str | None) -> None:
1369 """Set the account ID for this tool"""
1370 self._account_id = account_id
1372 def get_account_id(self) -> str | None:
1373 """Get the current account ID for this tool"""
1374 return self._account_id
1377class StackOneMcpTool(StackOneTool):
1378 """A tool executed over MCP ``tools/call`` on the endpoint that listed it.
1380 Every tool the toolset builds is one of these: per-action tools, the
1381 ``*_search_actions`` / ``*_execute_action`` meta tools and ``stackone_submit_feedback``.
1382 """
1384 _endpoint: str = PrivateAttr()
1385 # Set by the toolset that built the tool: the end-user id it recorded for an account.
1386 # Looked up per request, so a tool keeps up with a later GET /accounts and with
1387 # set_account_id().
1388 _end_user_id_of: Callable[[str], str | None] | None = PrivateAttr(default=None)
1389 # Also set by the toolset: given the API's refusal of a request sent without an end
1390 # user, looks the account's end user up, and says whether there now is one to retry with.
1391 _look_up_end_user: Callable[[str, Exception], bool] | None = PrivateAttr(default=None)
1393 def __init__(
1394 self,
1395 *,
1396 name: str,
1397 description: str,
1398 parameters: ToolParameters,
1399 api_key: str,
1400 endpoint: str,
1401 account_id: str | None,
1402 headers: Headers | None = None,
1403 timeout: float = 60.0,
1404 ) -> None:
1405 super().__init__(
1406 description=description,
1407 parameters=parameters,
1408 _execute_config=ExecuteConfig(name=name, headers=dict(headers or {}), timeout=timeout),
1409 _api_key=api_key,
1410 _account_id=account_id,
1411 )
1412 self._endpoint = endpoint
1414 def _prepare_headers(self) -> Headers:
1415 """The request headers: the configured extras first, then the SDK's own.
1417 Authorization, x-account-id and User-Agent are set last, and any case variant of
1418 them among the extras is dropped first, so neither can replace the credential or
1419 retarget the call at another account. So is x-end-user-id, when the toolset recorded
1420 one for this tool's account; otherwise a configured x-end-user-id is sent as given.
1421 """
1422 end_user_id = self._recorded_end_user_id()
1423 # The constructor requires the key; only code that clears it afterwards gets here.
1424 if not self._api_key: 1424 ↛ 1425line 1424 didn't jump to line 1425 because the condition on line 1424 was never true
1425 raise StackOneError(f'Tool "{self.name}" has no API key to authenticate with.')
1426 return build_request_headers(
1427 api_key=self._api_key,
1428 account_id=self._account_id,
1429 end_user_id=end_user_id,
1430 extra_headers=self._execute_config.headers,
1431 )
1433 def _recorded_end_user_id(self) -> str | None:
1434 if self._end_user_id_of is None or not self._account_id:
1435 return None
1436 return self._end_user_id_of(self._account_id)
1438 def execute(self, arguments: str | JsonDict | None = None) -> JsonDict:
1439 """Call the tool over MCP ``tools/call``.
1441 Arguments are sent as given; the server maps them onto the action. Header arguments
1442 — the entries of a ``headers`` object and any ``headers_<name>`` — are the exception:
1443 these arguments are model-controlled, so each is kept only if this tool's own schema
1444 declares it, and never if it is Authorization, x-account-id, User-Agent or
1445 x-end-user-id.
1447 Returns:
1448 The tool's result as the server wrote it (see :func:`parse_tool_result`): for an
1449 action, ``{"isError": false, "result": ..., ...}``. A file action's ``result`` is a
1450 download link, not the file.
1452 Raises:
1453 StackOneAPIError: If the result carries ``isError``, with the status from its
1454 payload, or the endpoint answers with an HTTP error. A 400 asking for the
1455 end user of an account the toolset has none recorded for is answered first:
1456 the toolset looks it up with ``GET /accounts`` and the call is sent once more.
1457 ToolArgumentsError: If the arguments are not a JSON object or cannot be encoded.
1458 A subclass of both StackOneError and ValueError.
1459 """
1460 parsed = self._parse_arguments(arguments)
1462 unencodable = f"Arguments for {_json_text(self.name)} could not be encoded as JSON"
1463 try:
1464 # Without this a prompt-injected call could put its own Authorization or
1465 # x-account-id in a header argument the server unpacks.
1466 parsed = self._sanitise_header_arguments(parsed)
1467 # NaN and Infinity are not JSON; the MCP client would send them as null.
1468 non_finite = _first_non_finite(parsed)
1469 json.dumps(parsed, ensure_ascii=False).encode("utf-8")
1470 except (UnicodeEncodeError, TypeError, ValueError) as exc:
1471 # A lone surrogate, in any string or key however deep — what a model emits when a
1472 # token boundary splits an emoji — or a value JSON cannot encode (a set, bytes,
1473 # a cycle) would otherwise fail deep inside the MCP client and surface as a
1474 # transport error. It is an argument problem.
1475 raise ToolArgumentsError(f"{unencodable}: {exc}") from exc
1476 except RecursionError as exc:
1477 # A header value that contains itself, directly or through a cycle of nested
1478 # dicts/lists, recurses forever as _header_text walks it. Not a different
1479 # defect than the ones above: it's still an argument the server could never
1480 # have accepted.
1481 raise ToolArgumentsError(f"{unencodable}: circular reference") from exc
1482 if non_finite is not None:
1483 raise ToolArgumentsError(f"{unencodable}: {_js_number(non_finite)} is not a JSON number")
1485 def call() -> JsonDict:
1486 timeout = self._execute_config.timeout
1487 return call_mcp_tool(self._endpoint, self._prepare_headers(), self.name, parsed, timeout=timeout)
1489 account_id = self._account_id
1490 without_end_user = account_id is not None and self._recorded_end_user_id() is None
1491 try:
1492 return call()
1493 except StackOneAPIError as exc:
1494 # Sent with no recorded end user and refused for want of one: the account is
1495 # non-shared and its end user can be looked up. Retried once, with it.
1496 if not (
1497 without_end_user
1498 and account_id is not None
1499 and self._look_up_end_user is not None
1500 and self._look_up_end_user(account_id, exc)
1501 ):
1502 raise
1503 return call()
1506def _read_openai_tool_call(call: Any) -> tuple[str, str, str | JsonDict]:
1507 """Pull (id, name, arguments) from an openai ToolCall object or its dict form."""
1508 if isinstance(call, dict):
1509 function = call.get("function") or {}
1510 return str(call.get("id", "")), str(function.get("name", "")), function.get("arguments") or {}
1511 function = call.function
1512 return str(call.id), str(function.name), function.arguments or {}
1515class Tools:
1516 """Container for Tool instances with lookup capabilities"""
1518 def __init__(self, tools: list[StackOneTool]) -> None:
1519 self.tools = tools
1520 # First listing wins, as Node's getTool() finds the first match.
1521 self._tool_map: dict[str, StackOneTool] = {}
1522 for tool in tools:
1523 self._tool_map.setdefault(tool.name, tool)
1525 # Two accounts on one provider serve identically named tools, and get_tool()
1526 # returns only one of them — the only symptom would otherwise be an action running
1527 # against an account the caller never chose.
1528 self._warn_duplicate_names()
1530 def _warn_duplicate_names(self) -> None:
1531 """Warn once if more than one tool has the same name."""
1532 if len(self._tool_map) == len(self.tools):
1533 return
1534 counts = Counter(tool.name for tool in self.tools)
1535 clashing = sorted(name for name, count in counts.items() if count > 1)
1536 logger.warning(
1537 "%d tool name(s) are served by more than one account (%s). The first one listed, from the "
1538 "lowest account id, is used — pass account ids to choose.",
1539 len(clashing),
1540 ", ".join(clashing[:5]),
1541 )
1543 def _first_per_name(self) -> list[StackOneTool]:
1544 """The first tool of each name, in order: the one :meth:`get_tool` returns.
1546 Every adapter is built from these, so a model is never offered a tool a lookup by
1547 name would not find. OpenAI was sent two functions of one name, and LangChain and
1548 Pydantic AI each kept whichever their own lookup happened to find.
1549 """
1550 self._warn_duplicate_names()
1551 return list(self._tool_map.values())
1553 def __getitem__(self, index: int) -> StackOneTool:
1554 return self.tools[index]
1556 def __len__(self) -> int:
1557 return len(self.tools)
1559 def __iter__(self) -> Any:
1560 """Make Tools iterable"""
1561 return iter(self.tools)
1563 def to_list(self) -> list[StackOneTool]:
1564 """Convert to list of tools"""
1565 return list(self.tools)
1567 def get_tool(self, name: str) -> StackOneTool | None:
1568 """Get a tool by its name, or None if absent.
1570 When more than one account serves the name, this is the first one listed.
1571 """
1572 return self._tool_map.get(name)
1574 def set_account_id(self, account_id: str | None) -> None:
1575 """Set the account ID for all tools in this collection"""
1576 for tool in self.tools:
1577 tool.set_account_id(account_id)
1579 def get_account_id(self) -> str | None:
1580 """Get the first non-None account ID found, or None if none set"""
1581 for tool in self.tools:
1582 account_id = tool.get_account_id()
1583 if isinstance(account_id, str):
1584 return account_id
1585 return None
1587 def to_openai(self) -> list[JsonDict]:
1588 """Convert the tools to OpenAI function format, the first of each name only."""
1589 return [tool.to_openai_function() for tool in self._first_per_name()]
1591 def execute_openai_tool_calls(self, tool_calls: Iterable[Any] | None) -> list[JsonDict]:
1592 """Run a Chat Completions response's tool calls and return the ``tool`` messages.
1594 The counterpart to :meth:`to_openai`: that turns these tools into what OpenAI
1595 accepts, this turns what OpenAI returns back into messages to send it. Append
1596 the assistant message first, then these, in order::
1598 message = response.choices[0].message
1599 messages.append(message.model_dump(exclude_none=True))
1600 messages.extend(tools.execute_openai_tool_calls(message.tool_calls))
1602 A failed call does not raise. Its error becomes the tool message's content, so
1603 the model can read why and retry — the same thing the LangChain and Pydantic AI
1604 adapters do. A call to a tool that is not in this collection is reported the
1605 same way.
1607 Accepts the ``openai`` package's objects or plain dicts, so it needs no extra.
1608 """
1609 messages: list[JsonDict] = []
1610 for call in tool_calls or []:
1611 call_id, name, arguments = _read_openai_tool_call(call)
1612 tool = self.get_tool(name)
1613 if tool is None:
1614 result: Any = {"error": f"Unknown tool {_json_text(name)}"}
1615 else:
1616 try:
1617 result = tool.execute(arguments)
1618 except (StackOneError, ValueError) as exc:
1619 body = getattr(exc, "response_body", None)
1620 result = {"error": str(exc), **({"response_body": body} if body else {})}
1621 # default=str: a hand-built tool may return something JSON cannot encode.
1622 content = json.dumps(result, default=str)
1623 messages.append({"role": "tool", "tool_call_id": call_id, "content": content})
1624 return messages
1626 def to_langchain(self) -> Sequence[Any]:
1627 """Convert the tools to LangChain format, the first of each name only.
1629 Requires ``stackone-ai[langchain]``.
1630 """
1631 return [tool.to_langchain() for tool in self._first_per_name()]
1633 def to_pydantic_ai(self) -> list[Any]:
1634 """Convert the tools to Pydantic AI ``Tool`` instances, the first of each name only.
1636 Requires ``stackone-ai[pydantic-ai]`` (installs ``pydantic-ai-slim``).
1637 """
1638 return [tool.to_pydantic_ai_tool() for tool in self._first_per_name()]