cisternal 0.1.0a1__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.
Files changed (66) hide show
  1. cisternal/__init__.py +150 -0
  2. cisternal/adapters/__init__.py +1 -0
  3. cisternal/adapters/base.py +221 -0
  4. cisternal/adapters/cli.py +85 -0
  5. cisternal/adapters/v2_decorator.py +101 -0
  6. cisternal/adapters/v3_middleware.py +64 -0
  7. cisternal/assets/__init__.py +51 -0
  8. cisternal/assets/bridge.py +140 -0
  9. cisternal/assets/bundle.py +176 -0
  10. cisternal/assets/capability.py +146 -0
  11. cisternal/assets/composite.py +79 -0
  12. cisternal/assets/data/__init__.py +1 -0
  13. cisternal/assets/data/vendor_tools.toml +34 -0
  14. cisternal/assets/inspect_json.py +49 -0
  15. cisternal/assets/load.py +21 -0
  16. cisternal/assets/manifest.py +236 -0
  17. cisternal/assets/manifest_commands.py +48 -0
  18. cisternal/assets/manifest_extensions.py +50 -0
  19. cisternal/assets/protocol.py +15 -0
  20. cisternal/assets/source.py +135 -0
  21. cisternal/assets/spec.py +32 -0
  22. cisternal/assets/validate_golden.py +182 -0
  23. cisternal/cli.py +559 -0
  24. cisternal/export/__init__.py +40 -0
  25. cisternal/export/_hash.py +58 -0
  26. cisternal/export/_markdown.py +33 -0
  27. cisternal/export/_rust_emit.py +116 -0
  28. cisternal/export/antigravity.py +155 -0
  29. cisternal/export/antigravity_rust.py +56 -0
  30. cisternal/export/base.py +65 -0
  31. cisternal/export/claude.py +181 -0
  32. cisternal/export/claude_rust.py +52 -0
  33. cisternal/export/copilot.py +105 -0
  34. cisternal/export/copilot_rust.py +71 -0
  35. cisternal/export/cursor.py +119 -0
  36. cisternal/export/cursor_rust.py +70 -0
  37. cisternal/export/hooks.py +116 -0
  38. cisternal/export/registry.py +94 -0
  39. cisternal/export/sink.py +117 -0
  40. cisternal/export/write.py +59 -0
  41. cisternal/probe/__init__.py +1 -0
  42. cisternal/probe/capability_probe.py +50 -0
  43. cisternal/probe/telemetry_doctor.py +333 -0
  44. cisternal/probe/telemetry_env.py +23 -0
  45. cisternal/py.typed +0 -0
  46. cisternal/registration/__init__.py +46 -0
  47. cisternal/registration/compose.py +89 -0
  48. cisternal/registration/decorator.py +87 -0
  49. cisternal/registration/errors.py +36 -0
  50. cisternal/registration/registry.py +140 -0
  51. cisternal/registration/shim.py +126 -0
  52. cisternal/registration/wired.py +205 -0
  53. cisternal/telemetry/__init__.py +39 -0
  54. cisternal/telemetry/context.py +69 -0
  55. cisternal/telemetry/exporter.py +155 -0
  56. cisternal/telemetry/otlp_exporter.py +255 -0
  57. cisternal/telemetry/pipeline.py +314 -0
  58. cisternal/telemetry/record.py +41 -0
  59. cisternal/telemetry/self_obs.py +245 -0
  60. cisternal/telemetry/span.py +204 -0
  61. cisternal-0.1.0a1.dist-info/METADATA +109 -0
  62. cisternal-0.1.0a1.dist-info/RECORD +66 -0
  63. cisternal-0.1.0a1.dist-info/WHEEL +5 -0
  64. cisternal-0.1.0a1.dist-info/entry_points.txt +8 -0
  65. cisternal-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  66. cisternal-0.1.0a1.dist-info/top_level.txt +1 -0
cisternal/__init__.py ADDED
@@ -0,0 +1,150 @@
1
+ """Cisternal: Shared telemetry substrate for praxia tool family.
2
+
3
+ Public API (spec §3.2 — M1 telemetry + M2 registration surface):
4
+
5
+ M1 — Telemetry:
6
+ init(log_dir, max_bytes, backup_count, exporters): Initialize pipeline
7
+ emit_event(name, **fields): Emit a telemetry event
8
+ span(name, **fields): Sync timing context manager
9
+ aspan(name, **fields): Async timing context manager
10
+ status(): Get pipeline health status
11
+
12
+ M2 — Registration surface (B+G2 hybrid design, challenger-hardened):
13
+ tool: Pure-metadata decorator for registering MCP tools (A1, A2).
14
+ @cisternal.tool returns the original fn unchanged (decorated_fn is fn).
15
+ wire(server, app, *, adapter, registry, expected, validate):
16
+ Snapshot a registry at call time and register each tool on a FastMCP
17
+ server (and optionally a Cyclopts App). Returns a WiredRegistry.
18
+ WiredRegistry: Introspection object returned by wire().
19
+ CisternalWireError: Raised by wire() when expected tools are missing.
20
+ clear_registry(name): Test teardown helper; clears a named registry (A7).
21
+
22
+ Design assumptions (spec §assumptions):
23
+ A1 — FastMCP v3 uses asyncio.iscoroutinefunction() to decide whether to await
24
+ the tool callable. The generated MCP callable is always async def.
25
+ A2 — FastMCP v3 reads inspect.signature(fn) for JSON schema generation.
26
+ Explicit __signature__ injection (H1) controls what FastMCP sees.
27
+ A3 — M1 CisternalMiddleware is installed on the FastMCP server before wire()
28
+ is called. Telemetry and shape adaptation are M1's exclusive responsibility.
29
+ A4 — Cyclopts 4.18.0+ calls asyncio.run() for async def command functions when
30
+ no event loop is running; inside a running loop, use app.run_async().
31
+ A5 — Global registry state is process-scoped; no cross-process sharing.
32
+ A6 — Python >= 3.11 (asyncio.get_running_loop() is the stable API).
33
+ A7 — Test environments call cisternal.clear_registry() in teardown to prevent
34
+ cross-test registry contamination.
35
+
36
+ HARD INVARIANT (C5/AC-M2-6):
37
+ The M2 wire-time MCP callable is a PURE PASSTHROUGH — it MUST NOT call any
38
+ adapter.emit_* or adapter.shape_* methods, or emit ANY telemetry. All
39
+ telemetry and shaping is exclusively owned by M1 CisternalMiddleware.
40
+ """
41
+
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from cisternal.telemetry import (
46
+ init_pipeline,
47
+ get_pipeline,
48
+ span,
49
+ aspan,
50
+ job_span,
51
+ status,
52
+ ExporterBase,
53
+ _build_record,
54
+ )
55
+ from cisternal.registration.decorator import tool
56
+ from cisternal.registration.errors import CisternalWireError
57
+ from cisternal.registration.registry import clear_registry
58
+
59
+ # M3 (assets export)
60
+ from cisternal.assets.spec import AssetSpec
61
+ from cisternal.assets.bundle import AssetBundle
62
+ from cisternal.assets.source import registry_assets
63
+ from cisternal.export.base import Emitter
64
+ from cisternal.export.claude import ClaudeEmitter
65
+ from cisternal.export.write import write_bundle
66
+
67
+
68
+ def init(
69
+ log_dir: str | Path | None = None,
70
+ max_bytes: int = 10_485_760,
71
+ backup_count: int = 5,
72
+ exporters: list[ExporterBase] | None = None,
73
+ heartbeat_interval: float = 30.0,
74
+ ) -> None:
75
+ """Initialize the telemetry pipeline (idempotent).
76
+
77
+ Args:
78
+ log_dir: Directory for JSONL logs. If None, resolves via env vars or defaults to ~/.cisternal/logs.
79
+ max_bytes: Max file size before rotation (default 10 MB).
80
+ backup_count: Number of backup files to keep.
81
+ exporters: Custom exporters. If None, uses JsonlExporter with log_dir.
82
+ heartbeat_interval: Seconds between liveness heartbeat probes (default 30s).
83
+ """
84
+ init_pipeline(
85
+ log_dir=log_dir,
86
+ max_bytes=max_bytes,
87
+ backup_count=backup_count,
88
+ exporters=exporters,
89
+ heartbeat_interval=heartbeat_interval,
90
+ )
91
+
92
+
93
+ def emit_event(name: str, **fields: Any) -> None:
94
+ """Emit a telemetry event.
95
+
96
+ Snapshots contextvars on this thread, builds a Record, and enqueues
97
+ it for non-blocking export. Never raises.
98
+
99
+ Args:
100
+ name: Event name (e.g. 'mcp.call_start').
101
+ **fields: Event fields (e.g. tool='foo', request_id='xyz').
102
+ """
103
+ import time
104
+
105
+ pipeline = get_pipeline()
106
+ if pipeline is None:
107
+ return
108
+
109
+ record = _build_record(name, ts=time.time(), **fields)
110
+ if record is not None:
111
+ pipeline.emit(record)
112
+
113
+
114
+ def _lazy_import(name: str) -> object:
115
+ """Lazy re-export for wire and WiredRegistry to defer fastmcp import."""
116
+ if name == "wire":
117
+ from cisternal.registration.wired import wire as _wire
118
+ return _wire
119
+ if name == "WiredRegistry":
120
+ from cisternal.registration.wired import WiredRegistry as _WiredRegistry
121
+ return _WiredRegistry
122
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
123
+
124
+
125
+ def __getattr__(name: str) -> object:
126
+ return _lazy_import(name)
127
+
128
+
129
+ __all__ = [
130
+ # M1 — Telemetry
131
+ "init",
132
+ "emit_event",
133
+ "span",
134
+ "aspan",
135
+ "job_span",
136
+ "status",
137
+ # M2 — Registration surface
138
+ "tool",
139
+ "wire",
140
+ "WiredRegistry",
141
+ "CisternalWireError",
142
+ "clear_registry",
143
+ # M3 (assets export)
144
+ "AssetSpec",
145
+ "AssetBundle",
146
+ "registry_assets",
147
+ "Emitter",
148
+ "ClaudeEmitter",
149
+ "write_bundle",
150
+ ]
@@ -0,0 +1 @@
1
+ """Consumer adapters: FastMCP v3 middleware, v2 decorator, CLI."""
@@ -0,0 +1,221 @@
1
+ """AdapterBase: Consumer-agnostic adapter protocol (spec §3.6, §5.4).
2
+
3
+ Each adapter owns the event-name set (ALLOWED_NAMES) and the response shape
4
+ for its consumer (shape_ok/shape_error). The MCP wrappers (CisternalMiddleware,
5
+ traced_tool) instantiate an adapter, delegate telemetry emission + shaping to it,
6
+ and never re-raise exceptions (CH-5).
7
+
8
+ (CH-9) Runtime name guard: emit_start/end/error check that the emitted name is
9
+ in self.ALLOWED_NAMES; on mismatch, calls _swallow_name_error (stderr warn + continue).
10
+ Tests monkeypatch _swallow_name_error to raise AssertionError instead.
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+ import json
15
+ import sys
16
+ from typing import Any
17
+
18
+ from cisternal import emit_event
19
+
20
+
21
+ class AdapterBase(ABC):
22
+ """Abstract base for MCP tool adapters (v3 middleware, v2 decorator, etc.).
23
+
24
+ Each subclass defines:
25
+ - ALLOWED_NAMES: frozenset[str] of event names this adapter may emit.
26
+ - shape_ok(tool_name, result): Transform success response.
27
+ - shape_error(tool_name, exc): Transform error response.
28
+ """
29
+
30
+ ALLOWED_NAMES: frozenset[str]
31
+
32
+ def emit_start(self, tool_name: str, arg_keys: list[str], request_id: str) -> None:
33
+ """Emit mcp.call_start event.
34
+
35
+ Args:
36
+ tool_name: Name of the tool being called.
37
+ arg_keys: Sorted list of argument keys (spec §3.7, §3.8).
38
+ request_id: Unique request ID for this invocation.
39
+ """
40
+ name = "mcp.call_start"
41
+ if name not in self.ALLOWED_NAMES:
42
+ self._swallow_name_error(name)
43
+ emit_event(name, tool=tool_name, arg_keys=arg_keys, request_id=request_id)
44
+
45
+ def emit_end(self, tool_name: str, request_id: str, duration_ms: float) -> None:
46
+ """Emit mcp.call_end event.
47
+
48
+ Args:
49
+ tool_name: Name of the tool that executed.
50
+ request_id: The request ID from emit_start.
51
+ duration_ms: Duration of execution in milliseconds.
52
+ """
53
+ name = "mcp.call_end"
54
+ if name not in self.ALLOWED_NAMES:
55
+ self._swallow_name_error(name)
56
+ emit_event(name, tool=tool_name, request_id=request_id, duration_ms=duration_ms)
57
+
58
+ def emit_error(self, tool_name: str, request_id: str, exc: BaseException) -> None:
59
+ """Emit mcp.tool_error event.
60
+
61
+ Args:
62
+ tool_name: Name of the tool that raised.
63
+ request_id: The request ID from emit_start.
64
+ exc: The exception that was raised.
65
+ """
66
+ name = "mcp.tool_error"
67
+ if name not in self.ALLOWED_NAMES:
68
+ self._swallow_name_error(name)
69
+ emit_event(
70
+ name,
71
+ tool=tool_name,
72
+ request_id=request_id,
73
+ exc_type=type(exc).__name__,
74
+ exc_msg=str(exc),
75
+ )
76
+
77
+ @abstractmethod
78
+ def shape_ok(self, tool_name: str, result: Any) -> Any:
79
+ """Shape a successful tool result for the consumer.
80
+
81
+ Args:
82
+ tool_name: Name of the tool (for context).
83
+ result: The result returned by the tool.
84
+
85
+ Returns:
86
+ Consumer-specific shaped response (dict, str, or other).
87
+ """
88
+ pass
89
+
90
+ @abstractmethod
91
+ def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
92
+ """Shape an error response for the consumer (never re-raises).
93
+
94
+ Args:
95
+ tool_name: Name of the tool (for context).
96
+ exc: The exception that was caught.
97
+ **fields: Optional extra fields for the error envelope.
98
+
99
+ Returns:
100
+ Consumer-specific error shape (dict, str, or other).
101
+ """
102
+ pass
103
+
104
+ def _swallow_name_error(self, name: str) -> None:
105
+ """Handle illegal event name: log to stderr and return (warn-and-continue).
106
+
107
+ Tests may monkeypatch this to raise AssertionError (AC-NAMEFREEZE-4).
108
+
109
+ Args:
110
+ name: The illegal event name.
111
+ """
112
+ print(f"[cisternal] ILLEGAL event name: {name!r}", file=sys.stderr)
113
+
114
+
115
+ class BathosAdapter(AdapterBase):
116
+ """Adapter for bathos v3 middleware.
117
+
118
+ Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
119
+ Response shape: dict envelope with ok/error_code/error/resolution_hint.
120
+ """
121
+
122
+ ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
123
+
124
+ def shape_ok(self, tool_name: str, result: Any) -> Any:
125
+ """Shape success: merge dict results, add envelope fields.
126
+
127
+ If result is a dict, spread it and add envelope.
128
+ Otherwise, return minimal envelope.
129
+ """
130
+ if isinstance(result, dict):
131
+ return {
132
+ **result,
133
+ "ok": True,
134
+ "error_code": None,
135
+ "error": None,
136
+ "resolution_hint": None,
137
+ }
138
+ return {
139
+ "ok": True,
140
+ "error_code": None,
141
+ "error": None,
142
+ "resolution_hint": None,
143
+ }
144
+
145
+ def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
146
+ """Shape error: error envelope with error_code and message."""
147
+ return {
148
+ "ok": False,
149
+ "error_code": "INTERNAL",
150
+ "error": str(exc),
151
+ "resolution_hint": "",
152
+ }
153
+
154
+
155
+ class ContemplexAdapter(AdapterBase):
156
+ """Adapter for contemplex v2 decorator (sync).
157
+
158
+ Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
159
+ Response shape: passthrough for success; err_envelope for error.
160
+ """
161
+
162
+ ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
163
+
164
+ def shape_ok(self, tool_name: str, result: Any) -> Any:
165
+ """Shape success: passthrough result unchanged."""
166
+ return result
167
+
168
+ def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
169
+ """Shape error: use contemplex err_envelope if available, else fallback.
170
+
171
+ Tries to import contemplex.errors; if unavailable, returns basic dict.
172
+ """
173
+ try:
174
+ from contemplex.errors import ErrorCode, err_envelope
175
+
176
+ return err_envelope(ErrorCode.INTERNAL, f"{type(exc).__name__}: {exc}")
177
+ except ImportError:
178
+ # Fallback if contemplex is not available
179
+ return {"ok": False, "error_code": "INTERNAL", "error": str(exc)}
180
+
181
+
182
+ class XpeririAdapter(AdapterBase):
183
+ """Adapter for xperiri v2 decorator (sync, JSON-string MCP returns).
184
+
185
+ Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
186
+ Response shape: JSON string for success and error (xperiri MCP tools return str).
187
+ """
188
+
189
+ ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
190
+
191
+ def shape_ok(self, tool_name: str, result: Any) -> Any:
192
+ """Shape success: passthrough str; serialize dict/other to JSON."""
193
+ if isinstance(result, str):
194
+ return result
195
+ return json.dumps(result, sort_keys=True)
196
+
197
+ def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
198
+ """Shape error: JSON string envelope matching xperiri error returns."""
199
+ payload: dict[str, Any] = {"ok": False, "error": str(exc)}
200
+ payload.update(fields)
201
+ return json.dumps(payload, sort_keys=True)
202
+
203
+
204
+ class MyxcelAdapter(AdapterBase):
205
+ """Adapter for myxcel v2 decorator (async MCP tools, dict returns).
206
+
207
+ Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
208
+ Response shape: dict passthrough; errors use {error, message} per myxcel.mcp_server._tool_error.
209
+ """
210
+
211
+ ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
212
+
213
+ def shape_ok(self, tool_name: str, result: Any) -> Any:
214
+ """Shape success: passthrough dict/list results (myxcel in-band errors included)."""
215
+ if isinstance(result, (dict, list)):
216
+ return result
217
+ return {"result": result}
218
+
219
+ def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
220
+ """Shape error: myxcel _tool_error envelope."""
221
+ return {"error": type(exc).__name__, "message": str(exc)}
@@ -0,0 +1,85 @@
1
+ """Typer CLI timing adapter (spec §4.2, AC-CLI-1).
2
+
3
+ Provides CliAdapter for Typer CLI commands and timed_command decorator
4
+ to instrument command execution with cli.cmd_start and cli.cmd_end events.
5
+
6
+ The decorator is CLI-specific: unlike MCP adapters, it re-raises exceptions
7
+ to the CLI, which owns exit codes.
8
+ """
9
+
10
+ import functools
11
+ import time
12
+ from typing import Any
13
+
14
+ from cisternal import emit_event
15
+ from cisternal.adapters.base import AdapterBase
16
+
17
+
18
+ class CliAdapter(AdapterBase):
19
+ """Adapter for Typer CLI commands.
20
+
21
+ Event names (spec §4.2): cli.cmd_start, cli.cmd_end.
22
+ Response shape: passthrough (CLI owns stdout/exit codes).
23
+ """
24
+
25
+ ALLOWED_NAMES = frozenset({"cli.cmd_start", "cli.cmd_end"})
26
+
27
+ def shape_ok(self, tool_name: str, result: Any) -> Any:
28
+ """Shape success: passthrough result (CLI owns output)."""
29
+ return result
30
+
31
+ def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
32
+ """Shape error: not used by CLI (exceptions re-raise directly)."""
33
+ return None
34
+
35
+
36
+ def timed_command(cmd_name: str | None = None):
37
+ """Decorator to emit CLI command timing events.
38
+
39
+ Emits cli.cmd_start before execution and cli.cmd_end after.
40
+ On exception: cli.cmd_end with ok=False, exc_type, then re-raises
41
+ (CLI owns the exit code).
42
+
43
+ Args:
44
+ cmd_name: Optional custom command name. If None, uses function.__name__.
45
+
46
+ Returns:
47
+ Decorator function.
48
+
49
+ Example:
50
+ @timed_command()
51
+ def my_command(arg1: str):
52
+ print(f"Running {arg1}")
53
+ return 0
54
+
55
+ @timed_command("custom_name")
56
+ def another_command():
57
+ return 0
58
+ """
59
+
60
+ def decorator(fn):
61
+ name = cmd_name or fn.__name__
62
+
63
+ @functools.wraps(fn)
64
+ def wrapper(*args, **kwargs):
65
+ emit_event("cli.cmd_start", cmd=name)
66
+ t0 = time.monotonic_ns()
67
+ try:
68
+ result = fn(*args, **kwargs)
69
+ duration_ms = (time.monotonic_ns() - t0) / 1e6
70
+ emit_event("cli.cmd_end", cmd=name, duration_ms=duration_ms, ok=True)
71
+ return result
72
+ except Exception as exc:
73
+ duration_ms = (time.monotonic_ns() - t0) / 1e6
74
+ emit_event(
75
+ "cli.cmd_end",
76
+ cmd=name,
77
+ duration_ms=duration_ms,
78
+ ok=False,
79
+ exc_type=type(exc).__name__,
80
+ )
81
+ raise
82
+
83
+ return wrapper
84
+
85
+ return decorator
@@ -0,0 +1,101 @@
1
+ """traced_tool: FastMCP v2 integration via decorator (spec §3.8).
2
+
3
+ Double-decorator pattern: outer decorator takes an adapter, inner decorator
4
+ wraps the tool function. On each call, emits telemetry, measures duration,
5
+ and returns a shaped response. Never re-raises exceptions (CH-5).
6
+
7
+ Sync and async originals are supported: async tools get an ``async def`` wrapper
8
+ that awaits the original (mirrors :func:`cisternal.registration.shim.dispatch`).
9
+
10
+ (CH-10, AC-MCP-4) Token management: set/reset with ValueError guard.
11
+ """
12
+
13
+ import asyncio
14
+ import functools
15
+ import time
16
+ import uuid
17
+ from typing import Any, Callable, TypeVar
18
+
19
+ from cisternal.telemetry.context import mcp_request_id_var
20
+ from cisternal.adapters.base import AdapterBase
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ def _reset_request_token(token: Any) -> None:
26
+ try:
27
+ mcp_request_id_var.reset(token)
28
+ except ValueError:
29
+ # AC-MCP-4: Token from different context; swallow.
30
+ pass
31
+
32
+
33
+ def traced_tool(
34
+ adapter: AdapterBase,
35
+ ) -> Callable[[Callable[..., T]], Callable[..., Any]]:
36
+ """Decorator to wrap a FastMCP v2 tool with telemetry.
37
+
38
+ Usage:
39
+ @traced_tool(ContemplexAdapter())
40
+ def my_tool(arg: str) -> str:
41
+ return f"result: {arg}"
42
+
43
+ @traced_tool(MyxcelAdapter())
44
+ async def mount_project(remote: str, project: str) -> dict:
45
+ ...
46
+
47
+ Args:
48
+ adapter: AdapterBase instance (e.g., BathosAdapter(), ContemplexAdapter()).
49
+
50
+ Returns:
51
+ Decorator function that wraps the tool (sync or async).
52
+ """
53
+
54
+ def decorator(fn: Callable[..., T]) -> Callable[..., Any]:
55
+ if asyncio.iscoroutinefunction(fn):
56
+
57
+ @functools.wraps(fn)
58
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
59
+ request_id = uuid.uuid4().hex
60
+ token = mcp_request_id_var.set(request_id)
61
+ arg_keys = sorted(kwargs.keys()) # CH-6: kwargs only (parity caveat)
62
+ adapter.emit_start(fn.__name__, arg_keys, request_id)
63
+ t0 = time.monotonic_ns()
64
+
65
+ try:
66
+ result = await fn(*args, **kwargs)
67
+ adapter.emit_end(
68
+ fn.__name__, request_id, (time.monotonic_ns() - t0) / 1e6
69
+ )
70
+ return adapter.shape_ok(fn.__name__, result)
71
+ except Exception as exc:
72
+ adapter.emit_error(fn.__name__, request_id, exc)
73
+ return adapter.shape_error(fn.__name__, exc)
74
+ finally:
75
+ _reset_request_token(token)
76
+
77
+ return async_wrapper
78
+
79
+ @functools.wraps(fn)
80
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
81
+ request_id = uuid.uuid4().hex
82
+ token = mcp_request_id_var.set(request_id)
83
+ arg_keys = sorted(kwargs.keys()) # CH-6: kwargs only (parity caveat)
84
+ adapter.emit_start(fn.__name__, arg_keys, request_id)
85
+ t0 = time.monotonic_ns()
86
+
87
+ try:
88
+ result = fn(*args, **kwargs)
89
+ adapter.emit_end(
90
+ fn.__name__, request_id, (time.monotonic_ns() - t0) / 1e6
91
+ )
92
+ return adapter.shape_ok(fn.__name__, result)
93
+ except Exception as exc:
94
+ adapter.emit_error(fn.__name__, request_id, exc)
95
+ return adapter.shape_error(fn.__name__, exc)
96
+ finally:
97
+ _reset_request_token(token)
98
+
99
+ return sync_wrapper
100
+
101
+ return decorator
@@ -0,0 +1,64 @@
1
+ """CisternalMiddleware: FastMCP v3 integration via middleware hook (spec §3.7).
2
+
3
+ Middleware fires on_call_tool for each tool call, wrapping the tool execution
4
+ with telemetry: emit_start, measure duration, emit_end or emit_error, return
5
+ shaped envelope. Never re-raises exceptions to the transport (CH-5).
6
+
7
+ (CH-10, AC-MCP-4) Token management: set/reset with ValueError guard for
8
+ cross-context token resets (possible in asyncio context copy scenarios).
9
+ """
10
+
11
+ import time
12
+ import uuid
13
+ from typing import Any
14
+
15
+ from fastmcp.server.middleware.middleware import Middleware, MiddlewareContext
16
+ from cisternal.telemetry.context import mcp_request_id_var
17
+ from cisternal.adapters.base import BathosAdapter
18
+
19
+
20
+ class CisternalMiddleware(Middleware):
21
+ """Middleware for FastMCP v3 servers.
22
+
23
+ Wires via: server.add_middleware(CisternalMiddleware()).
24
+ Fires on every tools/call request (spec §3.7).
25
+ """
26
+
27
+ async def on_call_tool(self, context: MiddlewareContext, call_next: Any) -> Any:
28
+ """Intercept tool call, emit telemetry, and return shaped response.
29
+
30
+ Args:
31
+ context: MiddlewareContext with .message = CallToolRequestParams.
32
+ .message.name = tool name (str)
33
+ .message.arguments = tool arguments (dict|None)
34
+ call_next: Async callable to invoke the actual tool.
35
+
36
+ Returns:
37
+ Shaped response (dict envelope with ok/error) per BathosAdapter.
38
+ Never raises; exceptions caught and returned as error envelopes.
39
+ """
40
+ tool_name = context.message.name
41
+ arguments = context.message.arguments or {}
42
+ arg_keys = sorted(arguments.keys()) # CH-6: client-supplied keys only
43
+ request_id = uuid.uuid4().hex
44
+
45
+ # Set mcp_request_id in context for this call
46
+ token = mcp_request_id_var.set(request_id)
47
+ adapter = BathosAdapter()
48
+ adapter.emit_start(tool_name, arg_keys, request_id)
49
+ t0 = time.monotonic_ns()
50
+
51
+ try:
52
+ result = await call_next(context)
53
+ adapter.emit_end(tool_name, request_id, (time.monotonic_ns() - t0) / 1e6)
54
+ return adapter.shape_ok(tool_name, result)
55
+ except Exception as exc:
56
+ adapter.emit_error(tool_name, request_id, exc)
57
+ return adapter.shape_error(tool_name, exc)
58
+ finally:
59
+ try:
60
+ mcp_request_id_var.reset(token)
61
+ except ValueError:
62
+ # AC-MCP-4: Token was created in a different Context (e.g., via
63
+ # copy_context().run() in asyncio). Swallow and continue.
64
+ pass
@@ -0,0 +1,51 @@
1
+ """cisternal.assets — Agent-asset IR (data model) and registry source.
2
+
3
+ Public API:
4
+ AssetSpec — frozen dataclass: one tool's asset metadata.
5
+ BundleMetadata — frozen dataclass: name/version/description for a bundle.
6
+ CommandAsset — frozen dataclass: one command asset.
7
+ McpAsset — frozen dataclass: one MCP server entry.
8
+ SkillAsset — frozen dataclass: one skill asset (M3.1a).
9
+ AgentAsset — frozen dataclass: one agent asset (M3.1a).
10
+ HookSpecAsset — frozen dataclass: one hook spec asset (M3.1a).
11
+ LoadReport — load result with bundle, warnings, conflicts (M3.1a).
12
+ AssetBundle — frozen dataclass: full bundle; sorted collections.
13
+ registry_assets — Extract AssetSpec tuples from a named registry partition.
14
+ """
15
+
16
+ from cisternal.assets.spec import AssetSpec
17
+ from cisternal.assets.bundle import (
18
+ AgentAsset,
19
+ AssetBundle,
20
+ BundleMetadata,
21
+ CommandAsset,
22
+ HookSpecAsset,
23
+ LoadReport,
24
+ McpAsset,
25
+ SkillAsset,
26
+ )
27
+ from cisternal.assets.capability import Capability, resolve_model_hint, resolve_tools
28
+ from cisternal.assets.composite import CompositeAssetSource
29
+ from cisternal.assets.manifest import ManifestAssetSource
30
+ from cisternal.assets.protocol import AssetSource
31
+ from cisternal.assets.source import registry_assets, registry_bundle
32
+
33
+ __all__ = [
34
+ "AssetSpec",
35
+ "AgentAsset",
36
+ "AssetBundle",
37
+ "AssetSource",
38
+ "BundleMetadata",
39
+ "Capability",
40
+ "CommandAsset",
41
+ "CompositeAssetSource",
42
+ "HookSpecAsset",
43
+ "LoadReport",
44
+ "ManifestAssetSource",
45
+ "McpAsset",
46
+ "SkillAsset",
47
+ "registry_assets",
48
+ "registry_bundle",
49
+ "resolve_model_hint",
50
+ "resolve_tools",
51
+ ]