@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 (
|
|
16
|
-
|
|
17
|
-
every call
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
returns; on a hard kill the OS reclaims the thread. We deliberately do NOT
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
365
|
-
it
|
|
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
|
|
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.
|
|
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(
|
|
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,
|
|
420
|
-
# to the author's scan(). Scoped to Exception so a tick
|
|
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
|
-
|
|
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) ->
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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.
|
|
464
|
-
self.
|
|
640
|
+
self._current = self._run(self._open_session(), name, deadline)
|
|
641
|
+
return self._current
|
|
465
642
|
|
|
466
|
-
|
|
467
|
-
"""
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "3.0.101",
|
|
6
6
|
"commandAliases": [
|
|
7
7
|
{
|
|
8
8
|
"name": "senpi"
|