@senpi/runtime 3.0.99 → 3.0.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ on error/timeout/malformed/failed-persist — transactional discard wins on fail
13
13
  """
14
14
 
15
15
  import inspect
16
+ import math
16
17
  import os
17
18
  import time
18
19
  import threading
@@ -38,6 +39,35 @@ from .state_manager import StateManager
38
39
  from .ulid import new_ulid
39
40
 
40
41
 
42
+ def _read_call_timeout_seconds(senpi_mcp_mod: Any) -> float:
43
+ """The per-call MCP bound from the launch env, or the documented default.
44
+
45
+ `SENPI_MCP_CALL_TIMEOUT_SECONDS` is operator-set on the runtime box and
46
+ reaches the scaffold child through the supervisor's inherited env. A value
47
+ that is not a positive finite number must not take the scanner down at
48
+ launch — it falls back to the default and says so (`senpi_mcp_config_invalid`),
49
+ the same "loud, not fatal" shape as a malformed launch config field.
50
+ """
51
+ variable = senpi_mcp_mod.CALL_TIMEOUT_ENV_VAR
52
+ default = senpi_mcp_mod.DEFAULT_CALL_TIMEOUT_SECONDS
53
+ raw = os.environ.get(variable)
54
+ if raw is None:
55
+ return default
56
+ try:
57
+ seconds = float(raw)
58
+ except ValueError:
59
+ seconds = math.nan
60
+ if not senpi_mcp_mod.is_valid_call_timeout(seconds):
61
+ log_event(
62
+ "senpi_mcp_config_invalid",
63
+ variable=variable,
64
+ value=raw[:100],
65
+ fallback_seconds=default,
66
+ )
67
+ return default
68
+ return seconds
69
+
70
+
41
71
  def _build_senpi_mcp() -> Any:
42
72
  """Construct the MCP client when creds are present; else return None.
43
73
 
@@ -45,7 +75,8 @@ def _build_senpi_mcp() -> Any:
45
75
  the launcher regardless of how the operator supplied them. With neither set,
46
76
  return None so the no-creds path does not crash — the client is loud at FIRST
47
77
  USE (see scaffold.senpi_mcp). Reference the class through the module attribute
48
- so a test stub patched onto scaffold.senpi_mcp is honored.
78
+ so a test stub patched onto scaffold.senpi_mcp is honored. The per-call bound
79
+ comes from SENPI_MCP_CALL_TIMEOUT_SECONDS (see _read_call_timeout_seconds).
49
80
  """
50
81
  api_key = os.environ.get("SENPI_AUTH_TOKEN") or os.environ.get("SENPI_API_KEY") or None
51
82
  mcp_url = os.environ.get("SENPI_MCP_URL") or None
@@ -63,6 +94,7 @@ def _build_senpi_mcp() -> Any:
63
94
  api_key=api_key,
64
95
  mcp_url=mcp_url,
65
96
  write_allowlist=senpi_mcp_mod.PRODUCER_WRITE_ALLOWLIST,
97
+ call_timeout_seconds=_read_call_timeout_seconds(senpi_mcp_mod),
66
98
  )
67
99
 
68
100
 
@@ -12,13 +12,40 @@ the OFFICIAL MCP Python SDK (`mcp`), which is async-only:
12
12
  await session.initialize()
13
13
  result = await session.call_tool(name, arguments=args)
14
14
 
15
- DECISION (locked, execution-plan B5): use the SDK the STANDARD way — a SINGLE
16
- PERSISTENT `ClientSession` created ONCE (and `initialize()`d once), reused across
17
- every call. NO extra guards: no custom timeout, no reconnect/retry wrapper, no
18
- pooling toggle, no fresh-connection-per-call. The async context managers must
19
- STAY OPEN for the client's lifetime, so we drive them on a dedicated event loop
20
- running in a background thread; `call_tool` submits work onto that loop and blocks
21
- for the result (the sync↔async bridge).
15
+ DECISION (execution-plan B5, AMENDED 2026-09-04): use the SDK the STANDARD way —
16
+ ONE persistent `ClientSession` created once (and `initialize()`d once), reused
17
+ across every call, with no pooling toggle and no fresh-connection-per-call. The
18
+ async context managers must STAY OPEN across calls, so we drive them on a
19
+ dedicated event loop running in a background thread; `call_tool` submits work
20
+ onto that loop and blocks for the result (the sync↔async bridge).
21
+
22
+ AMENDMENT (RCA 2026-09-04, "External scanners go permanently blind after one
23
+ MCP transport failure"): B5 originally also locked "no reconnect". In
24
+ production the MCP server's `POST /mcp` stream is severed at the 60 s Kong
25
+ upstream read timeout; the SDK then closes its streams but the client kept the
26
+ dead session for the life of the process, so every later `call_tool` raised
27
+ `ClosedResourceError` in ~1 ms until a runtime restart — 16–25 scanner MIDs
28
+ blind per day, and authored `scan()` code swallowing the error made every tick
29
+ report `ok` with zero candidates. The session is therefore now REBUILT: a
30
+ transport-level failure (closed/broken stream, connection reset, broken pipe,
31
+ httpx transport error) discards the dead session and the NEXT call opens and
32
+ initialises a fresh one. The failing call still fails — there is no retry
33
+ inside a call — the point is that the failure is no longer permanent. The
34
+ failure is logged once as `senpi_mcp_error` with `session_rebuilt: true`.
35
+
36
+ AMENDMENT, second half (same RCA): B5 also locked "no per-call timeout".
37
+ `call_tool` blocked on the loop's future with no bound, so one stalled
38
+ `POST /mcp` hung a call until Kong severed the stream at 60 s, and a handful of
39
+ such calls consumed the whole 300 s tick budget (`scaffold_tick_timeout`).
40
+ EVERY call is now BOUNDED — session open included — by
41
+ `call_timeout_seconds` (launch env `SENPI_MCP_CALL_TIMEOUT_SECONDS`, default
42
+ `DEFAULT_CALL_TIMEOUT_SECONDS`, well under the 60 s ceiling so the scaffold,
43
+ not the proxy, is what gives up). A call that outlives the bound raises
44
+ `SenpiMcpTimeoutError` (a `TimeoutError`) and is treated exactly like a
45
+ transport failure: the abandoned session is discarded — the server may still
46
+ write to that stream — and the next call opens a fresh one. It is logged as
47
+ `senpi_mcp_error` with `error_type: TimeoutError`, `timeout_seconds` and
48
+ `elapsed_ms` so a stall is distinguishable from a severed stream in ClickStack.
22
49
 
23
50
  Response normalization MATCHES producer.mjs `createSenpiToolClient`:
24
51
  1. isError truthy -> RAISE with the error text (never a silent None).
@@ -42,26 +69,37 @@ The `mcp` package is LAZY-IMPORTED on first connect (NOT at module top), so
42
69
  `import scaffold.senpi_mcp` works even where `mcp` is not installed. Missing creds
43
70
  are LOUD AT FIRST USE: `call_tool` raises, construction never does.
44
71
 
72
+ SESSION LIFETIME: each session lives inside ONE long-lived task on the loop
73
+ (`_session_lifetime`), which enters the transport + ClientSession context
74
+ managers, publishes the initialised session, then parks until a close is
75
+ requested and exits the same context managers from the same task. That
76
+ same-task shape is required: the SDK's transport is an anyio task group, and
77
+ anyio refuses to exit a cancel scope from a task other than the one that
78
+ entered it — so a dead session cannot be closed by calling `aclose()` from the
79
+ `call_tool` task. A rebuild only flips the session's close event; the old
80
+ lifetime task unwinds by itself, and `call_tool` never blocks on that teardown,
81
+ so a wedged transport cannot hang a tick.
82
+
45
83
  TEARDOWN (intentional, one-process-per-scanner model): there is NO explicit
46
- close()/shutdown for the persistent session. The background loop runs on a DAEMON
47
- thread, so it dies with the process. The scaffold runs exactly ONE client per
48
- process for that process's whole life — the supervisor restarts by spawning a
49
- FRESH child process (not by re-running run_scaffold in-place), so a single client
50
- is never replaced in-process and there is no loop/session leak across restart.
51
- On graceful stop (SIGTERM/SIGINT) run_scaffold exits its loop and the process
52
- returns; on a hard kill the OS reclaims the thread. We deliberately do NOT drive
53
- an AsyncExitStack.aclose() from a shutdown hook: it buys nothing over the dropped
54
- TCP connection (streamable-http is request/response, no protocol goodbye) and a
55
- cross-thread aclose() on a wedged transport could itself hang a path that today
56
- cannot hang.
84
+ close()/shutdown for the client. The background loop runs on a DAEMON thread,
85
+ so it dies with the process. The scaffold runs exactly ONE client per process
86
+ for that process's whole life — the supervisor restarts by spawning a FRESH
87
+ child process (not by re-running run_scaffold in-place), so a single client is
88
+ never replaced in-process and there is no loop leak across restart. On
89
+ graceful stop (SIGTERM/SIGINT) run_scaffold exits its loop and the process
90
+ returns; on a hard kill the OS reclaims the thread. We deliberately do NOT
91
+ drive a close from a shutdown hook: it buys nothing over the dropped TCP
92
+ connection (streamable-http is request/response, no protocol goodbye).
57
93
  """
58
94
 
59
95
  from __future__ import annotations
60
96
 
61
97
  import asyncio
98
+ import concurrent.futures
62
99
  import json
63
100
  import math
64
101
  import threading
102
+ import time
65
103
  from typing import Any, Iterable, Optional
66
104
 
67
105
  from .loop_primitives import log_event
@@ -356,13 +394,107 @@ def _cast_market_numerics(name: str, payload: Any, args: Optional[dict] = None)
356
394
  return payload
357
395
 
358
396
 
397
+ # --------------------------------------------------------------------------
398
+ # Transport-failure classification — which errors mean "the session is dead".
399
+ # --------------------------------------------------------------------------
400
+ #
401
+ # Matched by class NAME along the MRO so the module keeps its lazy-import
402
+ # contract (no `anyio`/`httpx` import at module top). anyio raises the first
403
+ # three when the SDK's streams are closed or broken under a session; httpx's
404
+ # `TransportError` is the base of every connect/read/write/protocol/timeout
405
+ # failure on the POST itself. OSError covers ConnectionError and its subclasses
406
+ # (reset, refused, aborted), BrokenPipeError and — deliberately — TimeoutError,
407
+ # which is what the per-call bound raises (`SenpiMcpTimeoutError`): a call that
408
+ # timed out at the socket or under the bound has left the session in an unknown
409
+ # state (the server may still write to the abandoned stream) and the next call
410
+ # must not inherit it.
411
+ # Our own pre-connect raises (PermissionError for a blocked mutation tool,
412
+ # RuntimeError for missing creds) happen before any session is held, so they
413
+ # never reach this classification.
414
+ _TRANSPORT_ERROR_NAMES: frozenset[str] = frozenset(
415
+ {
416
+ "ClosedResourceError",
417
+ "BrokenResourceError",
418
+ "EndOfStream",
419
+ "TransportError",
420
+ }
421
+ )
422
+
423
+
424
+ def _is_transport_error(exc: BaseException) -> bool:
425
+ """True when `exc` means the MCP session's transport is unusable."""
426
+ if isinstance(exc, (OSError, EOFError)):
427
+ return True
428
+ return any(klass.__name__ in _TRANSPORT_ERROR_NAMES for klass in type(exc).__mro__)
429
+
430
+
431
+ # --------------------------------------------------------------------------
432
+ # Per-call bound — the scaffold gives up before the proxy does.
433
+ # --------------------------------------------------------------------------
434
+ #
435
+ # Kong severs a stalled `POST /mcp` at 60.000 s; a tick has 300 s. 30 s is
436
+ # comfortably under the ceiling — a stall is visible as OUR timeout, not as a
437
+ # severed stream — and still leaves ten sequential calls inside one tick. The
438
+ # bound covers the whole call: opening the session (initialize) when there is
439
+ # none, plus the tool call itself.
440
+ DEFAULT_CALL_TIMEOUT_SECONDS: float = 30.0
441
+
442
+ # Launch-env knob for the bound, read by scaffold._build_senpi_mcp.
443
+ CALL_TIMEOUT_ENV_VAR = "SENPI_MCP_CALL_TIMEOUT_SECONDS"
444
+
445
+
446
+ def is_valid_call_timeout(seconds: Any) -> bool:
447
+ """A usable bound: a positive, finite number (bools are not numbers here).
448
+
449
+ Shared by the constructor (which rejects anything else) and the launch-env
450
+ reader in scaffold.py (which falls back to the default instead).
451
+ """
452
+ if isinstance(seconds, bool) or not isinstance(seconds, (int, float)):
453
+ return False
454
+ return math.isfinite(seconds) and seconds > 0
455
+
456
+
457
+ class SenpiMcpTimeoutError(TimeoutError):
458
+ """A `call_tool` outlived the client's per-call bound.
459
+
460
+ A `TimeoutError` so authored `scan()` code can `except TimeoutError`; a
461
+ distinct class so the scaffold's own bound is tellable from a socket
462
+ timeout. Classified as a transport failure: the session it happened on is
463
+ discarded and the next call opens a fresh one.
464
+ """
465
+
466
+
467
+ class _McpSession:
468
+ """One live MCP session: the SDK session plus the handles that end it.
469
+
470
+ `lifetime` is the loop task that entered the SDK's context managers and
471
+ will exit them once `close_requested` is set. `lifetime.done()` while the
472
+ session is still held means the transport died on its own.
473
+ """
474
+
475
+ __slots__ = ("session", "lifetime", "close_requested")
476
+
477
+ def __init__(
478
+ self, session: Any, lifetime: "asyncio.Task[None]", close_requested: asyncio.Event
479
+ ) -> None:
480
+ self.session = session
481
+ self.lifetime = lifetime
482
+ self.close_requested = close_requested
483
+
484
+ @property
485
+ def alive(self) -> bool:
486
+ return not self.lifetime.done()
487
+
488
+
359
489
  class SenpiMcpClient:
360
490
  """Sync MCP client over the official async SDK with one persistent session.
361
491
 
362
492
  Construction is cheap and never touches the network or imports `mcp`. The
363
493
  first `call_tool` lazily imports the SDK, spins up a private event loop on a
364
- background thread, opens the transport + ClientSession ONCE, and initializes
365
- it ONCE. Every subsequent call reuses that same session.
494
+ background thread (once, for the client's life), opens the transport +
495
+ ClientSession, and initializes it once. Every subsequent call reuses that
496
+ same session — until a transport failure, after which the dead session is
497
+ discarded and the next call opens a fresh one (B5 amendment, RCA 2026-09-04).
366
498
  """
367
499
 
368
500
  def __init__(
@@ -370,23 +502,37 @@ class SenpiMcpClient:
370
502
  api_key: Optional[str] = None,
371
503
  mcp_url: Optional[str] = None,
372
504
  write_allowlist: Iterable[str] = (),
505
+ call_timeout_seconds: Optional[float] = None,
373
506
  ) -> None:
374
507
  self._api_key = api_key
375
508
  self._mcp_url = mcp_url
509
+ if call_timeout_seconds is None:
510
+ call_timeout_seconds = DEFAULT_CALL_TIMEOUT_SECONDS
511
+ if not is_valid_call_timeout(call_timeout_seconds):
512
+ raise ValueError(
513
+ "senpi_mcp: call_timeout_seconds must be a positive number, "
514
+ f"got {call_timeout_seconds!r}"
515
+ )
516
+ self._call_timeout = float(call_timeout_seconds)
376
517
  # Mutation tools the operator has explicitly permitted. EMPTY by default
377
518
  # → every MUTATION_TOOLS entry is blocked (read-only producer boundary).
378
519
  self._write_allowlist: frozenset[str] = frozenset(write_allowlist)
379
520
 
380
- # Persistent bridge state — created lazily on first call_tool.
521
+ # Persistent bridge state. The loop + thread are created lazily on the
522
+ # first call_tool and live for the client's life; the session is
523
+ # (re)opened lazily whenever there is no live one.
381
524
  self._loop: Optional[asyncio.AbstractEventLoop] = None
382
525
  self._thread: Optional[threading.Thread] = None
383
- self._session: Any = None
384
- self._stack: Any = None
385
- self._connected = False
526
+ self._current: Optional[_McpSession] = None
386
527
  self._connect_lock = threading.Lock()
387
528
 
388
529
  # -- public sync surface ----------------------------------------------
389
530
 
531
+ @property
532
+ def call_timeout_seconds(self) -> float:
533
+ """The bound every `call_tool` fails within (session open included)."""
534
+ return self._call_timeout
535
+
390
536
  def call_tool(self, name: str, args: Optional[dict] = None) -> Any:
391
537
  """Invoke an MCP tool synchronously; return the unwrapped result.
392
538
 
@@ -396,7 +542,20 @@ class SenpiMcpClient:
396
542
  scaffold event) before it is re-raised, so even when the author's scan()
397
543
  swallows the exception in its own try/except, the failure still leaves a
398
544
  visible record at this boundary instead of vanishing into an empty result.
545
+
546
+ A transport-level failure (see `_is_transport_error`) additionally
547
+ discards the session it happened on, so the NEXT call opens a fresh one
548
+ instead of failing forever; its log event carries `session_rebuilt: true`.
549
+
550
+ The whole call — opening a session when there is none, then the tool
551
+ call — must finish within `call_timeout_seconds`, else it raises
552
+ `SenpiMcpTimeoutError` (logged with `error_type: TimeoutError` and the
553
+ elapsed ms) and the abandoned session is discarded like any other
554
+ transport failure.
399
555
  """
556
+ used: Optional[_McpSession] = None
557
+ started = time.monotonic()
558
+ deadline = started + self._call_timeout
400
559
  try:
401
560
  # Read-only producer boundary, enforced PRE-CONNECT (no transport
402
561
  # opened for a blocked write). A mutation tool passes only if its exact
@@ -411,30 +570,46 @@ class SenpiMcpClient:
411
570
  "not reachable by authored producer code)."
412
571
  )
413
572
 
414
- self._ensure_connected()
415
- result = self._run(self._session.call_tool(name, arguments=args))
573
+ used = self._ensure_connected(name, deadline)
574
+ result = self._run(used.session.call_tool(name, arguments=args), name, deadline)
416
575
  return _cast_market_numerics(name, self._unwrap(name, result), args)
417
576
  except Exception as exc:
418
577
  # Catch every failure — mutation block, missing creds, the lazy
419
- # `import mcp`, transport errors, tool `isError` — log it, then re-raise
420
- # to the author's scan(). Scoped to Exception so a tick timeout
421
- # (_TickTimeout, a BaseException) still unwinds the tick untouched.
578
+ # `import mcp`, transport errors, timeouts, tool `isError` — log it,
579
+ # then re-raise to the author's scan(). Scoped to Exception so a tick
580
+ # timeout (_TickTimeout, a BaseException) still unwinds the tick untouched.
581
+ rebuilt = used is not None and _is_transport_error(exc) and self._discard(used)
582
+ timed_out = isinstance(exc, SenpiMcpTimeoutError)
422
583
  log_event(
423
584
  "senpi_mcp_error",
424
585
  tool=name,
425
- error_type=type(exc).__name__,
586
+ # The fleet fingerprint for the bound is the builtin category,
587
+ # not the scaffold subclass: ClickStack queries `TimeoutError`.
588
+ error_type="TimeoutError" if timed_out else type(exc).__name__,
426
589
  error=str(exc)[:500],
590
+ elapsed_ms=round((time.monotonic() - started) * 1000, 1),
591
+ **({"timeout_seconds": self._call_timeout} if timed_out else {}),
592
+ **({"session_rebuilt": True} if rebuilt else {}),
427
593
  )
428
594
  raise
429
595
 
430
596
  # -- persistent session lifecycle -------------------------------------
431
597
 
432
- def _ensure_connected(self) -> None:
433
- if self._connected:
434
- return
598
+ def _ensure_connected(self, name: str, deadline: float) -> _McpSession:
599
+ """Return the live session, opening one when there is none.
600
+
601
+ A session whose lifetime task has ended on its own (the transport died
602
+ between calls) counts as absent and is replaced here, before the call.
603
+ Opening counts against the call's `deadline`: a stalled initialize
604
+ raises `SenpiMcpTimeoutError` like a stalled tool call would.
605
+ """
606
+ current = self._current
607
+ if current is not None and current.alive:
608
+ return current
435
609
  with self._connect_lock:
436
- if self._connected:
437
- return
610
+ current = self._current
611
+ if current is not None and current.alive:
612
+ return current
438
613
 
439
614
  # Loud at first use: creds are required to reach the MCP server.
440
615
  if not self._api_key:
@@ -448,48 +623,110 @@ class SenpiMcpClient:
448
623
  "(creds are resolved at launch from SENPI_API_KEY/SENPI_MCP_URL)."
449
624
  )
450
625
 
451
- # Dedicated event loop on a background thread. The async context
452
- # managers (transport + session) must stay OPEN for the client's
453
- # lifetime, so we cannot use asyncio.run() per call — that would
454
- # tear the session down between calls.
455
- self._loop = asyncio.new_event_loop()
456
- self._thread = threading.Thread(
457
- target=self._loop.run_forever,
458
- name="senpi-mcp-loop",
459
- daemon=True,
460
- )
461
- self._thread.start()
626
+ if self._loop is None:
627
+ # Dedicated event loop on a background thread, created ONCE per
628
+ # client. The async context managers (transport + session) must
629
+ # stay OPEN across calls, so we cannot use asyncio.run() per
630
+ # call — that would tear the session down between calls. A
631
+ # rebuild reuses this loop; it never spawns another thread.
632
+ self._loop = asyncio.new_event_loop()
633
+ self._thread = threading.Thread(
634
+ target=self._loop.run_forever,
635
+ name="senpi-mcp-loop",
636
+ daemon=True,
637
+ )
638
+ self._thread.start()
462
639
 
463
- self._run(self._connect())
464
- self._connected = True
640
+ self._current = self._run(self._open_session(), name, deadline)
641
+ return self._current
465
642
 
466
- async def _connect(self) -> None:
467
- """Open the transport + ClientSession ONCE and initialize ONCE.
643
+ def _discard(self, dead: _McpSession) -> bool:
644
+ """Drop `dead` so the next call opens a fresh session; ask it to close.
645
+
646
+ Returns False (and touches nothing) when `dead` is no longer the held
647
+ session — a concurrent caller already replaced it, and its successor
648
+ must not be thrown away for the predecessor's failure. The close is
649
+ fire-and-forget: the lifetime task exits its own context managers, and
650
+ the caller never waits on a possibly-wedged transport.
651
+ """
652
+ with self._connect_lock:
653
+ if self._current is not dead:
654
+ return False
655
+ self._current = None
656
+ self._loop.call_soon_threadsafe(dead.close_requested.set)
657
+ return True
658
+
659
+ async def _open_session(self) -> _McpSession:
660
+ """Start a session lifetime task and wait until its session is initialised.
661
+
662
+ A failure while opening propagates to the caller; the lifetime task has
663
+ already unwound whatever it had entered, so nothing is left half-built.
664
+ A cancelled open (the caller's bound ran out while initialize stalled)
665
+ cancels the lifetime task too, which unwinds the transport from inside
666
+ the task that entered it — a stalled open never leaks a session.
667
+ """
668
+ loop = asyncio.get_running_loop()
669
+ ready: "asyncio.Future[Any]" = loop.create_future()
670
+ close_requested = asyncio.Event()
671
+ lifetime = loop.create_task(self._session_lifetime(ready, close_requested))
672
+ try:
673
+ session = await ready
674
+ except asyncio.CancelledError:
675
+ lifetime.cancel()
676
+ raise
677
+ return _McpSession(session, lifetime, close_requested)
678
+
679
+ async def _session_lifetime(self, ready: "asyncio.Future[Any]", close_requested: asyncio.Event) -> None:
680
+ """Own one session from open to close, all within this single task.
468
681
 
469
682
  Lazy-import the SDK here so `import scaffold.senpi_mcp` succeeds in envs
470
683
  without `mcp` installed (tests inject a fake `mcp` into sys.modules).
471
684
  """
472
- from mcp import ClientSession
473
- from mcp.client.streamable_http import streamablehttp_client
474
-
475
- # Keep the async context managers alive by holding their __aenter__
476
- # values on self and only __aexit__-ing them at close. We use
477
- # AsyncExitStack so both nest correctly and unwind in order.
478
- from contextlib import AsyncExitStack
479
-
480
- self._stack = AsyncExitStack()
481
- headers = {"Authorization": f"Bearer {self._api_key}"}
482
- read, write, _ = await self._stack.enter_async_context(
483
- streamablehttp_client(self._mcp_url, headers=headers)
484
- )
485
- session = await self._stack.enter_async_context(ClientSession(read, write))
486
- await session.initialize()
487
- self._session = session
488
-
489
- def _run(self, coro: Any) -> Any:
490
- """Drive a coroutine to completion on the persistent loop, blocking."""
685
+ try:
686
+ from mcp import ClientSession
687
+ from mcp.client.streamable_http import streamablehttp_client
688
+
689
+ headers = {"Authorization": f"Bearer {self._api_key}"}
690
+ async with streamablehttp_client(self._mcp_url, headers=headers) as (read, write, _):
691
+ async with ClientSession(read, write) as session:
692
+ await session.initialize()
693
+ ready.set_result(session)
694
+ await close_requested.wait()
695
+ except BaseException as exc:
696
+ if not ready.done():
697
+ # Opening failed: surface it to the waiting call_tool. A
698
+ # non-Exception (task cancellation) is wrapped so the waiter
699
+ # gets a plain error instead of blocking forever.
700
+ ready.set_exception(
701
+ exc
702
+ if isinstance(exc, Exception)
703
+ else RuntimeError(f"senpi_mcp: session open aborted by {type(exc).__name__}")
704
+ )
705
+ # Otherwise the transport died under a published session (the
706
+ # next call_tool sees `alive` False and rebuilds) or its teardown
707
+ # complained after a rebuild — neither has a caller left to tell.
708
+ if not isinstance(exc, Exception):
709
+ raise
710
+
711
+ def _run(self, coro: Any, name: str, deadline: float) -> Any:
712
+ """Drive a coroutine on the persistent loop, blocking until `deadline`.
713
+
714
+ Past the deadline the loop-side task is cancelled and
715
+ `SenpiMcpTimeoutError` is raised. If the task finished in the instant
716
+ between the deadline and the cancel, its outcome is taken as-is — the
717
+ call made it, by a hair — so a completed open never leaks its session.
718
+ """
491
719
  future = asyncio.run_coroutine_threadsafe(coro, self._loop)
492
- return future.result()
720
+ try:
721
+ return future.result(timeout=max(0.0, deadline - time.monotonic()))
722
+ except concurrent.futures.TimeoutError:
723
+ if not future.cancel():
724
+ return future.result()
725
+ raise SenpiMcpTimeoutError(
726
+ f'senpi_mcp: tool "{name}" did not complete within '
727
+ f"{self._call_timeout:g}s ({CALL_TIMEOUT_ENV_VAR}); the session (or "
728
+ "the pending open) is discarded and the next call opens a fresh one"
729
+ ) from None
493
730
 
494
731
  # -- response normalization (matches producer.mjs createSenpiToolClient) --
495
732
 
@@ -2,7 +2,7 @@
2
2
  "id": "runtime",
3
3
  "name": "Senpi Runtime",
4
4
  "description": "Senpi runtime plugin for OpenClaw. Orchestrates scanners, LLM-based trade decisions, and a DSL trailing stop-loss exit engine.",
5
- "version": "3.0.99",
5
+ "version": "3.0.101",
6
6
  "commandAliases": [
7
7
  {
8
8
  "name": "senpi"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senpi/runtime",
3
- "version": "3.0.99",
3
+ "version": "3.0.101",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",