margin-meter 0.6.1__tar.gz → 0.6.2__tar.gz

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 (27) hide show
  1. {margin_meter-0.6.1/margin_meter.egg-info → margin_meter-0.6.2}/PKG-INFO +7 -1
  2. margin_meter-0.6.1/PKG-INFO → margin_meter-0.6.2/README.md +6 -12
  3. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/__init__.py +1 -1
  4. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/__main__.py +17 -0
  5. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/braintrust.py +11 -17
  6. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/client.py +72 -2
  7. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/deepeval.py +6 -2
  8. margin_meter-0.6.2/margin_meter/evals.py +218 -0
  9. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/instrument.py +131 -45
  10. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/langfuse.py +4 -5
  11. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/langsmith.py +10 -17
  12. margin_meter-0.6.2/margin_meter/model_override.py +279 -0
  13. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/promptfoo.py +15 -5
  14. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/providers.py +166 -18
  15. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/pytest_plugin.py +23 -4
  16. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/ragas.py +11 -13
  17. margin_meter-0.6.2/margin_meter/values.py +113 -0
  18. margin_meter-0.6.1/README.md → margin_meter-0.6.2/margin_meter.egg-info/PKG-INFO +18 -0
  19. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/SOURCES.txt +3 -0
  20. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/batching.py +0 -0
  21. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/doctor.py +0 -0
  22. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/substrate.py +0 -0
  23. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/dependency_links.txt +0 -0
  24. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/entry_points.txt +0 -0
  25. {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/top_level.txt +0 -0
  26. {margin_meter-0.6.1 → margin_meter-0.6.2}/pyproject.toml +0 -0
  27. {margin_meter-0.6.1 → margin_meter-0.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin-meter
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Tiny stdlib-only client that auto-instruments OpenAI/Anthropic/Gemini and emits LLM call + outcome economics to a Margin ingest API.
5
5
  Author: Margin
6
6
  License: MIT
@@ -12,6 +12,12 @@ Description-Content-Type: text/markdown
12
12
 
13
13
  # margin-meter (Python SDK)
14
14
 
15
+ > **You probably do not need this to start.** Margin's first step installs nothing: sign in at
16
+ > [trymargin.io/console](https://trymargin.io/console), connect the repository your agents live
17
+ > in (or drop the folder), and press Execute. Each agent runs on the evals beside it and every
18
+ > call is metered. This SDK is the **optional** route for metering your live production traffic,
19
+ > once you want that as well ([`/start#production`](https://trymargin.io/start#production)).
20
+
15
21
  The tiny client a **Python** project imports to connect to Margin. It wraps your
16
22
  LLM calls and records their outcomes, emitting each one **over HTTP** to a
17
23
  Margin **ingest API** (`POST /api/ingest/calls` / `/api/ingest/outcomes`),
@@ -1,17 +1,11 @@
1
- Metadata-Version: 2.4
2
- Name: margin-meter
3
- Version: 0.6.1
4
- Summary: Tiny stdlib-only client that auto-instruments OpenAI/Anthropic/Gemini and emits LLM call + outcome economics to a Margin ingest API.
5
- Author: Margin
6
- License: MIT
7
- Project-URL: Homepage, https://trymargin.io
8
- Project-URL: Source, https://pypi.org/project/margin-meter/
9
- Keywords: llm,cost,observability,finops,margin
10
- Requires-Python: >=3.9
11
- Description-Content-Type: text/markdown
12
-
13
1
  # margin-meter (Python SDK)
14
2
 
3
+ > **You probably do not need this to start.** Margin's first step installs nothing: sign in at
4
+ > [trymargin.io/console](https://trymargin.io/console), connect the repository your agents live
5
+ > in (or drop the folder), and press Execute. Each agent runs on the evals beside it and every
6
+ > call is metered. This SDK is the **optional** route for metering your live production traffic,
7
+ > once you want that as well ([`/start#production`](https://trymargin.io/start#production)).
8
+
15
9
  The tiny client a **Python** project imports to connect to Margin. It wraps your
16
10
  LLM calls and records their outcomes, emitting each one **over HTTP** to a
17
11
  Margin **ingest API** (`POST /api/ingest/calls` / `/api/ingest/outcomes`),
@@ -106,4 +106,4 @@ __all__ = [
106
106
  # closing the invoice-reconcile coverage gap is "ship the fix, wait for the seed's
107
107
  # dependency refresh, re-run the reconcile", and a runtime that misreports which
108
108
  # SDK metered a call makes that unanswerable.
109
- __version__ = "0.6.1"
109
+ __version__ = "0.6.2"
@@ -13,6 +13,10 @@ Subcommands:
13
13
  * ``langfuse`` — report Langfuse scores as outcomes (rung 2).
14
14
  * ``langfuse-calls`` — report Langfuse GENERATION observations as metered calls
15
15
  (rung 2, the AI-in plug — Langfuse is the one source that carries both).
16
+ * ``evals`` — draft eval criteria from the connected repo, list them, or open a PR
17
+ with the accepted ones (MAR-1167).
18
+ * ``mcp`` — the same eval actions as an MCP server on stdio.
19
+ * ``values`` — push what each outcome is worth from your own records (MAR-1496).
16
20
 
17
21
  Kept as a dispatcher so the surface is stable as more adapters/diagnostics arrive.
18
22
  """
@@ -26,10 +30,12 @@ from margin_meter import (
26
30
  braintrust,
27
31
  deepeval,
28
32
  doctor,
33
+ evals,
29
34
  langfuse,
30
35
  langsmith,
31
36
  promptfoo,
32
37
  ragas,
38
+ values,
33
39
  )
34
40
 
35
41
  _USAGE = """margin-meter — Margin's metering SDK
@@ -55,6 +61,11 @@ commands:
55
61
  langfuse-calls
56
62
  report Langfuse GENERATION observations to Margin as metered calls
57
63
  (cost from calculatedTotalCost, else priced from tokens)
64
+ evals draft | list | pr: draft eval criteria from the connected repo,
65
+ list them, or open a PR with the ones a person accepted
66
+ mcp serve the evals actions as MCP tools over stdio
67
+ values push what each outcome is worth from your own records
68
+ (event_id,value_usd CSV on stdin or a file; --source, --field)
58
69
 
59
70
  run `margin-meter <command> --help` for a command's options.
60
71
  """
@@ -82,6 +93,12 @@ def main(argv: Sequence[str] | None = None) -> int:
82
93
  return langfuse.main(rest)
83
94
  if command == "langfuse-calls":
84
95
  return langfuse.main_calls(rest)
96
+ if command == "evals":
97
+ return evals.main(rest)
98
+ if command == "mcp":
99
+ return evals.serve(rest)
100
+ if command == "values":
101
+ return values.main(rest)
85
102
  print(f"margin-meter: unknown command {command!r}\n", file=sys.stderr)
86
103
  print(_USAGE, file=sys.stderr)
87
104
  return 2
@@ -36,8 +36,8 @@ The mapping, one Braintrust result → one outcome:
36
36
  the grade is an LLM judge (rung 6 provenance); omitted otherwise. A bare
37
37
  ``model`` field is NOT used — in a Braintrust record it is usually the
38
38
  generation model under test, not the judge.
39
- * ``link`` ← the case ``id`` / ``span_id``, else a label from its
40
- ``input``, else ``braintrust:<index>``.
39
+ * ``link`` ← the case ``id`` / ``span_id``, else ``braintrust:<index>`` —
40
+ never its ``input`` (MAR-1548).
41
41
  * ``value_usd`` ← opt-in via ``metadata.margin_value`` — the only route to
42
42
  value-per-outcome (rung 8). Absent is the honest cost-per-outcome default,
43
43
  never defaulted to 0.
@@ -101,7 +101,7 @@ _JUDGE_MODEL_KEY = "margin_judge_model"
101
101
  # Mirror ``src/margin/ingest.MAX_VALUE_USD`` (kept as a literal so the SDK has no
102
102
  # dependency on the server package). A value above this is rejected by ingest and
103
103
  # would take the whole outcome row down with it, so the adapter drops the value
104
- # and keeps the row — the same treatment a negative value gets.
104
+ # and keeps the row — the same treatment a malformed value gets.
105
105
  _MAX_VALUE_USD = 10_000_000.0
106
106
 
107
107
  # Model ids that name no real model — a custom/deterministic scorer leaves the
@@ -296,28 +296,22 @@ def value_from_result(result: dict) -> Optional[float]:
296
296
  raise ValueError(f"{_VALUE_KEY} must be a number, got {raw!r}")
297
297
  if not math.isfinite(val):
298
298
  raise ValueError(f"{_VALUE_KEY} must be finite, got {raw!r}")
299
- if val < 0:
300
- raise ValueError(f"{_VALUE_KEY} must be >= 0, got {val!r}")
301
- if val > _MAX_VALUE_USD:
302
- raise ValueError(f"{_VALUE_KEY} implausible (> {_MAX_VALUE_USD}), got {val!r}")
299
+ # A loss is a real value (MAR-1198): ingest takes any finite amount within +/-MAX.
300
+ if abs(val) > _MAX_VALUE_USD:
301
+ raise ValueError(f"{_VALUE_KEY} implausible (|value| > {_MAX_VALUE_USD}), got {val!r}")
303
302
  return val
304
303
 
305
304
 
306
305
  def link_from_result(result: dict, index: int) -> str:
307
- """A traceable link back to the exact eval case: its id/span id, else a short
308
- label built from the input, else the row index."""
306
+ """A traceable link back to the exact eval case: its id/span id, else the row
307
+ index.
308
+
309
+ ⛔ Never the case's ``input`` (MAR-1548): /security says no prompt reaches us.
310
+ """
309
311
  for key in ("id", "span_id", "root_span_id"):
310
312
  val = result.get(key)
311
313
  if isinstance(val, str) and val.strip():
312
314
  return val.strip()[:_MAX_LINK_LEN]
313
- inp = result.get("input")
314
- if isinstance(inp, str) and inp.strip():
315
- return inp.strip()[:_MAX_LINK_LEN]
316
- if isinstance(inp, (dict, list)):
317
- try:
318
- return json.dumps(inp, ensure_ascii=False, sort_keys=True)[:_MAX_LINK_LEN]
319
- except (TypeError, ValueError):
320
- pass
321
315
  return f"braintrust:{index}"
322
316
 
323
317
 
@@ -46,6 +46,8 @@ import functools
46
46
  import json as _json
47
47
  import contextlib
48
48
  import logging
49
+ import contextvars
50
+ import inspect
49
51
  import os
50
52
  import random
51
53
  import time
@@ -623,6 +625,44 @@ def _retry_after_seconds(resp: Any) -> Optional[float]:
623
625
  return None
624
626
 
625
627
 
628
+ _SDK_DIR = os.path.dirname(os.path.realpath(__file__))
629
+
630
+
631
+ #: The calling file, set on the CALLING thread by a wrapper whose metering record may be written
632
+ #: on another one (MAR-1230). litellm writes its success callbacks from a thread pool but runs
633
+ #: them in a copy of the caller's context, so a value set here reaches that thread.
634
+ _SITE_HINT: "contextvars.ContextVar[str]" = contextvars.ContextVar("margin_call_site", default="")
635
+ #: Set as the hint by a callback that has no calling thread to read (MAR-1236): the record then
636
+ #: names no file rather than whatever unrelated frame sits at the bottom of that thread. A
637
+ #: litellm stream, sync or async, is logged after the call has returned, so it names no file.
638
+ _NO_SITE = "\0"
639
+
640
+
641
+ def _call_site() -> str:
642
+ """The file in the working tree that made this call, relative to it (MAR-1227): the
643
+ innermost frame that is neither this package nor an installed library. It rides on a SINK
644
+ line only, next to ``case``, so a harness can split a case's spend by the agent file that
645
+ spent it; ingest never sees it. Empty when no such frame exists."""
646
+ import sys
647
+ hint = _SITE_HINT.get()
648
+ if hint:
649
+ return "" if hint == _NO_SITE else hint
650
+ try:
651
+ root = os.path.realpath(os.getcwd())
652
+ except OSError: # the working directory was removed: no site, but keep the call metered
653
+ return ""
654
+ frame = sys._getframe(1)
655
+ while frame is not None:
656
+ name = frame.f_code.co_filename
657
+ # "<frozen runpy>", "<string>": not files, though realpath would place them in cwd.
658
+ path = "" if name.startswith("<") else os.path.realpath(name)
659
+ if (path.startswith(root + os.sep) and not path.startswith(_SDK_DIR)
660
+ and "site-packages" not in path and "dist-packages" not in path):
661
+ return os.path.relpath(path, root).replace(os.sep, "/")
662
+ frame = frame.f_back
663
+ return ""
664
+
665
+
626
666
  def _backoff_seconds(attempt: int, retry_after: Optional[float] = None) -> float:
627
667
  """FULL jitter — ``random(0, base * 2**attempt)``, capped, or the server's wait.
628
668
 
@@ -1146,8 +1186,23 @@ class MarginMeter:
1146
1186
  def _write_sink(self, path: str, payload: dict) -> IngestResult:
1147
1187
  """Append one record to the JSONL sink for later forwarding. Fail-safe."""
1148
1188
  try:
1189
+ record = {"path": path, "payload": payload}
1190
+ # The pytest case this record was made in (MAR-1207). It rides on the SINK line
1191
+ # only, never in the payload, so a harness can tell a case that called a model
1192
+ # from one that did not; ingest never sees it.
1193
+ case = os.environ.get("PYTEST_CURRENT_TEST") or ""
1194
+ for phase in (" (setup)", " (call)", " (teardown)"):
1195
+ if case.endswith(phase):
1196
+ case = case[: -len(phase)]
1197
+ if case:
1198
+ record["case"] = case
1199
+ # The calling file rides on every sink line (MAR-1230), cased or not, so a face-off
1200
+ # scoped to one agent can prove which of its calls were swapped.
1201
+ site = _call_site()
1202
+ if site:
1203
+ record["site"] = site
1149
1204
  with open(self.ingest_file, "a", encoding="utf-8") as fh:
1150
- fh.write(_json.dumps({"path": path, "payload": payload}) + "\n")
1205
+ fh.write(_json.dumps(record) + "\n")
1151
1206
  except Exception as exc: # a sink write must never break the wrapped call
1152
1207
  self._count(path, ok=False, reason="sink_write")
1153
1208
  result = IngestResult(
@@ -1812,6 +1867,9 @@ class MarginMeter:
1812
1867
  cache_creation_1h_tokens=usage.cache_creation_1h_tokens,
1813
1868
  reasoning_tokens=usage.reasoning_tokens,
1814
1869
  web_search_requests=usage.web_search_requests,
1870
+ # OpenRouter's billed cost for a fan-out slug (MAR-1309); None everywhere
1871
+ # else, which leaves the server to price tokens off its table as before.
1872
+ cost_usd=usage.billed_cost_usd,
1815
1873
  latency_ms=latency_ms,
1816
1874
  status=status,
1817
1875
  is_retry=is_retry,
@@ -2018,6 +2076,9 @@ class MarginMeter:
2018
2076
  # accumulator max-merges it, so forward it or a streamed search
2019
2077
  # drops its flat $10/1,000 charge and under-bills (MAR-499).
2020
2078
  web_search_requests=usage.web_search_requests,
2079
+ # A streamed fan-out's billed cost (MAR-1309), merged off the
2080
+ # final usage chunk; None for every other stream.
2081
+ cost_usd=usage.billed_cost_usd,
2021
2082
  latency_ms=latency_ms,
2022
2083
  status="ok",
2023
2084
  is_retry=is_retry,
@@ -2093,7 +2154,16 @@ class MarginMeter:
2093
2154
  # from the supply chain — even though the host, which awaits the coroutine,
2094
2155
  # keeps working. Detect a coroutine function and return an async wrapper that
2095
2156
  # AWAITS the real response before metering, timing the full await.
2096
- if asyncio.iscoroutinefunction(fn):
2157
+ # ⛔ openai 2.x and anthropic DECORATE their async create (``required_args``), so the
2158
+ # attribute itself is a plain function returning a coroutine and
2159
+ # ``iscoroutinefunction`` says False. Measured 2026-09-25 (MAR-1236): every
2160
+ # AsyncOpenAI chat call and every AsyncAnthropic messages call went unmetered. The
2161
+ # decorators keep ``__wrapped__``, so the real ``async def`` is found by unwrapping.
2162
+ try:
2163
+ real = inspect.unwrap(fn)
2164
+ except ValueError: # a __wrapped__ cycle (mock.call): classify the callable as given
2165
+ real = fn
2166
+ if asyncio.iscoroutinefunction(fn) or inspect.iscoroutinefunction(real):
2097
2167
 
2098
2168
  @functools.wraps(fn)
2099
2169
  async def _awrapped(*args: Any, **kwargs: Any) -> Any:
@@ -56,6 +56,9 @@ _MAX_JUDGE_ID_LEN = 128
56
56
 
57
57
  _VALUE_KEY = "margin_value"
58
58
 
59
+ # Mirror ``src/margin/ingest.MAX_VALUE_USD``: ingest refuses a value past this either way.
60
+ _MAX_VALUE_USD = 10_000_000.0
61
+
59
62
  # ``evaluationModel`` values that name no real model — a deterministic/custom
60
63
  # metric leaves it empty or a placeholder. Normalized lower-case for the check.
61
64
  _NON_MODELS = frozenset({"", "n/a", "na", "none", "null"})
@@ -158,8 +161,9 @@ def value_from_case(case: dict) -> Optional[float]:
158
161
  raise ValueError(f"{_VALUE_KEY} must be a number, got {raw!r}")
159
162
  if not math.isfinite(val):
160
163
  raise ValueError(f"{_VALUE_KEY} must be finite, got {raw!r}")
161
- if val < 0:
162
- raise ValueError(f"{_VALUE_KEY} must be >= 0, got {val!r}")
164
+ # A loss is a real value (MAR-1198): ingest takes any finite amount within +/-MAX.
165
+ if abs(val) > _MAX_VALUE_USD:
166
+ raise ValueError(f"{_VALUE_KEY} implausible (|value| > {_MAX_VALUE_USD}), got {val!r}")
163
167
  return val
164
168
 
165
169
 
@@ -0,0 +1,218 @@
1
+ """Evals from the command line and from MCP (MAR-1167).
2
+
3
+ The console is one door to the eval builder. This module opens two more for developers
4
+ who would rather stay in a terminal or an agent:
5
+
6
+ * ``margin-meter evals draft`` — draft criteria from the project's connected repository
7
+ * ``margin-meter evals list`` — the project's criteria, drafts and accepted
8
+ * ``margin-meter evals pr`` — open a PR carrying the ACCEPTED criteria into the repo
9
+ * ``margin-meter mcp`` — the same three as MCP tools over stdio
10
+
11
+ All of them call the same project-scoped HTTP routes the console calls, authenticated with
12
+ the project's ingest key (``MARGIN_INGEST_KEY``) against ``MARGIN_INGEST_URL``. Nothing here
13
+ can accept a criterion: acceptance needs a named person in a console session, so a PR opened
14
+ from a script still carries only rules a person on the customer's side agreed to.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import sys
23
+ import urllib.error
24
+ import urllib.request
25
+ from typing import Any, Callable, Optional, Sequence
26
+
27
+ DRAFT_PATH = "/api/console/evals/from-repo"
28
+ LIST_PATH = "/api/console/evals"
29
+ PR_PATH = "/api/console/evals/pull-request"
30
+ TIMEOUT_S = 180.0 # a draft reads and scans the whole repo; it is not an ingest call
31
+
32
+ # (method, path, key) -> (status, body). Tests inject a TestClient-backed one.
33
+ Transport = Callable[[str, str, str], "tuple[int, Any]"]
34
+
35
+
36
+ def http_transport(base_url: str) -> Transport:
37
+ base = base_url.rstrip("/")
38
+
39
+ def send(method: str, path: str, key: str) -> "tuple[int, Any]":
40
+ req = urllib.request.Request(
41
+ base + path,
42
+ data=b"{}" if method == "POST" else None,
43
+ method=method,
44
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
45
+ )
46
+ try:
47
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
48
+ raw, status = resp.read(), resp.status
49
+ except urllib.error.HTTPError as exc:
50
+ try:
51
+ raw = exc.read()
52
+ except Exception: # an HTTPError without a body must not mask the status
53
+ raw = b""
54
+ status = exc.code
55
+ except (urllib.error.URLError, OSError) as exc: # unreachable: report, never crash
56
+ raise EvalsError(f"could not reach {base}: {exc}") from exc
57
+ try:
58
+ return status, json.loads(raw or b"null")
59
+ except ValueError:
60
+ return status, raw.decode("utf-8", "replace")
61
+
62
+ return send
63
+
64
+
65
+ class EvalsError(RuntimeError):
66
+ """The API refused; the message is the API's own reason."""
67
+
68
+
69
+ def _call(send: Transport, method: str, path: str, key: str) -> dict:
70
+ status, body = send(method, path, key)
71
+ if status >= 400:
72
+ detail = body.get("detail") if isinstance(body, dict) else body
73
+ raise EvalsError(f"HTTP {status}: {detail}")
74
+ return body if isinstance(body, dict) else {"result": body}
75
+
76
+
77
+ def draft(send: Transport, key: str) -> dict:
78
+ return _call(send, "POST", DRAFT_PATH, key)
79
+
80
+
81
+ def list_criteria(send: Transport, key: str) -> dict:
82
+ return _call(send, "GET", LIST_PATH, key)
83
+
84
+
85
+ def open_pr(send: Transport, key: str) -> dict:
86
+ return _call(send, "POST", PR_PATH, key)
87
+
88
+
89
+ _ACTIONS = {"draft": draft, "list": list_criteria, "pr": open_pr}
90
+
91
+
92
+ def _config(url: Optional[str], key: Optional[str]) -> "tuple[str, str]":
93
+ url = url or os.environ.get("MARGIN_INGEST_URL") or ""
94
+ key = key or os.environ.get("MARGIN_INGEST_KEY") or ""
95
+ if not url or not key:
96
+ raise EvalsError("set MARGIN_INGEST_URL and MARGIN_INGEST_KEY (or pass --url/--key)")
97
+ return url, key
98
+
99
+
100
+ def _print_human(action: str, out: dict) -> None:
101
+ if action == "pr":
102
+ verb = "updated" if out.get("reused") else "opened"
103
+ print(f"{verb} PR #{out.get('number')} on {out.get('repo')}: {out.get('url')}")
104
+ return
105
+ for c in out.get("criteria", []):
106
+ where = ", ".join(c.get("evidence") or []) or "-"
107
+ print(f"[{c.get('label', c.get('status'))}] {c.get('text')} ({where})")
108
+ for h in out.get("harnesses", []) or []:
109
+ print(f"existing harness: {h}")
110
+ if out.get("why"):
111
+ print(out["why"])
112
+
113
+
114
+ def main(argv: Optional[Sequence[str]] = None, send: Optional[Transport] = None) -> int:
115
+ p = argparse.ArgumentParser(prog="margin-meter evals",
116
+ description="Draft, list and ship eval criteria.")
117
+ p.add_argument("action", choices=sorted(_ACTIONS))
118
+ p.add_argument("--url")
119
+ p.add_argument("--key")
120
+ p.add_argument("--json", action="store_true", help="print the raw API response")
121
+ a = p.parse_args(argv)
122
+ try:
123
+ url, key = _config(a.url, a.key)
124
+ out = _ACTIONS[a.action](send or http_transport(url), key)
125
+ except EvalsError as exc:
126
+ print(f"margin-meter evals: {exc}", file=sys.stderr)
127
+ return 1
128
+ if a.json:
129
+ print(json.dumps(out, indent=2))
130
+ else:
131
+ _print_human(a.action, out)
132
+ return 0
133
+
134
+
135
+ # --------------------------------------------------------------------------- #
136
+ # MCP over stdio: newline-delimited JSON-RPC 2.0, the three actions as tools.
137
+ # --------------------------------------------------------------------------- #
138
+ PROTOCOL_VERSION = "2025-06-18"
139
+ _TOOLS = [
140
+ {"name": "margin_evals_draft_from_repo",
141
+ "description": ("Read the project's connected GitHub repository and draft eval criteria "
142
+ "from the instructions its agent is given. Every criterion cites the "
143
+ "path:line it came from and stays a draft until a person accepts it "
144
+ "in the Margin console."),
145
+ "annotations": {"readOnlyHint": False, "destructiveHint": False}},
146
+ {"name": "margin_evals_list",
147
+ "description": "List the project's eval criteria with their status (draft or accepted).",
148
+ "annotations": {"readOnlyHint": True}},
149
+ {"name": "margin_evals_open_pr",
150
+ "description": ("Open (or update) a pull request that adds the project's ACCEPTED "
151
+ "criteria to margin/evals.json in its repository. Refused when nothing "
152
+ "is accepted yet. The customer reviews and merges it."),
153
+ "annotations": {"readOnlyHint": False, "destructiveHint": False}},
154
+ ]
155
+ _TOOL_ACTION = {"margin_evals_draft_from_repo": draft, "margin_evals_list": list_criteria,
156
+ "margin_evals_open_pr": open_pr}
157
+ for _t in _TOOLS:
158
+ _t["inputSchema"] = {"type": "object", "properties": {}, "additionalProperties": False}
159
+
160
+
161
+ def handle(msg: dict, send: Transport, key: str) -> Optional[dict]:
162
+ """One JSON-RPC message → its response (None for a notification)."""
163
+ if not isinstance(msg, dict):
164
+ return {"jsonrpc": "2.0", "id": None,
165
+ "error": {"code": -32600, "message": "invalid request"}}
166
+ if "id" not in msg:
167
+ return None
168
+ mid, method = msg["id"], msg.get("method")
169
+ if method == "initialize":
170
+ result: Any = {"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}},
171
+ "serverInfo": {"name": "margin-evals", "version": "1"}}
172
+ elif method == "tools/list":
173
+ result = {"tools": _TOOLS}
174
+ elif method == "tools/call":
175
+ name = (msg.get("params") or {}).get("name")
176
+ action = _TOOL_ACTION.get(name)
177
+ if action is None:
178
+ return {"jsonrpc": "2.0", "id": mid,
179
+ "error": {"code": -32602, "message": f"unknown tool {name!r}"}}
180
+ try:
181
+ text, err = json.dumps(action(send, key), indent=2), False
182
+ except EvalsError as exc:
183
+ text, err = str(exc), True
184
+ result = {"content": [{"type": "text", "text": text}], "isError": err}
185
+ elif method == "ping":
186
+ result = {}
187
+ else:
188
+ return {"jsonrpc": "2.0", "id": mid,
189
+ "error": {"code": -32601, "message": f"method not found: {method}"}}
190
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
191
+
192
+
193
+ def serve(argv: Optional[Sequence[str]] = None) -> int:
194
+ p = argparse.ArgumentParser(prog="margin-meter mcp",
195
+ description="Margin's eval tools as an MCP server on stdio.")
196
+ p.add_argument("--url")
197
+ p.add_argument("--key")
198
+ a = p.parse_args(argv)
199
+ try:
200
+ url, key = _config(a.url, a.key)
201
+ except EvalsError as exc:
202
+ print(f"margin-meter mcp: {exc}", file=sys.stderr)
203
+ return 1
204
+ send = http_transport(url)
205
+ for line in sys.stdin:
206
+ if not line.strip():
207
+ continue
208
+ try:
209
+ msg = json.loads(line)
210
+ except ValueError:
211
+ out: Optional[dict] = {"jsonrpc": "2.0", "id": None,
212
+ "error": {"code": -32700, "message": "parse error"}}
213
+ else:
214
+ out = handle(msg, send, key)
215
+ if out is not None:
216
+ sys.stdout.write(json.dumps(out) + "\n")
217
+ sys.stdout.flush()
218
+ return 0