induscode 0.1.0__py3-none-any.whl
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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Bridge-ledger — public barrel for event-sourced MCP tool enrollment.
|
|
2
|
+
|
|
3
|
+
Three concerns, one import site:
|
|
4
|
+
|
|
5
|
+
- **Keys** (``.key``) — content-addressed and ULID :data:`BridgeKey` minting,
|
|
6
|
+
so re-enrolling the same external tool is an idempotent upsert.
|
|
7
|
+
- **Ledger** (``.ledger``) — the immutable :class:`BridgeLedger` value and its
|
|
8
|
+
pure transitions: :func:`enroll_bridge_card` (upsert), :func:`retire` /
|
|
9
|
+
:func:`withdraw_server` (splice), each returning a new ledger folded by the
|
|
10
|
+
contract's :func:`reduce_ledger`.
|
|
11
|
+
- **Network** (``.network``) — the side-effecting half:
|
|
12
|
+
:func:`attach_bridge_capabilities` mounting external MCP servers through the
|
|
13
|
+
framework's ``mount_protocol_bridge``, adapting the returned ``ToolBox``
|
|
14
|
+
into ``AgentTool``-shaped capabilities, and enrolling them into a ledger
|
|
15
|
+
(wholesale mount failure surfaces as a ``bridge`` :class:`DeckFault` on the
|
|
16
|
+
:class:`AttachResult`, never raised); :func:`detach_bridge` withdrawing a
|
|
17
|
+
server set and best-effort tearing the fleet down.
|
|
18
|
+
|
|
19
|
+
The frozen shapes (:data:`BridgeKey`, :class:`BridgeEntry`,
|
|
20
|
+
:class:`LedgerSnapshot`, :func:`reduce_ledger`) continue to live in the deck
|
|
21
|
+
contract; this module re-exports the behavior that operates on them.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .key import bridge_content_key, bridge_ulid_key, qualify_bridge_name
|
|
27
|
+
from .ledger import (
|
|
28
|
+
BridgeLedger,
|
|
29
|
+
EnrollRequest,
|
|
30
|
+
bridge_ledger_from_log,
|
|
31
|
+
empty_bridge_ledger,
|
|
32
|
+
enroll_bridge_card,
|
|
33
|
+
live_capabilities,
|
|
34
|
+
live_capabilities_for_server,
|
|
35
|
+
retire,
|
|
36
|
+
withdraw_server,
|
|
37
|
+
)
|
|
38
|
+
from .network import (
|
|
39
|
+
AttachResult,
|
|
40
|
+
attach_bridge_capabilities,
|
|
41
|
+
bridge_box_to_capabilities,
|
|
42
|
+
bridge_capability_card,
|
|
43
|
+
bridge_config,
|
|
44
|
+
detach_bridge,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AttachResult",
|
|
49
|
+
"BridgeLedger",
|
|
50
|
+
"EnrollRequest",
|
|
51
|
+
"attach_bridge_capabilities",
|
|
52
|
+
"bridge_box_to_capabilities",
|
|
53
|
+
"bridge_capability_card",
|
|
54
|
+
"bridge_config",
|
|
55
|
+
"bridge_content_key",
|
|
56
|
+
"bridge_ledger_from_log",
|
|
57
|
+
"bridge_ulid_key",
|
|
58
|
+
"detach_bridge",
|
|
59
|
+
"empty_bridge_ledger",
|
|
60
|
+
"enroll_bridge_card",
|
|
61
|
+
"live_capabilities",
|
|
62
|
+
"live_capabilities_for_server",
|
|
63
|
+
"qualify_bridge_name",
|
|
64
|
+
"retire",
|
|
65
|
+
"withdraw_server",
|
|
66
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Bridge-key minting — stable identifiers for enrolled MCP capabilities.
|
|
2
|
+
|
|
3
|
+
An enrolled bridge capability needs a key that is *the same* across sessions
|
|
4
|
+
for the same external tool, so re-enrolling is an idempotent upsert rather
|
|
5
|
+
than an accumulating duplicate. The catalog already settled this policy in the
|
|
6
|
+
contract: a :data:`BridgeKey` is "a content hash of the qualified
|
|
7
|
+
``<server>__<tool>`` name (and its schema) or a fresh ULID" — keyed by the
|
|
8
|
+
capability's *identity*, never by a working-directory digest.
|
|
9
|
+
|
|
10
|
+
This module implements both halves of that policy and nothing else:
|
|
11
|
+
|
|
12
|
+
- :func:`bridge_content_key` — the default. A deterministic digest of the
|
|
13
|
+
qualified name plus the canonicalized parameter schema. Two enrollments of
|
|
14
|
+
the same remote tool (same name, same schema) collapse to one key, so the
|
|
15
|
+
ledger upsert is naturally idempotent and the live set never duplicates a
|
|
16
|
+
tool just because a server reconnected.
|
|
17
|
+
- :func:`bridge_ulid_key` — an opt-in, monotonic, time-sortable ULID for the
|
|
18
|
+
rare case a caller wants every enrollment to be a distinct event (e.g. an
|
|
19
|
+
ephemeral, per-invocation graft) rather than a deduplicated identity.
|
|
20
|
+
|
|
21
|
+
Both return a branded :data:`BridgeKey`; neither touches the filesystem, the
|
|
22
|
+
clock-as-state, or any shared mutable value.
|
|
23
|
+
|
|
24
|
+
Port note — canonical-JSON parity with the TS lineage
|
|
25
|
+
-----------------------------------------------------
|
|
26
|
+
The content key must hash the same bytes the TS ``canonicalize`` produced, or
|
|
27
|
+
keys minted by the two runtimes diverge (analysis 03 risk 9). The decisions:
|
|
28
|
+
|
|
29
|
+
- **Objects**: keys sorted by UTF-16 code units (the TS ``a < b`` string
|
|
30
|
+
comparison), serialized ``"key":value`` with no whitespace. Entries whose
|
|
31
|
+
value is ``None`` are **dropped** — the Python stand-in for the TS filter
|
|
32
|
+
on ``undefined``-valued entries. (TS kept JSON ``null`` values; Python
|
|
33
|
+
cannot distinguish an in-memory "absent" from a JSON ``null`` once both are
|
|
34
|
+
``None``, so dict-entry ``None`` is read as *absent*, exactly where — and
|
|
35
|
+
only where — TS dropped ``undefined``.)
|
|
36
|
+
- **Arrays**: order preserved (it is semantic in JSON Schema, e.g.
|
|
37
|
+
``required`` / ``enum``); ``None`` *elements* serialize as ``null`` — TS
|
|
38
|
+
rendered both ``null`` and ``undefined`` array elements as ``"null"``.
|
|
39
|
+
- **Strings**: ``json.dumps(..., ensure_ascii=False)`` — matches
|
|
40
|
+
``JSON.stringify`` escaping (quote/backslash/control characters escaped,
|
|
41
|
+
non-ASCII left raw).
|
|
42
|
+
- **Numbers**: integral floats print without the trailing ``.0`` (JS has one
|
|
43
|
+
number type: ``1.0`` stringifies as ``"1"``); non-finite floats print as
|
|
44
|
+
``null`` (``JSON.stringify(NaN)`` → ``"null"``).
|
|
45
|
+
- **Everything non-JSON** (functions, arbitrary objects) prints as ``null``,
|
|
46
|
+
mirroring ``JSON.stringify(value) ?? "null"`` on unserializable values.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import time
|
|
54
|
+
from collections.abc import Mapping, Sequence
|
|
55
|
+
from hashlib import sha256
|
|
56
|
+
|
|
57
|
+
from indusagi.interop import QUALIFIER
|
|
58
|
+
from ulid import ULID
|
|
59
|
+
from ulid import constants as _ulid_constants
|
|
60
|
+
|
|
61
|
+
from ..contract import BridgeKey, Schema, bridge_key
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"bridge_content_key",
|
|
65
|
+
"bridge_ulid_key",
|
|
66
|
+
"qualify_bridge_name",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def qualify_bridge_name(server: str, tool: str) -> str:
|
|
71
|
+
"""The qualified, collision-free name the model sees for a remote tool —
|
|
72
|
+
``"<server>__<tool>"``, using the framework's bridge :data:`QUALIFIER`.
|
|
73
|
+
|
|
74
|
+
The protocol bridge already stamps this exact form onto each grafted
|
|
75
|
+
tool's descriptor; we recompute it here only so a key can be minted
|
|
76
|
+
*before* a descriptor is in hand (e.g. from a ``RemoteToolRef``-shaped
|
|
77
|
+
pair).
|
|
78
|
+
|
|
79
|
+
:param server: the owning MCP server's id
|
|
80
|
+
:param tool: the remote tool's own (unqualified) name
|
|
81
|
+
"""
|
|
82
|
+
return f"{server}{QUALIFIER}{tool}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _canonical_scalar(value: object) -> str:
|
|
86
|
+
"""Serialize one JSON scalar the way ``JSON.stringify`` would (see the
|
|
87
|
+
module docstring for the number/None decisions)."""
|
|
88
|
+
if value is None:
|
|
89
|
+
return "null"
|
|
90
|
+
if isinstance(value, bool): # before int: bool is an int subclass
|
|
91
|
+
return "true" if value else "false"
|
|
92
|
+
if isinstance(value, int):
|
|
93
|
+
return str(value)
|
|
94
|
+
if isinstance(value, float):
|
|
95
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
96
|
+
return "null" # JSON.stringify(NaN/Infinity) -> "null"
|
|
97
|
+
if value.is_integer() and abs(value) < 1e21:
|
|
98
|
+
return str(int(value)) # JS prints 1.0 as "1"
|
|
99
|
+
return json.dumps(value)
|
|
100
|
+
if isinstance(value, str):
|
|
101
|
+
return json.dumps(value, ensure_ascii=False)
|
|
102
|
+
# Unserializable (the TS `JSON.stringify(value) ?? "null"` fallback).
|
|
103
|
+
return "null"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _canonicalize(value: object) -> str:
|
|
107
|
+
"""Stably serialize a parameter schema so two structurally-equal schemas
|
|
108
|
+
hash to the same string regardless of key insertion order.
|
|
109
|
+
|
|
110
|
+
A plain ``json.dumps`` of an unsorted mapping is order-sensitive:
|
|
111
|
+
``{a,b}`` and ``{b,a}`` would digest differently even though they describe
|
|
112
|
+
the same parameters. This walks the value and sorts every object's keys
|
|
113
|
+
(by UTF-16 code units, matching the TS string comparison), yielding a
|
|
114
|
+
canonical form. Arrays keep their order (it is semantic in JSON Schema,
|
|
115
|
+
e.g. ``required``/``enum``). Mapping entries whose value is ``None`` are
|
|
116
|
+
dropped — the stand-in for the TS ``undefined`` filter.
|
|
117
|
+
|
|
118
|
+
:param value: the schema (or any JSON-ish value) to canonicalize
|
|
119
|
+
"""
|
|
120
|
+
if isinstance(value, Mapping):
|
|
121
|
+
kept = [(str(k), v) for k, v in value.items() if v is not None]
|
|
122
|
+
kept.sort(key=lambda kv: kv[0].encode("utf-16-be"))
|
|
123
|
+
entries = (
|
|
124
|
+
f"{json.dumps(k, ensure_ascii=False)}:{_canonicalize(v)}" for k, v in kept
|
|
125
|
+
)
|
|
126
|
+
return "{" + ",".join(entries) + "}"
|
|
127
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
128
|
+
return "[" + ",".join(_canonicalize(item) for item in value) + "]"
|
|
129
|
+
return _canonical_scalar(value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def bridge_content_key(
|
|
133
|
+
server: str,
|
|
134
|
+
tool: str,
|
|
135
|
+
parameters: Schema | None = None,
|
|
136
|
+
) -> BridgeKey:
|
|
137
|
+
"""Mint a content-addressed :data:`BridgeKey` from a remote tool's
|
|
138
|
+
identity.
|
|
139
|
+
|
|
140
|
+
The digest is taken over the qualified ``<server>__<tool>`` name and the
|
|
141
|
+
canonicalized parameter schema, so the key is a pure function of *what the
|
|
142
|
+
tool is*. Enrolling the same tool twice yields the same key — the ledger
|
|
143
|
+
upsert deduplicates it — while a tool whose schema changed yields a new
|
|
144
|
+
key, correctly surfacing it as a distinct capability.
|
|
145
|
+
|
|
146
|
+
:param server: the owning MCP server's id
|
|
147
|
+
:param tool: the remote tool's own (unqualified) name
|
|
148
|
+
:param parameters: the tool's parameter schema (a raw JSON-schema mapping)
|
|
149
|
+
"""
|
|
150
|
+
qualified = qualify_bridge_name(server, tool)
|
|
151
|
+
schema_part = "" if parameters is None else _canonicalize(parameters)
|
|
152
|
+
digest = sha256()
|
|
153
|
+
digest.update(qualified.encode("utf-8"))
|
|
154
|
+
digest.update(b" ") # domain separator so name|schema cannot collide with name+schema
|
|
155
|
+
digest.update(schema_part.encode("utf-8"))
|
|
156
|
+
return bridge_key(f"bk_{digest.hexdigest()[:32]}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def bridge_ulid_key(monotonic: bool = True) -> BridgeKey:
|
|
160
|
+
"""Mint a fresh, time-sortable :data:`BridgeKey` as a ULID.
|
|
161
|
+
|
|
162
|
+
Use when each enrollment must be a *distinct* event rather than a
|
|
163
|
+
deduplicated identity — for instance an ephemeral, per-invocation graft,
|
|
164
|
+
or when two differently-configured connections to the same server should
|
|
165
|
+
each get their own live entry. The ULID is monotonic within a process so
|
|
166
|
+
ordering is stable.
|
|
167
|
+
|
|
168
|
+
:param monotonic: when ``True`` (default) use ``python-ulid``'s
|
|
169
|
+
process-monotonic provider so keys minted in the same millisecond
|
|
170
|
+
remain strictly increasing (the TS ``monotonicFactory()``); pass
|
|
171
|
+
``False`` for a plain, independent ULID built from a fresh timestamp
|
|
172
|
+
and randomness.
|
|
173
|
+
"""
|
|
174
|
+
if monotonic:
|
|
175
|
+
# python-ulid's shared ValueProvider increments the randomness when two
|
|
176
|
+
# ULIDs land in the same millisecond — process-monotonic by default.
|
|
177
|
+
return bridge_key(f"bk_{ULID()}")
|
|
178
|
+
raw = (time.time_ns() // 1_000_000).to_bytes(
|
|
179
|
+
_ulid_constants.TIMESTAMP_LEN, "big"
|
|
180
|
+
) + os.urandom(_ulid_constants.RANDOMNESS_LEN)
|
|
181
|
+
return bridge_key(f"bk_{ULID(raw)}")
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""The immutable bridge ledger — event-sourced enrollment of MCP capabilities.
|
|
2
|
+
|
|
3
|
+
MCP tools are grafted in and pulled out over a session's life: a server
|
|
4
|
+
connects and contributes a handful of tools, another disconnects, the same
|
|
5
|
+
server reconnects after a hot-reload. Rather than mutate a shared module-level
|
|
6
|
+
list of live tools (which makes "who's enrolled right now" a function of call
|
|
7
|
+
order and hides every past change), enrollment here is an **append-only event
|
|
8
|
+
log** folded into a **derived snapshot**.
|
|
9
|
+
|
|
10
|
+
- :class:`BridgeLedger` is a plain immutable value: the ordered
|
|
11
|
+
:class:`BridgeEntry` log, its already-folded :class:`LedgerSnapshot`, and
|
|
12
|
+
the next sequence number to stamp.
|
|
13
|
+
- Every mutation (:func:`enroll_bridge_card`, :func:`retire`,
|
|
14
|
+
:func:`withdraw_server`) returns a *new* ledger; the input is never touched.
|
|
15
|
+
``enroll`` is an upsert (re-enrolling a key replaces its entry), ``retire``
|
|
16
|
+
is a splice (the keyed entry is removed from the live view).
|
|
17
|
+
- The live view is produced solely by the contract's pure
|
|
18
|
+
:func:`reduce_ledger` fold, so the snapshot can never drift from the log.
|
|
19
|
+
|
|
20
|
+
The log is the source of truth; the snapshot is a cache of its fold. Because
|
|
21
|
+
both live on the value, callers read ``ledger.snapshot.live`` directly and
|
|
22
|
+
never re-reduce by hand.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
from ..contract import (
|
|
32
|
+
AnyCapability,
|
|
33
|
+
BridgeEntry,
|
|
34
|
+
BridgeKey,
|
|
35
|
+
LedgerSnapshot,
|
|
36
|
+
reduce_ledger,
|
|
37
|
+
)
|
|
38
|
+
from .key import bridge_content_key
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"BridgeLedger",
|
|
42
|
+
"EnrollRequest",
|
|
43
|
+
"bridge_ledger_from_log",
|
|
44
|
+
"empty_bridge_ledger",
|
|
45
|
+
"enroll_bridge_card",
|
|
46
|
+
"live_capabilities",
|
|
47
|
+
"live_capabilities_for_server",
|
|
48
|
+
"retire",
|
|
49
|
+
"withdraw_server",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
54
|
+
class BridgeLedger:
|
|
55
|
+
"""An immutable, event-sourced view of MCP tool enrollment.
|
|
56
|
+
|
|
57
|
+
Holds the append-only :class:`BridgeEntry` log, the
|
|
58
|
+
:class:`LedgerSnapshot` that is its current fold, and the sequence number
|
|
59
|
+
the next appended entry will carry. Treat every instance as frozen: the
|
|
60
|
+
enroll/retire/withdraw helpers derive a new ledger rather than editing
|
|
61
|
+
this one in place.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# The append-only enrollment event log, in append order.
|
|
65
|
+
log: tuple[BridgeEntry, ...]
|
|
66
|
+
# The current fold of `log` — the live capabilities and per-server counts.
|
|
67
|
+
snapshot: LedgerSnapshot
|
|
68
|
+
# The sequence number the next appended `BridgeEntry` will carry.
|
|
69
|
+
next_seq: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
73
|
+
class EnrollRequest:
|
|
74
|
+
"""The fields an enrollment supplies; the ledger stamps ``op``, ``seq``,
|
|
75
|
+
and ``at``.
|
|
76
|
+
|
|
77
|
+
A caller names the capability, the owning server, and optionally a
|
|
78
|
+
pre-minted :data:`BridgeKey`; when the key is omitted it is
|
|
79
|
+
content-addressed from the capability's identity so re-enrolling the same
|
|
80
|
+
tool is idempotent.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# The grafted capability to enroll (its `name` is the qualified tool name).
|
|
84
|
+
capability: AnyCapability
|
|
85
|
+
# Id of the external MCP server that owns the capability.
|
|
86
|
+
server: str
|
|
87
|
+
# Optional explicit key; content-addressed from the capability when omitted.
|
|
88
|
+
key: BridgeKey | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _now_iso() -> str:
|
|
92
|
+
"""An ISO-8601 UTC timestamp in the TS ``Date.toISOString()`` form."""
|
|
93
|
+
return (
|
|
94
|
+
datetime.now(timezone.utc)
|
|
95
|
+
.isoformat(timespec="milliseconds")
|
|
96
|
+
.replace("+00:00", "Z")
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def empty_bridge_ledger() -> BridgeLedger:
|
|
101
|
+
"""An empty ledger: no events, an empty live view, sequence numbering at 1."""
|
|
102
|
+
return BridgeLedger(log=(), snapshot=reduce_ledger(()), next_seq=1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def bridge_ledger_from_log(log: Sequence[BridgeEntry]) -> BridgeLedger:
|
|
106
|
+
"""Rebuild a ledger value from a persisted event log.
|
|
107
|
+
|
|
108
|
+
The log is authoritative, so a ledger is fully reconstructable from it:
|
|
109
|
+
fold it for the snapshot and resume sequence numbering one past its
|
|
110
|
+
high-water mark. Use when rehydrating enrollment state across a restart.
|
|
111
|
+
|
|
112
|
+
:param log: the persisted append-only entries, in any order (sorted on fold)
|
|
113
|
+
"""
|
|
114
|
+
snapshot = reduce_ledger(log)
|
|
115
|
+
high_water = 0
|
|
116
|
+
for entry in log:
|
|
117
|
+
if entry.seq > high_water:
|
|
118
|
+
high_water = entry.seq
|
|
119
|
+
return BridgeLedger(log=tuple(log), snapshot=snapshot, next_seq=high_water + 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_key(req: EnrollRequest) -> BridgeKey:
|
|
123
|
+
"""Resolve the key for an :class:`EnrollRequest`: the caller's explicit
|
|
124
|
+
key, or a content key derived from the capability's identity."""
|
|
125
|
+
if req.key is not None:
|
|
126
|
+
return req.key
|
|
127
|
+
cap = req.capability
|
|
128
|
+
# `cap.name` is already the qualified "<server>__<tool>" the bridge stamped
|
|
129
|
+
# on; pairing it with the schema makes the key a pure function of the
|
|
130
|
+
# tool's shape.
|
|
131
|
+
return bridge_content_key(req.server, cap.name, cap.parameters)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def enroll_bridge_card(
|
|
135
|
+
ledger: BridgeLedger,
|
|
136
|
+
req: EnrollRequest,
|
|
137
|
+
at: str | None = None,
|
|
138
|
+
) -> BridgeLedger:
|
|
139
|
+
"""Append an ``enroll`` event and return the resulting ledger.
|
|
140
|
+
|
|
141
|
+
The graft is an **upsert**: the appended entry carries a
|
|
142
|
+
:data:`BridgeKey`, and because :func:`reduce_ledger` lets a later sequence
|
|
143
|
+
win on a repeated key, a re-enrollment of the same tool transparently
|
|
144
|
+
replaces its prior live entry rather than duplicating it. The input ledger
|
|
145
|
+
is not mutated.
|
|
146
|
+
|
|
147
|
+
:param ledger: the current ledger (left untouched)
|
|
148
|
+
:param req: the capability + server to enroll, with an optional explicit key
|
|
149
|
+
:param at: enrollment timestamp; defaults to now (ISO-8601)
|
|
150
|
+
"""
|
|
151
|
+
key = _resolve_key(req)
|
|
152
|
+
entry = BridgeEntry(
|
|
153
|
+
op="enroll",
|
|
154
|
+
key=key,
|
|
155
|
+
server=req.server,
|
|
156
|
+
capability=req.capability,
|
|
157
|
+
seq=ledger.next_seq,
|
|
158
|
+
at=at if at is not None else _now_iso(),
|
|
159
|
+
)
|
|
160
|
+
return _append_entry(ledger, entry)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def retire(
|
|
164
|
+
ledger: BridgeLedger,
|
|
165
|
+
key: BridgeKey,
|
|
166
|
+
server: str,
|
|
167
|
+
at: str | None = None,
|
|
168
|
+
) -> BridgeLedger:
|
|
169
|
+
"""Append a ``retire`` event for one capability and return the resulting
|
|
170
|
+
ledger.
|
|
171
|
+
|
|
172
|
+
The withdrawal is a **splice**: the appended ``retire`` entry names the
|
|
173
|
+
key, and the fold drops that key from the live view. Retiring an unknown
|
|
174
|
+
or already-retired key is a harmless no-op in the live set (the event is
|
|
175
|
+
still recorded for the audit trail). The input ledger is not mutated.
|
|
176
|
+
|
|
177
|
+
:param ledger: the current ledger (left untouched)
|
|
178
|
+
:param key: the stable key of the capability to withdraw
|
|
179
|
+
:param server: the owning server id, recorded on the event
|
|
180
|
+
:param at: retirement timestamp; defaults to now (ISO-8601)
|
|
181
|
+
"""
|
|
182
|
+
entry = BridgeEntry(
|
|
183
|
+
op="retire",
|
|
184
|
+
key=key,
|
|
185
|
+
server=server,
|
|
186
|
+
seq=ledger.next_seq,
|
|
187
|
+
at=at if at is not None else _now_iso(),
|
|
188
|
+
)
|
|
189
|
+
return _append_entry(ledger, entry)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def withdraw_server(
|
|
193
|
+
ledger: BridgeLedger,
|
|
194
|
+
server: str,
|
|
195
|
+
at: str | None = None,
|
|
196
|
+
) -> BridgeLedger:
|
|
197
|
+
"""Retire every capability a given server currently has live, in one batch.
|
|
198
|
+
|
|
199
|
+
Appends one ``retire`` event per live key owned by the server — the
|
|
200
|
+
disconnect counterpart to grafting a whole server's tool set. Servers with
|
|
201
|
+
nothing live yield the ledger unchanged. The input ledger is not mutated.
|
|
202
|
+
|
|
203
|
+
:param ledger: the current ledger (left untouched)
|
|
204
|
+
:param server: the server whose live capabilities should all be withdrawn
|
|
205
|
+
:param at: retirement timestamp applied to every event; defaults to now
|
|
206
|
+
"""
|
|
207
|
+
owned = _live_keys_for_server(ledger, server)
|
|
208
|
+
if not owned:
|
|
209
|
+
return ledger
|
|
210
|
+
stamp = at if at is not None else _now_iso()
|
|
211
|
+
seq = ledger.next_seq
|
|
212
|
+
events: list[BridgeEntry] = []
|
|
213
|
+
for key in owned:
|
|
214
|
+
events.append(BridgeEntry(op="retire", key=key, server=server, seq=seq, at=stamp))
|
|
215
|
+
seq += 1
|
|
216
|
+
log = (*ledger.log, *events)
|
|
217
|
+
return BridgeLedger(log=log, snapshot=reduce_ledger(log), next_seq=seq)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def live_capabilities_for_server(
|
|
221
|
+
ledger: BridgeLedger, server: str
|
|
222
|
+
) -> list[AnyCapability]:
|
|
223
|
+
"""Read the live capabilities a single server currently contributes.
|
|
224
|
+
|
|
225
|
+
Pure projection over the snapshot; the per-key→server association is
|
|
226
|
+
recovered from the log (each live key's last-touching server). Handy for
|
|
227
|
+
status panels and for :func:`withdraw_server`'s batch retirement.
|
|
228
|
+
|
|
229
|
+
:param ledger: the ledger to read
|
|
230
|
+
:param server: the server id to filter by
|
|
231
|
+
"""
|
|
232
|
+
out: list[AnyCapability] = []
|
|
233
|
+
for key in _live_keys_for_server(ledger, server):
|
|
234
|
+
cap = ledger.snapshot.live.get(key)
|
|
235
|
+
if cap is not None:
|
|
236
|
+
out.append(cap)
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def live_capabilities(ledger: BridgeLedger) -> list[AnyCapability]:
|
|
241
|
+
"""The flat list of every live capability, in stable iteration order.
|
|
242
|
+
|
|
243
|
+
What a deck assembler grafts onto the static catalog before handing the
|
|
244
|
+
conductor its ``options.tools``.
|
|
245
|
+
"""
|
|
246
|
+
return list(ledger.snapshot.live.values())
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# internals
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _append_entry(ledger: BridgeLedger, entry: BridgeEntry) -> BridgeLedger:
|
|
255
|
+
"""Append one stamped entry and re-fold; the single mutation primitive."""
|
|
256
|
+
log = (*ledger.log, entry)
|
|
257
|
+
return BridgeLedger(log=log, snapshot=reduce_ledger(log), next_seq=entry.seq + 1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _live_keys_for_server(ledger: BridgeLedger, server: str) -> list[BridgeKey]:
|
|
261
|
+
"""The live keys a server owns, derived from the log: a key is the
|
|
262
|
+
server's when the server's last touch on that key is the entry the fold
|
|
263
|
+
settled on. We map each key to the server of its highest-seq entry, then
|
|
264
|
+
keep the live ones."""
|
|
265
|
+
last_server: dict[BridgeKey, str] = {}
|
|
266
|
+
last_seq: dict[BridgeKey, int] = {}
|
|
267
|
+
for entry in ledger.log:
|
|
268
|
+
seen = last_seq.get(entry.key)
|
|
269
|
+
if seen is None or entry.seq >= seen:
|
|
270
|
+
last_seq[entry.key] = entry.seq
|
|
271
|
+
last_server[entry.key] = entry.server
|
|
272
|
+
out: list[BridgeKey] = []
|
|
273
|
+
for key in ledger.snapshot.live:
|
|
274
|
+
if last_server.get(key) == server:
|
|
275
|
+
out.append(key)
|
|
276
|
+
return out
|