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

1"""Tools served by the StackOne MCP endpoint, and their execution over MCP ``tools/call``. 

2 

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""" 

8 

9from __future__ import annotations 

10 

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 

29 

30import anyio 

31import httpx 

32from pydantic import BaseModel, Field, PrivateAttr 

33 

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) 

46 

47logger = logging.getLogger("stackone.tools") 

48 

49T = TypeVar("T") 

50 

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" 

55 

56USER_AGENT = f"stackone-ai-python/{_SDK_VERSION}" 

57 

58_HEADER_NAME_PATTERN = re.compile(r"[A-Za-z0-9!#$%&'*+.^_`|~-]+") 

59_HEADER_VALUE_PATTERN = re.compile(r"[\x20-\x7e\t\x80-\xff]*") 

60 

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) 

68 

69 

70def _trim_header_name(name: str) -> str: 

71 """A header name trimmed as Node trims it.""" 

72 return name.strip(_JS_WHITESPACE) 

73 

74 

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 

78 

79 

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. 

90 

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 

108 

109 

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"}) 

114 

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" 

119 

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} 

122 

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_" 

126 

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} 

135 

136 

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]) 

145 

146 

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 

155 

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 

161 

162 

163@dataclass 

164class McpToolDefinition: 

165 """A tool exactly as the MCP server listed it.""" 

166 

167 name: str 

168 description: str | None 

169 input_schema: dict[str, Any] 

170 

171 

172def run_async(awaitable: Coroutine[Any, Any, T]) -> T: 

173 """Run a coroutine, even when called from an existing event loop.""" 

174 

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) 

185 

186 result: dict[str, T] = {} 

187 error: dict[str, BaseException] = {} 

188 

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 

194 

195 thread = threading.Thread(target=runner, daemon=True) 

196 thread.start() 

197 thread.join() 

198 

199 if "error" in error: 

200 raise error["error"] 

201 

202 return result["value"] 

203 

204 

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". 

207 

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)}" 

230 

231 

232def _js_json(value: Any) -> str: 

233 """``value`` as JavaScript's ``JSON.stringify`` writes it: compact, numbers as JS prints them. 

234 

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") 

258 

259 

260def _json_text(value: Any) -> str: 

261 """A value as compact JSON, the way every message quotes a name or a value. 

262 

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) 

270 

271 

272def _json_type(value: Any) -> str: 

273 """The JSON type of a value: object, array, string, number, boolean or null. 

274 

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__ 

290 

291 

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) 

295 

296 

297_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") 

298 

299 

300def _ascii_lower(name: str) -> str: 

301 """A header name lower-cased in ASCII only, as HTTP compares names. 

302 

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) 

307 

308 

309def _declares_object_type(schema_type: Any) -> bool: 

310 """Whether a JSON Schema ``type`` declares (among others) ``"object"``. 

311 

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 

320 

321 

322def _header_text(value: Any) -> str | None: 

323 """A header argument's value as text, as Node's ``headerText`` writes it; ``None`` if null. 

324 

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) 

338 

339 

340def build_auth_header(api_key: str) -> str: 

341 token = base64.b64encode(f"{api_key}:".encode()).decode() 

342 return f"Basic {token}" 

343 

344 

345async def _buffer_error_body(response: httpx.Response) -> None: 

346 """Read an error response's body while its stream is still open. 

347 

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() 

356 

357 

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") 

371 

372 

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) 

379 

380 

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. 

383 

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. 

387 

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)) 

429 

430 

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. 

433 

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) 

449 

450 

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. 

453 

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) 

462 

463 

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"))) 

467 

468 

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 

472 

473 

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 ) 

483 

484 

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. 

487 

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 

504 

505 

506class RateLimitRetryingClient(httpx.Client): 

507 """An ``httpx.Client`` that retries a 429 before the caller ever sees it. 

508 

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. 

513 

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 """ 

519 

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 

523 

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 

543 

544 

545class RateLimitTimeout(httpx.TimeoutException): 

546 """A request retried after a 429 timed out: the 429's doing, not an ordinary timeout.""" 

547 

548 

549@dataclass 

550class _Throttle: 

551 """The last 429 an MCP exchange retried, while any request is still retrying one. 

552 

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 """ 

556 

557 response: httpx.Response | None = None 

558 retrying: int = 0 

559 

560 

561class RateLimitRetryingAsyncClient(httpx.AsyncClient): 

562 """The async counterpart of :class:`RateLimitRetryingClient`, used for every MCP request. 

563 

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. 

566 

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. 

569 

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 """ 

574 

575 def __init__(self, *args: Any, throttle: _Throttle | None = None, **kwargs: Any) -> None: 

576 super().__init__(*args, **kwargs) 

577 self._throttle = throttle 

578 

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 

604 

605 

606def is_rate_limited(exc: BaseException) -> bool: 

607 """Whether a failure is a 429 that outlasted every retry. 

608 

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 

628 

629 

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. 

635 

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. 

640 

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] 

647 

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 

657 

658 

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. 

663 

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 

673 

674 throttle = _Throttle() 

675 

676 async def _list() -> list[McpToolDefinition]: 

677 with anyio.fail_after(timeout): 

678 return await _list_within_deadline() 

679 

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 

710 

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) 

717 

718 

719def _response_body(response: httpx.Response) -> str: 

720 """Best-effort read of an error body. 

721 

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 "" 

736 

737 

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. 

740 

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) 

771 

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}") 

781 

782 

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 

795 

796 

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. 

799 

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 

819 

820 

821def _status_of(parsed: JsonDict) -> int: 

822 """Dig the HTTP status out of an MCP error payload. 

823 

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 

839 

840 

841def _reject_json_constant(constant: str) -> Any: 

842 raise ValueError(f"{constant} is not valid JSON") 

843 

844 

845def _first_non_finite(value: Any) -> float | None: 

846 """The first NaN or infinity in ``value``, depth first in key order, or ``None``. 

847 

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() 

852 

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)) 

867 

868 return walk(value) 

869 

870 

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 

876 

877 

878def parse_tool_result(result: Any, name: str) -> JsonDict: 

879 """Turn an MCP ``CallToolResult`` into a plain dict. 

880 

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. 

885 

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. 

889 

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", "")] 

902 

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) 

918 

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) 

924 

925 if non_text: 

926 parsed["content_parts"] = non_text 

927 return parsed 

928 

929 

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. 

934 

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] 

939 

940 throttle = _Throttle() 

941 

942 async def _call() -> JsonDict: 

943 with anyio.fail_after(timeout): 

944 return await _call_within_deadline() 

945 

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) 

970 

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) 

976 

977 

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. 

980 

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 

1000 

1001 

1002def _strip_internal_keys(schema: Any) -> Any: 

1003 """Recursively drop the SDK's internal markers from a property schema. 

1004 

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 

1018 

1019 

1020class StackOneTool(BaseModel): 

1021 """A single tool: its served schema, and how to call it. 

1022 

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 """ 

1028 

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) 

1035 

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 

1054 

1055 def _parse_arguments(self, arguments: str | JsonDict | None) -> JsonDict: 

1056 """Arguments as a dict, from a dict, a JSON string, or nothing. 

1057 

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) 

1076 

1077 def _declared_headers(self) -> tuple[set[str] | None, set[str], bool]: 

1078 """The header names this tool's served schema declares. 

1079 

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 

1110 

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 

1127 

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. 

1130 

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. 

1136 

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() 

1142 

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 

1155 

1156 def _sanitise_header_arguments(self, arguments: JsonDict) -> JsonDict: 

1157 """Filter the header arguments in a call; every other argument is kept unchanged. 

1158 

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. 

1164 

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. 

1170 

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 

1200 

1201 def execute(self, arguments: str | JsonDict | None = None) -> JsonDict: 

1202 """Execute the tool. Not implemented on the base class. 

1203 

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. 

1206 

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 ) 

1213 

1214 def call(self, *args: Any, **kwargs: Any) -> JsonDict: 

1215 """Call the tool with the given arguments 

1216 

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") 

1223 

1224 if args: 

1225 if len(args) > 1: 

1226 raise ToolArgumentsError("Only one positional argument is allowed") 

1227 return self.execute(args[0]) 

1228 

1229 return self.execute(kwargs if kwargs else None) 

1230 

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) 

1234 

1235 def to_openai_function(self) -> JsonDict: 

1236 """Convert this tool to OpenAI's function format. 

1237 

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"} 

1248 

1249 parameters: JsonDict = self.parameters.model_dump() 

1250 parameters["properties"] = properties 

1251 

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 

1260 

1261 return { 

1262 "type": "function", 

1263 "function": { 

1264 "name": self.name, 

1265 "description": self.description, 

1266 "parameters": parameters, 

1267 }, 

1268 } 

1269 

1270 def to_langchain(self) -> Any: 

1271 """Convert this tool to LangChain format. 

1272 

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. 

1276 

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 

1285 

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"] 

1293 

1294 parent_tool = self 

1295 

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] 

1300 

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 

1325 

1326 return StackOneLangChainTool() 

1327 

1328 def to_pydantic_ai_tool(self) -> Any: 

1329 """Convert this tool to a Pydantic AI ``Tool``. 

1330 

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. 

1333 

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 

1344 

1345 openai_function = self.to_openai_function() 

1346 json_schema = openai_function["function"]["parameters"] 

1347 parent_tool = self 

1348 

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 

1360 

1361 return Tool.from_schema( 

1362 function=implementation, 

1363 name=self.name, 

1364 description=self.description, 

1365 json_schema=json_schema, 

1366 ) 

1367 

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 

1371 

1372 def get_account_id(self) -> str | None: 

1373 """Get the current account ID for this tool""" 

1374 return self._account_id 

1375 

1376 

1377class StackOneMcpTool(StackOneTool): 

1378 """A tool executed over MCP ``tools/call`` on the endpoint that listed it. 

1379 

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 """ 

1383 

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) 

1392 

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 

1413 

1414 def _prepare_headers(self) -> Headers: 

1415 """The request headers: the configured extras first, then the SDK's own. 

1416 

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 ) 

1432 

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) 

1437 

1438 def execute(self, arguments: str | JsonDict | None = None) -> JsonDict: 

1439 """Call the tool over MCP ``tools/call``. 

1440 

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. 

1446 

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. 

1451 

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) 

1461 

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") 

1484 

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) 

1488 

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() 

1504 

1505 

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 {} 

1513 

1514 

1515class Tools: 

1516 """Container for Tool instances with lookup capabilities""" 

1517 

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) 

1524 

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() 

1529 

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 ) 

1542 

1543 def _first_per_name(self) -> list[StackOneTool]: 

1544 """The first tool of each name, in order: the one :meth:`get_tool` returns. 

1545 

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()) 

1552 

1553 def __getitem__(self, index: int) -> StackOneTool: 

1554 return self.tools[index] 

1555 

1556 def __len__(self) -> int: 

1557 return len(self.tools) 

1558 

1559 def __iter__(self) -> Any: 

1560 """Make Tools iterable""" 

1561 return iter(self.tools) 

1562 

1563 def to_list(self) -> list[StackOneTool]: 

1564 """Convert to list of tools""" 

1565 return list(self.tools) 

1566 

1567 def get_tool(self, name: str) -> StackOneTool | None: 

1568 """Get a tool by its name, or None if absent. 

1569 

1570 When more than one account serves the name, this is the first one listed. 

1571 """ 

1572 return self._tool_map.get(name) 

1573 

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) 

1578 

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 

1586 

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()] 

1590 

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. 

1593 

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:: 

1597 

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)) 

1601 

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. 

1606 

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 

1625 

1626 def to_langchain(self) -> Sequence[Any]: 

1627 """Convert the tools to LangChain format, the first of each name only. 

1628 

1629 Requires ``stackone-ai[langchain]``. 

1630 """ 

1631 return [tool.to_langchain() for tool in self._first_per_name()] 

1632 

1633 def to_pydantic_ai(self) -> list[Any]: 

1634 """Convert the tools to Pydantic AI ``Tool`` instances, the first of each name only. 

1635 

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()]