baton-proxy 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.
@@ -0,0 +1,9 @@
1
+ """baton-proxy — subprocess-wrap MCP proxy.
2
+
3
+ Wraps a stdio MCP server, injects an annotation tool into the handshake,
4
+ and emits friction events to a baton-console.
5
+
6
+ See README.md for usage.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Entry point for `python -m baton_proxy` and the `baton-proxy` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from baton_proxy.proxy import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
baton_proxy/config.py ADDED
@@ -0,0 +1,100 @@
1
+ """Runtime configuration — read from environment variables once at startup.
2
+
3
+ Subprocess-wrap deployment is 1-process-per-user (Claude Desktop / Claude Code
4
+ spawns one proxy per MCP server entry), so a static per-process token model is
5
+ fine. Hosted-HTTP deployment will need a per-request resolver; not in scope here.
6
+
7
+ Zero-config defaults
8
+ --------------------
9
+
10
+ The proxy is meant to be install-and-play: add ``baton-proxy --`` in front of
11
+ any MCP server, restart, and you get a stream of friction events in
12
+ ``/tmp/baton-proxy.jsonl`` (and on stderr). No env vars required. The
13
+ defaults are deliberately placeholder-flavoured (``"local"``) so that the
14
+ upgrade to a real Console is forced to be explicit.
15
+
16
+ When ``BATON_EVENT_SINK`` resolves to an http(s):// sink, the emitter
17
+ refuses to start while ``BATON_CONSENT_TOKEN`` is still the placeholder —
18
+ placeholder-tagged events must never leak to a remote collector.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import uuid
25
+ from dataclasses import dataclass
26
+
27
+ # Zero-config defaults. Multi-sink (stderr + local file) so the events are
28
+ # immediately visible both as a live stream and as a persistent log; tenant
29
+ # and consent default to a sentinel ``"local"`` to make it obvious in any
30
+ # downstream system that the install hasn't been wired to a real Console yet.
31
+ DEFAULT_EVENT_SINK = "stderr:,file:///tmp/baton-proxy.jsonl"
32
+ DEFAULT_TENANT_ID = "local"
33
+ DEFAULT_CONSENT_TOKEN = "local"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Config:
38
+ """All runtime knobs. Created via Config.from_env()."""
39
+
40
+ # Process-lifetime session identifier per SPEC §11.4. Every event the proxy
41
+ # emits during this process shares this session_id.
42
+ session_id: str
43
+
44
+ # Where emitted events go. A URL whose scheme selects the sink:
45
+ # https://console.example.com -> HTTP POST to {url}/v0/events
46
+ # file:///tmp/events.jsonl -> append-JSONL to the local path
47
+ # stderr: -> JSONL to stderr
48
+ # Comma-separated values fan out (MultiSink). Defaults via from_env() to
49
+ # ``DEFAULT_EVENT_SINK`` (stderr + local file). None disables emission —
50
+ # only test code sets this to None directly; ``from_env()`` always
51
+ # returns a populated value.
52
+ event_sink: str | None
53
+ tenant_id: str | None
54
+ # Only required for http(s) sinks; ignored for file/stderr sinks. The
55
+ # HTTP sink raises at startup if event_sink is http(s):// and this is None.
56
+ api_key: str | None
57
+ consent_token: str | None
58
+
59
+ # Vendor identifier surfaced in proxy logs and the annotation tool's
60
+ # namespace prefix (``{vendor_id}_annotate``). Optional; falls back to
61
+ # the generic ``vendor_annotate`` name.
62
+ vendor_id: str | None
63
+
64
+ # Where the proxy writes its own operational log. Stderr by default;
65
+ # override with BATON_PROXY_LOG_FILE for persistent debugging.
66
+ log_file: str | None
67
+
68
+ @property
69
+ def emission_enabled(self) -> bool:
70
+ """True when the envelope-essential fields are populated. With
71
+ ``from_env()`` defaults this is always True; only test code that
72
+ passes ``event_sink=None`` etc. directly will see False."""
73
+ return all(
74
+ v is not None
75
+ for v in (self.event_sink, self.tenant_id, self.consent_token)
76
+ )
77
+
78
+ @property
79
+ def using_placeholder_consent(self) -> bool:
80
+ """True when consent_token is still the install-time placeholder.
81
+ Emitter refuses to start an http(s) sink while this is True — a
82
+ placeholder consent token must never reach a remote collector."""
83
+ return self.consent_token == DEFAULT_CONSENT_TOKEN
84
+
85
+ @classmethod
86
+ def from_env(cls) -> Config:
87
+ return cls(
88
+ session_id=str(uuid.uuid4()),
89
+ event_sink=_env("BATON_EVENT_SINK") or DEFAULT_EVENT_SINK,
90
+ tenant_id=_env("BATON_TENANT_ID") or DEFAULT_TENANT_ID,
91
+ api_key=_env("BATON_API_KEY"),
92
+ consent_token=_env("BATON_CONSENT_TOKEN") or DEFAULT_CONSENT_TOKEN,
93
+ vendor_id=_env("BATON_VENDOR_ID"),
94
+ log_file=_env("BATON_PROXY_LOG_FILE"),
95
+ )
96
+
97
+
98
+ def _env(name: str) -> str | None:
99
+ v = os.environ.get(name)
100
+ return v if v else None
baton_proxy/emitter.py ADDED
@@ -0,0 +1,306 @@
1
+ """Async friction-event emitter.
2
+
3
+ The proxy intercepts MCP traffic on the hot path (every `tools/call`). Doing
4
+ a synchronous network call from that thread would add the full ingest
5
+ round-trip (~50-200ms) to every tool call. Trust pattern: sub-ms overhead.
6
+ So emission is queued and drained on a background thread; the hot path
7
+ only pays an `enqueue()`.
8
+
9
+ Failure mode: the background thread logs and drops on sink failures. A
10
+ backed-up or dead emitter must NEVER block proxy I/O — that's the
11
+ fail-open contract. Queue is bounded; overflow drops the oldest event
12
+ and logs once per 100 drops.
13
+
14
+ Where events go is the Sink's job (sinks.py). The Emitter just enqueues,
15
+ drains, and hands each event to ``self._sink.write(event)``. Sink is built
16
+ once at start() from ``BATON_EVENT_SINK`` (URL-driven, comma-separated
17
+ list builds a MultiSink); misconfig (unsupported scheme, http without
18
+ api_key) raises at start() — never a silent no-emit.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import queue
25
+ import threading
26
+ import time
27
+ import uuid
28
+ from collections.abc import Mapping
29
+ from dataclasses import dataclass
30
+ from datetime import UTC, datetime
31
+ from typing import Any
32
+
33
+ from baton_proxy.config import Config
34
+ from baton_proxy.sinks import Sink, make_sink
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Bounded queue — backed-up emitter shouldn't accumulate unbounded memory.
39
+ # 1000 events buys a decent buffer for typical 5-10 RPS tool-call workloads.
40
+ _QUEUE_MAXSIZE = 1000
41
+
42
+ _SDK_VERSION = "baton-proxy/0.0.1"
43
+ _AGENT_RUNTIME = "mcp-proxy"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _Event:
48
+ """Wire envelope, mirrors baton-console IncomingEvent shape.
49
+
50
+ Schemas are mirrored rather than imported so the proxy isn't lock-stepped
51
+ to a baton-console release. The console accepts `spec_version: str = "0.1"`
52
+ with a default and `extra="forbid"` on everything else.
53
+ """
54
+
55
+ event_id: str
56
+ event_type: str
57
+ session_id: str
58
+ sequence_number: int
59
+ captured_at: str
60
+ tenant_id: str
61
+ consent_token: str
62
+ sdk_version: str
63
+ agent_runtime: str
64
+ payload: dict[str, Any]
65
+ runtime_meta: dict[str, Any] | None = None
66
+
67
+ def to_json(self) -> dict[str, Any]:
68
+ d: dict[str, Any] = {
69
+ "event_id": self.event_id,
70
+ "event_type": self.event_type,
71
+ "session_id": self.session_id,
72
+ "sequence_number": self.sequence_number,
73
+ "captured_at": self.captured_at,
74
+ "tenant_id": self.tenant_id,
75
+ "consent_token": self.consent_token,
76
+ "sdk_version": self.sdk_version,
77
+ "agent_runtime": self.agent_runtime,
78
+ "payload": self.payload,
79
+ }
80
+ if self.runtime_meta is not None:
81
+ d["runtime_meta"] = self.runtime_meta
82
+ return d
83
+
84
+
85
+ class Emitter:
86
+ """Background-thread emitter. Construct, call .start(), enqueue from any
87
+ thread, and call .stop() at shutdown.
88
+
89
+ When `config.emission_enabled` is False, .start() / .enqueue_*() are no-ops
90
+ so callers don't need to branch.
91
+ """
92
+
93
+ def __init__(self, config: Config) -> None:
94
+ self._config = config
95
+ self._queue: queue.Queue[_Event | None] = queue.Queue(maxsize=_QUEUE_MAXSIZE)
96
+ self._thread: threading.Thread | None = None
97
+ self._seq = 0
98
+ self._seq_lock = threading.Lock()
99
+ # Serialises put_nowait across producers. queue.Queue's internal mutex
100
+ # guards individual operations but not a get+put pair, so an unguarded
101
+ # drop-oldest sequence has a window where another producer can refill
102
+ # the queue between our get and put.
103
+ self._enqueue_lock = threading.Lock()
104
+ self._drop_count = 0
105
+ # Sink set up in start(); None until then.
106
+ self._sink: Sink | None = None
107
+
108
+ def start(self) -> None:
109
+ if not self._config.emission_enabled:
110
+ return
111
+ if self._thread is not None:
112
+ return
113
+ assert self._config.event_sink is not None # emission_enabled gates this
114
+ self._guard_remote_consent()
115
+ self._sink = make_sink(self._config.event_sink, api_key=self._config.api_key)
116
+ self._thread = threading.Thread(target=self._drain, name="baton-proxy-emitter", daemon=True)
117
+ self._thread.start()
118
+
119
+ def _guard_remote_consent(self) -> None:
120
+ """Refuse to ship events to a remote sink while the consent token is
121
+ still the install-time placeholder. Local file/stderr sinks are
122
+ always OK — the placeholder just marks "this install hasn't been
123
+ wired to a real Console yet". The check runs before sink
124
+ construction so a misconfigured install fails loudly at startup
125
+ instead of silently leaking placeholder-tagged events.
126
+ """
127
+ if not self._config.using_placeholder_consent:
128
+ return
129
+ assert self._config.event_sink is not None
130
+ parts = [p.strip() for p in self._config.event_sink.split(",") if p.strip()]
131
+ if any(p.startswith(("http://", "https://")) for p in parts):
132
+ raise ValueError(
133
+ "Refusing to ship events to an http(s) sink with placeholder "
134
+ "BATON_CONSENT_TOKEN='local' — set BATON_CONSENT_TOKEN to the "
135
+ "real per-install consent token before pointing at a Console."
136
+ )
137
+
138
+ def stop(self, timeout: float = 2.0) -> None:
139
+ if self._thread is None:
140
+ return
141
+ # Blocking put with timeout — if the queue is full, put_nowait would
142
+ # silently drop the sentinel and the drain thread would loop until
143
+ # daemon-killed at process exit (losing buffered events). put() waits
144
+ # for the drain thread to free a slot, which it does once per second.
145
+ try:
146
+ self._queue.put(None, timeout=timeout)
147
+ except queue.Full:
148
+ # Drain thread is dead or wedged; nothing more we can do here.
149
+ pass
150
+ self._thread.join(timeout=timeout)
151
+ self._thread = None
152
+ if self._sink is not None:
153
+ self._sink.close()
154
+ self._sink = None
155
+
156
+ def enqueue_tool_call_start(
157
+ self,
158
+ *,
159
+ tool_name: str,
160
+ params: Mapping[str, Any] | None,
161
+ runtime_meta: Mapping[str, Any] | None = None,
162
+ ) -> None:
163
+ self._enqueue(
164
+ event_type="tool_call_start",
165
+ payload={"tool_name": tool_name, "params": dict(params) if params else {}},
166
+ runtime_meta=dict(runtime_meta) if runtime_meta else None,
167
+ )
168
+
169
+ def enqueue_tool_call_end(
170
+ self,
171
+ *,
172
+ tool_name: str,
173
+ result: Any,
174
+ duration_ms: int,
175
+ runtime_meta: Mapping[str, Any] | None = None,
176
+ ) -> None:
177
+ self._enqueue(
178
+ event_type="tool_call_end",
179
+ payload={"tool_name": tool_name, "result": result, "duration_ms": duration_ms},
180
+ runtime_meta=dict(runtime_meta) if runtime_meta else None,
181
+ )
182
+
183
+ def enqueue_tool_call_error(
184
+ self,
185
+ *,
186
+ tool_name: str,
187
+ error_type: str,
188
+ error_body: str,
189
+ duration_ms: int,
190
+ runtime_meta: Mapping[str, Any] | None = None,
191
+ ) -> None:
192
+ self._enqueue(
193
+ event_type="tool_call_error",
194
+ payload={
195
+ "tool_name": tool_name,
196
+ "error_type": error_type,
197
+ "error_body": error_body,
198
+ "duration_ms": duration_ms,
199
+ },
200
+ runtime_meta=dict(runtime_meta) if runtime_meta else None,
201
+ )
202
+
203
+ def enqueue_annotation(
204
+ self,
205
+ *,
206
+ signal_type: str | None,
207
+ intent: str | None,
208
+ suggested_improvement: str | None,
209
+ expected_outcome: str | None = None,
210
+ workflow: str | None = None,
211
+ context: Mapping[str, Any] | None = None,
212
+ runtime_meta: Mapping[str, Any] | None = None,
213
+ ) -> None:
214
+ """Annotation event per SPEC §11.4; nullable keys omitted when None."""
215
+ candidates: dict[str, Any] = {
216
+ "signal_type": signal_type,
217
+ "intent": intent,
218
+ "suggested_improvement": suggested_improvement,
219
+ "expected_outcome": expected_outcome,
220
+ "workflow": workflow,
221
+ "context": dict(context) if context is not None else None,
222
+ }
223
+ payload = {k: v for k, v in candidates.items() if v is not None}
224
+ self._enqueue(
225
+ event_type="annotation",
226
+ payload=payload,
227
+ runtime_meta=dict(runtime_meta) if runtime_meta else None,
228
+ )
229
+
230
+ def _enqueue(
231
+ self,
232
+ *,
233
+ event_type: str,
234
+ payload: dict[str, Any],
235
+ runtime_meta: dict[str, Any] | None,
236
+ ) -> None:
237
+ if not self._config.emission_enabled or self._thread is None:
238
+ return
239
+
240
+ with self._seq_lock:
241
+ seq = self._seq
242
+ self._seq += 1
243
+
244
+ event = _Event(
245
+ event_id=str(uuid.uuid4()),
246
+ event_type=event_type,
247
+ session_id=self._config.session_id,
248
+ sequence_number=seq,
249
+ captured_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
250
+ tenant_id=self._config.tenant_id, # type: ignore[arg-type]
251
+ consent_token=self._config.consent_token, # type: ignore[arg-type]
252
+ sdk_version=_SDK_VERSION,
253
+ agent_runtime=_AGENT_RUNTIME,
254
+ payload=payload,
255
+ runtime_meta=runtime_meta,
256
+ )
257
+
258
+ with self._enqueue_lock:
259
+ try:
260
+ self._queue.put_nowait(event)
261
+ except queue.Full:
262
+ # Drop-oldest. Held under _enqueue_lock so the get+put pair
263
+ # is atomic w.r.t. other producers; without it a concurrent
264
+ # put_nowait could refill the slot between our get and put
265
+ # and silently drop the new event instead of the oldest.
266
+ self._drop_count += 1
267
+ try:
268
+ self._queue.get_nowait()
269
+ except queue.Empty:
270
+ pass
271
+ try:
272
+ self._queue.put_nowait(event)
273
+ except queue.Full:
274
+ pass
275
+ if self._drop_count % 100 == 1:
276
+ logger.warning(
277
+ "baton-proxy emitter queue full, dropped %d events", self._drop_count
278
+ )
279
+
280
+ def _drain(self) -> None:
281
+ while True:
282
+ try:
283
+ event = self._queue.get(timeout=1.0)
284
+ except queue.Empty:
285
+ continue
286
+ if event is None:
287
+ return
288
+ self._deliver(event)
289
+
290
+ def _deliver(self, event: _Event) -> None:
291
+ """Hand one event to the sink. Any failure is logged and dropped —
292
+ fail-open contract: a broken sink must not stall the drain loop or
293
+ propagate exceptions that would kill the daemon thread."""
294
+ assert self._sink is not None # start() built it
295
+ try:
296
+ self._sink.write(event.to_json())
297
+ except Exception as e: # noqa: BLE001 — fail-open at delivery boundary
298
+ logger.warning(
299
+ "baton-proxy emit %s -> %s: %s", event.event_type, type(e).__name__, e
300
+ )
301
+
302
+
303
+ def utc_now_ms() -> int:
304
+ """Monotonic-ish millisecond clock for duration math. time.monotonic()
305
+ gives a relative clock; multiply to ms."""
306
+ return int(time.monotonic() * 1000)