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.
- digline_mcp-0.1.0/.gitignore +45 -0
- digline_mcp-0.1.0/PKG-INFO +99 -0
- digline_mcp-0.1.0/README.md +82 -0
- digline_mcp-0.1.0/pyproject.toml +44 -0
- digline_mcp-0.1.0/src/digline_mcp/__main__.py +11 -0
- digline_mcp-0.1.0/src/digline_mcp/descriptions.py +111 -0
- digline_mcp-0.1.0/src/digline_mcp/errors.py +68 -0
- digline_mcp-0.1.0/src/digline_mcp/server.py +279 -0
- digline_mcp-0.1.0/tests/conftest.py +39 -0
- digline_mcp-0.1.0/tests/test_boundary.py +174 -0
- digline_mcp-0.1.0/tests/test_errors.py +96 -0
- digline_mcp-0.1.0/tests/test_isolation.py +102 -0
- digline_mcp-0.1.0/tests/test_playbook.py +105 -0
- digline_mcp-0.1.0/tests/test_stdio.py +94 -0
- digline_mcp-0.1.0/tests/test_surface.py +93 -0
- digline_mcp-0.1.0/tests/test_tools.py +282 -0
|
@@ -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)
|