digline-mcp 0.1.0__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.
@@ -0,0 +1,45 @@
1
+ # Python bytecode and build output
2
+ __pycache__/
3
+ *.py[cod]
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+
8
+ # Environment. Recreated by `uv sync`; it pins absolute paths, so it must
9
+ # never be committed.
10
+ .venv/
11
+ .env
12
+
13
+ # Tool caches. Each already drops its own `.gitignore`; listed here so a
14
+ # fresh clone is clean before the tools have run once.
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+
19
+ # IDE. Excluded because the project files carry machine-specific SDK paths
20
+ # (`digline.iml` names the interpreter by absolute path). Drop these two lines
21
+ # to version the shared part, and keep ignoring `.idea/workspace.xml`.
22
+ .idea/
23
+ *.iml
24
+
25
+ # Local Claude Code settings. `.claude/settings.json`, if it appears, is shared
26
+ # and stays versioned. `CLAUDE.local.md` is the personal working agreement —
27
+ # how I want to be worked with — as against `CLAUDE.md`, which is the project.
28
+ .claude/settings.local.json
29
+ CLAUDE.local.md
30
+
31
+ # macOS
32
+ .DS_Store
33
+
34
+ # Working material that stays local and is not part of the package.
35
+ private/
36
+
37
+ # NOT ignored: `.digline/`. Decision 2 — baselines are versioned, run
38
+ # artifacts are not — and the split is enforced one level down, by the
39
+ # `.gitignore` the store itself writes into `.digline/` (`*/runs/`).
40
+ # Ignoring `.digline/` here would take the baselines out of git with it.
41
+ to-publish/
42
+
43
+ # The site checkout that ci.yml's `docs` job makes, and that RELEASING
44
+ # tells you to make to reproduce it. Never committed here.
45
+ _site/
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.5
2
+ Name: digline-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for digline: an agent can read a result and measure one, and cannot promote a baseline.
5
+ Author-email: Alessandro Prandini <alessandro.prandini@ict-group.it>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Topic :: Software Development :: Testing
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: digline>=0.6.0
15
+ Requires-Dist: mcp<3,>=2.2
16
+ Description-Content-Type: text/markdown
17
+
18
+ # digline-mcp
19
+
20
+ The [MCP](https://modelcontextprotocol.io) server for
21
+ [digline](https://github.com/digline/digline): it lets a coding agent **read** a
22
+ digline result and **measure** a new one, and it does not let the agent promote
23
+ a baseline.
24
+
25
+ Not because promotion is refused. Because there is no such tool.
26
+
27
+ ## The six tools
28
+
29
+ | tool | what it does |
30
+ |---|---|
31
+ | `list_runs` | every stored run of a suite, newest first, with the baseline marked |
32
+ | `get_run` | one stored run — the verdicts, never the payload |
33
+ | `get_baseline` | the approved reference, same shape |
34
+ | `compare` | a run against the baseline: did it get worse? |
35
+ | `diff` | two runs, neither of them a reference: should I switch? |
36
+ | `run` | execute the suite, with the call count acknowledged first |
37
+
38
+ `promote` is **absent by construction**, and so are `migrate`, `view` and
39
+ `report`. A baseline is an *approved reference*, and `promote` writes into
40
+ `.digline/<tenant>/baselines/`, which is committed — anything landing there
41
+ arrives in somebody's diff and must arrive because they put it there. A refusal
42
+ would be a conversation an agent could argue with; an absence is not.
43
+
44
+ The reasoning is [ADR 0011](../../docs/adr/0011-the-mcp-server.md), and the
45
+ judgement layer it turns into surface is [`AGENTS.md`](../../AGENTS.md).
46
+
47
+ ## Install and configure
48
+
49
+ ```bash
50
+ uv pip install digline-mcp
51
+ ```
52
+
53
+ One server serves **one repository**: the perimeter is the repo, and
54
+ multi-project is N named servers rather than a registry.
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "digline-northwind": {
60
+ "command": "digline-mcp",
61
+ "args": ["--root", "/Users/you/src/northwind"]
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ `--tenant` and `--env` are accepted and **verify**; they never override. The
68
+ suite decides, as it does everywhere else in digline.
69
+
70
+ ## What `run` costs, before it costs it
71
+
72
+ `run` takes a mandatory `acknowledge_calls` that must equal the suite's planned
73
+ calls to the target. Called without it, the tool refuses **and hands back the
74
+ number**:
75
+
76
+ ```
77
+ run(suite="suite.py")
78
+ → refused: this suite plans 100 calls to the target.
79
+ 20 cases × 5 samples = 100 calls to the target; each answer is judged
80
+ 3 times by llm_rubric.
81
+ Call again with acknowledge_calls=100.
82
+
83
+ run(suite="suite.py", acknowledge_calls=100) → executes
84
+ ```
85
+
86
+ The first call is the probe. An agent cannot spend a hundred model calls without
87
+ having stated the number — `AGENTS.md` §7 as a contract rather than as advice.
88
+
89
+ ## What never crosses
90
+
91
+ An MCP response goes into a model's context, and from there into transcripts and
92
+ caches nobody controls. So the run tools return a **verdict-only projection**:
93
+ the name, the identity, the status, the score, the threshold, the tolerance, the
94
+ measured interval, and the metadata a suite disclosed.
95
+
96
+ Never the judge's `reason` — the judge quotes the output, so the reason *is* the
97
+ output. Never the sentence explaining a suspension, never undisclosed metadata,
98
+ never a case's inputs, and never a prompt or its digest unless the suite
99
+ declares `Disclosure(artifacts=True)`.
@@ -0,0 +1,82 @@
1
+ # digline-mcp
2
+
3
+ The [MCP](https://modelcontextprotocol.io) server for
4
+ [digline](https://github.com/digline/digline): it lets a coding agent **read** a
5
+ digline result and **measure** a new one, and it does not let the agent promote
6
+ a baseline.
7
+
8
+ Not because promotion is refused. Because there is no such tool.
9
+
10
+ ## The six tools
11
+
12
+ | tool | what it does |
13
+ |---|---|
14
+ | `list_runs` | every stored run of a suite, newest first, with the baseline marked |
15
+ | `get_run` | one stored run — the verdicts, never the payload |
16
+ | `get_baseline` | the approved reference, same shape |
17
+ | `compare` | a run against the baseline: did it get worse? |
18
+ | `diff` | two runs, neither of them a reference: should I switch? |
19
+ | `run` | execute the suite, with the call count acknowledged first |
20
+
21
+ `promote` is **absent by construction**, and so are `migrate`, `view` and
22
+ `report`. A baseline is an *approved reference*, and `promote` writes into
23
+ `.digline/<tenant>/baselines/`, which is committed — anything landing there
24
+ arrives in somebody's diff and must arrive because they put it there. A refusal
25
+ would be a conversation an agent could argue with; an absence is not.
26
+
27
+ The reasoning is [ADR 0011](../../docs/adr/0011-the-mcp-server.md), and the
28
+ judgement layer it turns into surface is [`AGENTS.md`](../../AGENTS.md).
29
+
30
+ ## Install and configure
31
+
32
+ ```bash
33
+ uv pip install digline-mcp
34
+ ```
35
+
36
+ One server serves **one repository**: the perimeter is the repo, and
37
+ multi-project is N named servers rather than a registry.
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "digline-northwind": {
43
+ "command": "digline-mcp",
44
+ "args": ["--root", "/Users/you/src/northwind"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ `--tenant` and `--env` are accepted and **verify**; they never override. The
51
+ suite decides, as it does everywhere else in digline.
52
+
53
+ ## What `run` costs, before it costs it
54
+
55
+ `run` takes a mandatory `acknowledge_calls` that must equal the suite's planned
56
+ calls to the target. Called without it, the tool refuses **and hands back the
57
+ number**:
58
+
59
+ ```
60
+ run(suite="suite.py")
61
+ → refused: this suite plans 100 calls to the target.
62
+ 20 cases × 5 samples = 100 calls to the target; each answer is judged
63
+ 3 times by llm_rubric.
64
+ Call again with acknowledge_calls=100.
65
+
66
+ run(suite="suite.py", acknowledge_calls=100) → executes
67
+ ```
68
+
69
+ The first call is the probe. An agent cannot spend a hundred model calls without
70
+ having stated the number — `AGENTS.md` §7 as a contract rather than as advice.
71
+
72
+ ## What never crosses
73
+
74
+ An MCP response goes into a model's context, and from there into transcripts and
75
+ caches nobody controls. So the run tools return a **verdict-only projection**:
76
+ the name, the identity, the status, the score, the threshold, the tolerance, the
77
+ measured interval, and the metadata a suite disclosed.
78
+
79
+ Never the judge's `reason` — the judge quotes the output, so the reason *is* the
80
+ output. Never the sentence explaining a suspension, never undisclosed metadata,
81
+ never a case's inputs, and never a prompt or its digest unless the suite
82
+ declares `Disclosure(artifacts=True)`.
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "digline-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for digline: an agent can read a result and measure one, and cannot promote a baseline."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [
9
+ { name = "Alessandro Prandini", email = "alessandro.prandini@ict-group.it" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Software Development :: Testing",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ # 0.6.0 is the release that first publishes `digline.wire` and
21
+ # `digline.host`, and `core.diff` with them. Held there by
22
+ # `tests/test_plugin_floors.py`, which computes the floor from the newest
23
+ # name these sources import rather than taking this number on trust.
24
+ "digline>=0.6.0",
25
+ # Both halves matter. Unpinned, a resolver could hand somebody 1.x, where
26
+ # `MCPServer` does not exist and `mcp.server.fastmcp` raises on import: the
27
+ # 2.0 release renamed FastMCP, and every example written before it is wrong.
28
+ # The cap is the same lesson the examples' caps gate encodes — a major
29
+ # version that renames the entry point is not hypothetical here, it happened.
30
+ "mcp>=2.2,<3",
31
+ ]
32
+
33
+ [project.scripts]
34
+ digline-mcp = "digline_mcp.server:main"
35
+
36
+ [build-system]
37
+ requires = ["hatchling>=1.27"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/digline_mcp"]
42
+
43
+ [tool.uv.sources]
44
+ digline = { workspace = true }
@@ -0,0 +1,11 @@
1
+ """`python -m digline_mcp`, the same entry point as the `digline-mcp` script.
2
+
3
+ Both exist because a client's MCP config may name either: a console script is
4
+ tidier, and `python -m` is what somebody reaches for when the script is not on
5
+ the PATH of whatever launched the process.
6
+ """
7
+
8
+ from digline_mcp.server import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
1
+ """The playbook, where the model that is about to call a tool will read it.
2
+
3
+ A tool description is not `--help`. It is loaded into the context of the model
4
+ deciding whether to call the tool, at the moment it decides — which makes it the
5
+ one place `AGENTS.md` reaches an agent that never read `AGENTS.md`.
6
+
7
+ So each description carries the rule that governs its own misuse. The agent that
8
+ loads the tools receives the discipline with the instrument. (ADR 0011 §9)
9
+
10
+ `tests/test_playbook.py` checks each of these against `AGENTS.md` itself, the
11
+ way `tests/test_agents.py` checks the shipped skill: a rule reworded in one
12
+ place cannot stay stale in the other two.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__ = ["DESCRIPTIONS"]
18
+
19
+ LIST_RUNS = """\
20
+ Every stored run of a suite, newest first, with the baseline marked.
21
+
22
+ This is the table you choose a run from, and choosing is the point: promote from
23
+ the middle of several, never from the first one that goes green. A baseline
24
+ freezes one run, and the first green run is green partly on merit and partly on
25
+ luck — a case recorded at 0.667 where three runs out of three say 1.000 becomes
26
+ a red line nobody can explain a fortnight later.
27
+
28
+ You cannot promote from here and there is no tool that can. Assemble the
29
+ evidence, name the run whose per-case profile is closest to typical, and
30
+ recommend it; the human runs `digline promote`, or tells you to.
31
+
32
+ If `skipped` or `note` is non-empty, stored runs were written under an older
33
+ schema and this listing does not show them. Say so, and propose
34
+ `digline migrate` — do not run it."""
35
+
36
+ GET_RUN = """\
37
+ One stored run: its verdicts, its measured intervals, and the configuration that
38
+ produced it.
39
+
40
+ Several cases flipping together in one run is a different event from one case
41
+ moving. It is either a real regression or the judge itself moving, and both are
42
+ findings — read the run, name the cases, and look at what they have in common.
43
+ Do not retry it: retrying until it passes destroys the evidence either way.
44
+
45
+ The judge's reason is not here and cannot be. A reason quotes the output, so it
46
+ is the output, and it stays inside the perimeter it was measured in."""
47
+
48
+ GET_BASELINE = """\
49
+ The approved reference for this suite: the run a human decided to hold the
50
+ others against.
51
+
52
+ Same shape as get_run, and the same boundary. If the suite has no baseline yet,
53
+ that is not an error — it is the first round, and what it needs is a person."""
54
+
55
+ COMPARE = """\
56
+ A run against the baseline. Answers: did it get worse?
57
+
58
+ `exit_code` is the contract. 0 proceed. 1 stop and report what got worse. 2 stop
59
+ — the run could not be judged, and nothing downstream of it is meaningful,
60
+ including any conclusion you were about to draw from the green checks beside it.
61
+
62
+ A movement inside the baseline's measured interval is reported as `unchanged`,
63
+ with `within_noise` on the delta. That explains a comparison and excuses
64
+ nothing: an absolute threshold still gates, a flip from passing to failing is
65
+ never rescued by noise, and an exit code of 1 is never argued away by quoting an
66
+ interval. If you find yourself writing "but this is within noise" about a red
67
+ run, you are arguing with the instrument.
68
+
69
+ A dip that does not recur on re-run is sampling noise: document it and move on.
70
+ Decide the number of re-runs before running them — with a stochastic judge,
71
+ enough re-runs always produce a green one, and a stopping rule chosen after the
72
+ fact measures your patience rather than the system."""
73
+
74
+ DIFF = """\
75
+ Two runs, neither of them a reference. Answers: should I switch?
76
+
77
+ Prompt A against prompt B, one model against another, temperature 0.3 against
78
+ 0.7. This is a report and never a verdict, so there is **no `worse` field and no
79
+ exit code** — a verdict exists only against an approved reference, and neither
80
+ side of a diff was approved by anybody. Do not synthesise one.
81
+
82
+ Where both sides were sampled, each check carries the two measured intervals.
83
+ Overlapping intervals mean the two are not distinguishable by that check. That
84
+ is evidence beside the count, never an excuse: a diff has no baseline, so no
85
+ interval has the standing to overrule a difference."""
86
+
87
+ RUN = """\
88
+ Execute the suite against its target and store the result.
89
+
90
+ **This spends money.** `acknowledge_calls` must equal the suite's planned calls
91
+ to the target; call without it once and the refusal tells you the number. That
92
+ number counts calls to the target only — where an assertion judges each answer
93
+ several times, the returned `sentence` names the multiplier, and the honest
94
+ figure you report is the whole sentence.
95
+
96
+ Before proposing a hunt that means several runs, multiply that line by the
97
+ number of runs and say the figure out loud in your recommendation. Five runs
98
+ over a hundred-call suite is five hundred model calls, and that is a decision
99
+ for whoever pays for them.
100
+
101
+ Decide the number of re-runs before running them, and stop at the signal. Never
102
+ keep rolling until the answer looks right."""
103
+
104
+ DESCRIPTIONS: dict[str, str] = {
105
+ "list_runs": LIST_RUNS,
106
+ "get_run": GET_RUN,
107
+ "get_baseline": GET_BASELINE,
108
+ "compare": COMPARE,
109
+ "diff": DIFF,
110
+ "run": RUN,
111
+ }
@@ -0,0 +1,68 @@
1
+ """digline's refusals, translated so they survive the boundary.
2
+
3
+ The SDK is bimodal about this and the difference is total. A `ToolError`
4
+ reaches the caller with its message, as a `CallToolResult` carrying the text and
5
+ `is_error`. **Anything else is wrapped and its message is discarded** — a custom
6
+ exception arrives as the bare string "Error executing tool <name>".
7
+
8
+ So every carefully written refusal in digline — the perimeter messages, the
9
+ three promotion conditions, "no runs stored for suite … run it first" — would
10
+ reach an agent as five identical words unless it is translated here. That is the
11
+ whole reason this module exists, and `tests/test_errors.py` is parametrized over
12
+ the list so a new exception type added to digline without a translation fails
13
+ here rather than in front of somebody.
14
+
15
+ Nothing else is caught. An unexpected exception should stay an unexpected
16
+ exception: dressing one as a tool result hides a bug behind a sentence.
17
+ (ADR 0011 §10)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable
23
+ from functools import wraps
24
+ from typing import NoReturn
25
+
26
+ from mcp.server.mcpserver.exceptions import ToolError
27
+
28
+ from digline.core import DifferentJudgesError, DifferentSuitesError
29
+ from digline.host import UsageError
30
+ from digline.store import ConfigMismatchError, ErroredRunError, TenantMismatchError
31
+ from digline.targets import ProviderNotFound
32
+
33
+ __all__ = ["TRANSLATED", "translated"]
34
+
35
+ #: The exceptions digline raises deliberately, each carrying a message written
36
+ #: for a reader. Anything not in here is a bug and travels as one.
37
+ TRANSLATED: tuple[type[Exception], ...] = (
38
+ UsageError,
39
+ TenantMismatchError,
40
+ ConfigMismatchError,
41
+ ErroredRunError,
42
+ DifferentSuitesError,
43
+ DifferentJudgesError,
44
+ ProviderNotFound,
45
+ )
46
+
47
+
48
+ # PEP 695 syntax (Python 3.12+): `[**P, R]` declares the type parameters inline
49
+ # instead of the older module-level `ParamSpec`/`TypeVar` pair. `**P` is the
50
+ # parameter *list* of the wrapped function, so the decorator keeps each tool's
51
+ # real signature — which is what the SDK reads to build the input schema.
52
+ def translated[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
53
+ """Re-raise digline's own refusals as `ToolError`, message intact."""
54
+
55
+ @wraps(fn)
56
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
57
+ try:
58
+ return fn(*args, **kwargs)
59
+ except TRANSLATED as exc:
60
+ raise ToolError(str(exc)) from exc
61
+
62
+ return wrapper
63
+
64
+
65
+ def refuse(message: str) -> NoReturn:
66
+ """A refusal this server itself makes, in the one shape that reaches an
67
+ agent."""
68
+ raise ToolError(message)