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.
- baton_proxy/__init__.py +9 -0
- baton_proxy/__main__.py +8 -0
- baton_proxy/config.py +100 -0
- baton_proxy/emitter.py +306 -0
- baton_proxy/proxy.py +554 -0
- baton_proxy/report.py +215 -0
- baton_proxy/sinks.py +219 -0
- baton_proxy-0.1.0.dist-info/METADATA +182 -0
- baton_proxy-0.1.0.dist-info/RECORD +12 -0
- baton_proxy-0.1.0.dist-info/WHEEL +4 -0
- baton_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- baton_proxy-0.1.0.dist-info/licenses/LICENSE +200 -0
baton_proxy/__init__.py
ADDED
baton_proxy/__main__.py
ADDED
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)
|