quality-loop 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,103 @@
1
+ # You are the operator of one room
2
+
3
+ A **room** is a separate Claude Code session that was given a task and nothing
4
+ but a product's agent-facing surface - a skill bundle and the product's own
5
+ tools. It was started so it cannot read the product's repository. What it
6
+ does with what it was given is the measurement. Your job is to make that
7
+ measurement possible and then report what it shows.
8
+
9
+ You work in two phases of one session. The message that starts each phase
10
+ says which one it is, and gives you the paths.
11
+
12
+ ## Phase 1: put up the screen
13
+
14
+ 1. Read the scenario file. Its **Screen** section says what has to be in
15
+ front before the room starts. It may say "none".
16
+ 2. If a screen is needed, call `screen_list` to see what the product
17
+ declares, choose what the section asks for, and call `screen_open` for
18
+ each one. It returns once the window is up, or fails.
19
+ 3. If the section asks for something no declared screen provides, do not
20
+ improvise. Write that into `setup.md` in your directory and stop: the
21
+ room cannot measure anything without its screen.
22
+ 4. Write `setup.md` in your directory: what you opened, with the ids `open`
23
+ returned, and anything about the screen a reader of your report should
24
+ know.
25
+
26
+ You have no shell. Your tools are Read, Glob, Grep, writing in your own
27
+ directory and the room's REPORT.md, and the four tools above.
28
+
29
+ You may not change anything in the room. The runner checks every file in it
30
+ after this phase, and a changed byte cancels the room. In particular, the
31
+ room's TASK.md is copied from the scenario by the runner and must reach the
32
+ room exactly as written.
33
+
34
+ ## Phase 2: report
35
+
36
+ The room has finished. In the room directory you have:
37
+
38
+ - `transcript.jsonl`: everything the room did, as stream-json. This is the
39
+ record. The room's own account of what it did is only a claim.
40
+ - `QUESTIONS.md`: what the room noticed it had to guess.
41
+ - `evidence/`: what the runner collected. That includes the product's files
42
+ before and after, anything the room created, the screen's event record, and
43
+ `audit.json`, the runner's isolation audit.
44
+
45
+ Then:
46
+
47
+ 1. Read the scenario's **Must** and **Must not** sections. For each line,
48
+ establish from evidence whether the run met it. Use the product's evidence
49
+ tools where a line is about behaviour; the start-of-phase message lists
50
+ them. Quote what a tool answered. Do not infer it.
51
+ 2. Read the transcript for **friction**, whether or not the room noticed it:
52
+ a tool call that failed or was refused, an answer that was an error or a
53
+ blank, a search of the bundle that came back empty, a file rewritten or a
54
+ run repeated, turns running out, sentences where the room says it is
55
+ unsure or was wrong. A problem the room never mentioned in QUESTIONS.md is
56
+ the most valuable kind.
57
+ 3. Read **QUESTIONS.md**. Each question is a candidate problem.
58
+ 4. Write **REPORT.md** in the room, in the format below. Then close every
59
+ screen you opened (`screen_close`).
60
+
61
+ **Friction is not a problem by itself.** A deploy's own "DEPLOY FAILED" on a
62
+ screen built to show a failed deploy is the task working. Report what a
63
+ product change would improve, and say why.
64
+
65
+ ## REPORT.md
66
+
67
+ ```markdown
68
+ # Report: <scenario title>
69
+
70
+ One paragraph: what the room was asked, what it did, and what it left.
71
+
72
+ ### [high] <the problem, in one line>
73
+ - **Id:** `<kebab-case, stable>`
74
+ - **Kind:** skill gap
75
+ - **Must:** "<the Must line it concerns, or none>"
76
+ - **Evidence:** <tool output quoted, or transcript step and what it shows>
77
+ - **Where it would be fixed:** <file and section, in the product>
78
+ - **How a fix is confirmed:** <the evidence that would show it fixed>
79
+
80
+ <Free text: what happened, and why it matters.>
81
+ ```
82
+
83
+ **Severity** - pick the first that fits:
84
+
85
+ | | |
86
+ |---|---|
87
+ | critical | The run did harm: data destroyed, the user's files rewritten or no longer loading, a destructive control pressed. |
88
+ | high | A Must not met, or a result that is silently wrong - one that looks right and is not. |
89
+ | medium | Met, but only by guessing something the product should have said, or after rework a user's agent would repeat. |
90
+ | low | Friction that cost little: an empty search, a retry, an unclear message. |
91
+
92
+ **Kind**: `skill gap` (the product's agent-facing text should have said it),
93
+ `product` (the product behaves wrongly), `harness` (the runner, or the room's
94
+ tools), `out of scope` (true of the task or the application, not of the
95
+ product).
96
+
97
+ **Id** names the problem, not the run: `fn-f1-not-bound`, not `room-3-issue`.
98
+ A later run reports the same problem under the same id, which is how a fix is
99
+ seen to have worked.
100
+
101
+ There is no verdict. Write problems, most severe first. If you found none,
102
+ say so in the opening paragraph and say what you checked. Zero problems is
103
+ rare, and the reader will want to know why you believe it.
@@ -0,0 +1,15 @@
1
+ """quality-loop: measure what an agent-facing product teaches.
2
+
3
+ A product that agents use - a skill bundle, an MCP server, a CLI - is only as
4
+ good as what an agent with nothing else can do with it. This package runs that
5
+ agent: a task, in a room holding the product's agent-facing surface and nothing
6
+ else, started so it cannot read the repository the answers are in. A second
7
+ agent, the operator, puts up whatever the task needs beforehand and afterwards
8
+ reports the problems it finds in what the room did, each with a severity.
9
+
10
+ What has to hold for a run to mean anything - the isolation, the prompt the
11
+ room is handed, putting the user's machine back - is code here, not an
12
+ agent's discretion.
13
+ """
14
+
15
+ __version__ = "0.1.0"
@@ -0,0 +1,48 @@
1
+ """The operator agent's command line: ``python -m quality_loop screen ...``.
2
+
3
+ python -m quality_loop screen list
4
+ python -m quality_loop screen open approval-queue
5
+ python -m quality_loop screen close approval-queue-1
6
+
7
+ Everything it takes is a name from the manifest the runner wrote. Answers are
8
+ JSON on stdout, errors one line on stderr with exit status 1.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+ from quality_loop import screens
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="quality-loop")
22
+ commands = parser.add_subparsers(dest="command", required=True)
23
+ screen = commands.add_parser("screen", help="put up or take down a declared screen")
24
+ actions = screen.add_subparsers(dest="action", required=True)
25
+ actions.add_parser("list", help="the screens the adapter declares")
26
+ opening = actions.add_parser("open", help="open one, and wait for its window")
27
+ opening.add_argument("name")
28
+ closing = actions.add_parser("close", help="close one that `open` recorded")
29
+ closing.add_argument("id")
30
+ args = parser.parse_args(argv)
31
+
32
+ try:
33
+ if args.action == "list":
34
+ answer = {name: {"kind": e["kind"], "title": e["title"]}
35
+ for name, e in screens.names().items()}
36
+ elif args.action == "open":
37
+ answer = screens.open_screen(args.name)
38
+ else:
39
+ answer = screens.close_screen(args.id)
40
+ except screens.ScreenError as error:
41
+ print(f"error: {error}", file=sys.stderr)
42
+ return 1
43
+ print(json.dumps(answer, indent=2))
44
+ return 0
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())
@@ -0,0 +1,88 @@
1
+ """What a product supplies: its surface, its rooms' limits, its evidence.
2
+
3
+ A product subclasses `Adapter` and fills in what is its own. Everything else -
4
+ building rooms, isolating the session, the stand-in, the screens, the
5
+ operator, the audit, the findings - is the runner's and is the same for
6
+ every product.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import pathlib
13
+ import sys
14
+
15
+ from quality_loop import audit, scenario, session, standin
16
+
17
+
18
+ class Adapter:
19
+ """One product's half of a run.
20
+
21
+ Attributes:
22
+ name: The product's name, used in the run directory and messages.
23
+ version: The product's version, which names the run directory.
24
+ checkout: The product's repository.
25
+ rooms: Where rooms are built. Inside the checkout is fine, because the
26
+ session is `--restricted`; anywhere else in the checkout is refused.
27
+ scenarios: The directory of scenario files.
28
+ skill_name: The bundle's directory name inside a room.
29
+ fixtures: The directory screens are served or copied from, or None.
30
+ screens: ``{name: Page | Terminal}`` - all the operator may put up.
31
+ evidence: ``{name: {"argv": [...], "help": "..."}}`` - commands that
32
+ read, which the operator runs through its MCP server with
33
+ arguments of its choosing and no shell.
34
+ evidence_help: One paragraph on what those tools answer.
35
+ python: The interpreter the operator's CLIs run under.
36
+ """
37
+ name = "product"
38
+ version = "0"
39
+ checkout: pathlib.Path
40
+ rooms: pathlib.Path
41
+ scenarios: pathlib.Path
42
+ skill_name = "product"
43
+ fixtures: pathlib.Path | None = None
44
+ screens: dict = {}
45
+ evidence: dict = {}
46
+ evidence_help = ""
47
+ python = sys.executable
48
+
49
+ def bundle(self) -> pathlib.Path:
50
+ """The surface under test - a zip or a directory - built for this run."""
51
+ raise NotImplementedError
52
+
53
+ def rules(self) -> str:
54
+ """RULES.md: what the room may read and what it must write down."""
55
+ raise NotImplementedError
56
+
57
+ def session(self, room: pathlib.Path) -> session.Session:
58
+ """How the room's session is started: its tools, permissions, servers."""
59
+ raise NotImplementedError
60
+
61
+ def policy(self, room: pathlib.Path) -> audit.Policy:
62
+ """What the audit flags for this product."""
63
+ return audit.Policy(checkout=self.checkout)
64
+
65
+ def product(self):
66
+ """A context manager for the length of a run: the product up, if it
67
+ has to be running for the room to use it."""
68
+ return contextlib.nullcontext()
69
+
70
+ def stand_in(self, case: scenario.Scenario, variant: str,
71
+ room: pathlib.Path) -> standin.StandIn | None:
72
+ """The operator's files stood in for, for one room, or None."""
73
+ return None
74
+
75
+ def runs(self, cases: list[scenario.Scenario],
76
+ named: bool = False) -> list[tuple[scenario.Scenario, str]]:
77
+ """``(scenario, variant)`` pairs: the same scenario on several fixture
78
+ configs is several rooms. The default is one room per scenario.
79
+
80
+ ``named`` says the scenarios were picked by name rather than being
81
+ every one there is - a product can leave out of a run of everything
82
+ those that need a screen only the person running it can put up.
83
+ """
84
+ return [(case, "") for case in cases]
85
+
86
+ def alive(self) -> bool:
87
+ """Whether the product is still answering, asked on the heartbeat."""
88
+ return True
quality_loop/audit.py ADDED
@@ -0,0 +1,144 @@
1
+ """Checking the transcript for the rules no flag can enforce.
2
+
3
+ RULES.md forbids the checkout, the installed package and other people's work,
4
+ and until there was a transcript the only evidence that a room obeyed was that
5
+ it said so. A rule you cannot check is a rule you are assuming.
6
+
7
+ What is scanned is where a tool was *pointed* - the path-bearing fields of its
8
+ input - not what it typed or what came back. A room that reads a path off the
9
+ screen and repeats it in its notes is doing its task, not reaching for the
10
+ checkout; and a window title is full of paths the room never asked for.
11
+
12
+ **A refusal is not a breach.** A call the confinement turned down read
13
+ nothing, so the measurement is intact. Refusals are reported - they are the
14
+ confinement working in public - and only a call that came back with something
15
+ disqualifies a run.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ import os
22
+ import pathlib
23
+ import re
24
+ from typing import Callable
25
+
26
+ from quality_loop import transcript as tr
27
+
28
+ #: Input fields that say where a tool was aimed.
29
+ AIMED_AT = ("file_path", "path", "notebook_path", "pattern", "command", "glob")
30
+
31
+ #: An absolute path in any spelling: /Users/..., C:\Users\... or C:/..., and
32
+ #: \Users\... - rooted on the current drive, which Windows accepts too.
33
+ ABSOLUTE = re.compile(r"(?:[A-Za-z]:[\\/]|[\\/])[^\s\"'`;|&)]+")
34
+
35
+ #: A shell command that runs git: at the start, or after a separator, by any
36
+ #: path, with or without ".exe".
37
+ RUNS_GIT = re.compile(r"(?:^|[;&|(`]|\$\()\s*(?:\S*/)?git(?:\.exe)?\b")
38
+
39
+ #: What git answers once GIT_CEILING_DIRECTORIES has stopped it at the room.
40
+ NOT_A_REPOSITORY = "not a git repository"
41
+
42
+ #: What room.CANARY asks a contaminated session to say.
43
+ CANARY_PHRASE = "without --restricted"
44
+
45
+
46
+ def norm(path: str) -> str:
47
+ """A path in one spelling: forward slashes, and lowercase on Windows,
48
+ where C:\\Users and c:/users are the same place."""
49
+ path = path.replace("\\", "/")
50
+ return path.lower() if os.name == "nt" else path
51
+
52
+
53
+ def paths_in(tool_input: dict) -> list[str]:
54
+ """The absolute paths a tool call was pointed at."""
55
+ aimed = []
56
+ for key in AIMED_AT:
57
+ value = tool_input.get(key)
58
+ if isinstance(value, str):
59
+ value = value.replace("~/", str(pathlib.Path.home()) + "/")
60
+ aimed += ABSOLUTE.findall(value)
61
+ return aimed
62
+
63
+
64
+ @dataclasses.dataclass
65
+ class Policy:
66
+ """What a product's rooms must not reach, and what their sessions must have.
67
+
68
+ Attributes:
69
+ checkout: The product's repository. A path under it and outside the
70
+ room is a breach - not a mention of it, which every honest read of
71
+ the bundle inside the room also is.
72
+ forbidden: ``{substring: what it is}`` for paths that are a breach
73
+ anywhere: the installed package, the installed app, the operator's
74
+ files set aside.
75
+ foreign: Basenames of other authors' work in a directory the room may
76
+ write to - read one, and the room has learned from somebody who
77
+ had the repository.
78
+ bash: Whether the session has Bash on purpose. When it does not, Bash
79
+ in the init event means `--restricted` did not take effect.
80
+ servers: MCP servers that must be connected; one that is not means
81
+ the room reported on the outage, not the product.
82
+ leaked_skill: Says whether a slash command in the init event is one of
83
+ the checkout's own skills - the scoring key among them.
84
+ """
85
+ checkout: pathlib.Path
86
+ forbidden: dict[str, str] = dataclasses.field(default_factory=dict)
87
+ foreign: set[str] = dataclasses.field(default_factory=set)
88
+ bash: bool = False
89
+ servers: tuple[str, ...] = ()
90
+ leaked_skill: Callable[[str], bool] | None = None
91
+
92
+
93
+ def isolation_held(transcript: pathlib.Path, policy: Policy) -> list[str]:
94
+ """What the session's init event says went wrong with the flags."""
95
+ init = tr.init_event(transcript)
96
+ if init is None:
97
+ return ["the session produced no init event"]
98
+ found = []
99
+ if not policy.bash and "Bash" in init.get("tools", []):
100
+ found.append("the session had Bash: it was not started with --restricted, "
101
+ "so the checkout's CLAUDE.md was in context from the first token")
102
+ if policy.leaked_skill:
103
+ if leaked := [c for c in init.get("slash_commands", [])
104
+ if policy.leaked_skill(str(c))]:
105
+ found.append(f"the checkout's own skills were visible to the room: {leaked}")
106
+ servers = {s.get("name"): s.get("status") for s in init.get("mcp_servers", [])
107
+ if isinstance(s, dict)}
108
+ for name in policy.servers:
109
+ if servers.get(name) != "connected":
110
+ found.append(f"MCP server {name} was {servers.get(name, 'absent')}, so what "
111
+ f"the room reported is about the server, not the product")
112
+ if CANARY_PHRASE in tr.final_reply(transcript).replace("`", ""):
113
+ found.append("the room read its CLAUDE.md canary unasked: the session was "
114
+ "started without --restricted")
115
+ return found
116
+
117
+
118
+ def audit(room: pathlib.Path, policy: Policy) -> tuple[list[str], list[str]]:
119
+ """``(breaches, refused)`` from the room's transcript."""
120
+ room = pathlib.Path(room).resolve()
121
+ transcript = room / "transcript.jsonl"
122
+ if not transcript.exists():
123
+ return ["no transcript - the room was never driven"], []
124
+ breaches = isolation_held(transcript, policy)
125
+ refused: list[str] = []
126
+ checkout, inside = norm(str(policy.checkout.resolve())), norm(str(room))
127
+ forbidden = {norm(path): what for path, what in policy.forbidden.items()}
128
+ for call in tr.tool_calls(transcript):
129
+ landed = refused if call.is_error else breaches
130
+ command = call.input.get("command")
131
+ if isinstance(command, str) and RUNS_GIT.search(command):
132
+ # Refused when git found no repository - the ceiling held.
133
+ (refused if call.is_error or NOT_A_REPOSITORY in call.result
134
+ else breaches).append(f"{call.name} ran git: {command[:80]}")
135
+ for aimed in paths_in(call.input):
136
+ spelled = norm(aimed)
137
+ for path, what in forbidden.items():
138
+ if path in spelled:
139
+ landed.append(f"{call.name} reached into {what}: {aimed}")
140
+ if spelled.startswith(checkout) and not spelled.startswith(inside):
141
+ landed.append(f"{call.name} reached into the checkout: {aimed}")
142
+ if pathlib.PurePath(aimed.replace("\\", "/")).name in policy.foreign:
143
+ landed.append(f"{call.name} read another author's work: {aimed}")
144
+ return sorted(set(breaches)), sorted(set(refused))
quality_loop/mcp.py ADDED
@@ -0,0 +1,161 @@
1
+ """The operator's tools, as a small MCP server: ``python -m quality_loop.mcp``.
2
+
3
+ The operator has no shell. Claude Code lets a read-only command through
4
+ whatever the allow list says, so a compound ``cd; ls; cat ...`` ran on the
5
+ first real operator - and ``cat`` reads anywhere on the machine, which the
6
+ file tools' confinement does not. So the operator's only way to act is these
7
+ four tools:
8
+
9
+ - ``screen_list``, ``screen_open(name)``, ``screen_close(id)`` - the
10
+ declared screens, as `quality_loop.screens` puts them up;
11
+ - ``evidence(tool, args)`` - one of the product's declared evidence
12
+ commands, run with ``args`` as a list and no shell, so nothing can be piped,
13
+ redirected or chained. What the arguments mean is the evidence command's
14
+ own business, and an adapter declares only commands that read.
15
+
16
+ Everything comes from the manifest the runner wrote (``QUALITY_LOOP_MANIFEST``).
17
+ The protocol is the least of MCP that Claude Code needs - initialize,
18
+ tools/list, tools/call, ping - as newline-delimited JSON-RPC on stdio, with
19
+ nothing outside the standard library.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import pathlib
27
+ import subprocess
28
+ import sys
29
+
30
+ import quality_loop
31
+ from quality_loop import screens
32
+
33
+ PROTOCOL = "2025-06-18"
34
+
35
+ #: Ceiling on what one evidence call hands back, in characters.
36
+ MAX_OUTPUT = 20_000
37
+
38
+ #: How long one evidence command may take.
39
+ EVIDENCE_TIMEOUT = 120
40
+
41
+
42
+ def _manifest() -> dict:
43
+ return json.loads(pathlib.Path(os.environ[screens.MANIFEST]).read_text(encoding="utf-8"))
44
+
45
+
46
+ def tools(manifest: dict) -> list[dict]:
47
+ """The tool list, with the declared names in the schemas."""
48
+ evidence = manifest.get("evidence", {})
49
+ listing = "\n".join(f"- {name}: {entry.get('help', '')}"
50
+ for name, entry in evidence.items()) or "(none declared)"
51
+ return [
52
+ {"name": "screen_list",
53
+ "description": "The screens the product declares: name, kind and window title.",
54
+ "inputSchema": {"type": "object", "properties": {}}},
55
+ {"name": "screen_open",
56
+ "description": "Open a declared screen and wait until its window is up. "
57
+ "Answers the id to close it with.",
58
+ "inputSchema": {"type": "object", "properties": {
59
+ "name": {"type": "string", "enum": sorted(manifest.get("screens", {}))}},
60
+ "required": ["name"]}},
61
+ {"name": "screen_close",
62
+ "description": "Close a screen screen_open opened, by its id.",
63
+ "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}},
64
+ "required": ["id"]}},
65
+ {"name": "evidence",
66
+ "description": "Run one of the product's evidence commands with the given "
67
+ "arguments and answer what it printed. Run it with "
68
+ "args [\"--help\"] first. Declared:\n" + listing,
69
+ "inputSchema": {"type": "object", "properties": {
70
+ "tool": {"type": "string", "enum": sorted(evidence)},
71
+ "args": {"type": "array", "items": {"type": "string"}}},
72
+ "required": ["tool", "args"]}},
73
+ ]
74
+
75
+
76
+ class ToolError(Exception):
77
+ pass
78
+
79
+
80
+ def call(name: str, arguments: dict, manifest: dict) -> str:
81
+ """One tool call's answer, or a ToolError saying what was wrong."""
82
+ try:
83
+ if name == "screen_list":
84
+ return json.dumps({n: {"kind": e["kind"], "title": e["title"]}
85
+ for n, e in manifest.get("screens", {}).items()}, indent=2)
86
+ if name == "screen_open":
87
+ return json.dumps(screens.open_screen(str(arguments.get("name", ""))), indent=2)
88
+ if name == "screen_close":
89
+ return json.dumps(screens.close_screen(str(arguments.get("id", ""))), indent=2)
90
+ except screens.ScreenError as error:
91
+ raise ToolError(str(error)) from None
92
+ if name == "evidence":
93
+ return _evidence(arguments, manifest)
94
+ raise ToolError(f"no tool {name!r}")
95
+
96
+
97
+ def _evidence(arguments: dict, manifest: dict) -> str:
98
+ declared = manifest.get("evidence", {})
99
+ tool = arguments.get("tool")
100
+ if tool not in declared:
101
+ raise ToolError(f"no evidence tool {tool!r}; declared: "
102
+ + (", ".join(sorted(declared)) or "none"))
103
+ args = arguments.get("args", [])
104
+ if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
105
+ raise ToolError("args must be a list of strings")
106
+ try:
107
+ done = subprocess.run([*declared[tool]["argv"], *args], capture_output=True,
108
+ text=True, encoding="utf-8", errors="replace",
109
+ stdin=subprocess.DEVNULL, timeout=EVIDENCE_TIMEOUT)
110
+ except subprocess.TimeoutExpired:
111
+ raise ToolError(f"{tool} did not finish within {EVIDENCE_TIMEOUT}s") from None
112
+ said = done.stdout + (f"\n[stderr]\n{done.stderr}" if done.stderr.strip() else "")
113
+ if len(said) > MAX_OUTPUT:
114
+ said = said[:MAX_OUTPUT] + f"\n... {len(said) - MAX_OUTPUT} more characters"
115
+ if done.returncode != 0:
116
+ raise ToolError(f"{tool} exited {done.returncode}\n{said}")
117
+ return said
118
+
119
+
120
+ def handle(message: dict, manifest: dict) -> dict | None:
121
+ """The reply to one JSON-RPC message, or None for a notification."""
122
+ if "id" not in message:
123
+ return None
124
+ method, ident = message.get("method"), message["id"]
125
+ if method == "initialize":
126
+ asked = (message.get("params") or {}).get("protocolVersion") or PROTOCOL
127
+ result = {"protocolVersion": asked, "capabilities": {"tools": {}},
128
+ "serverInfo": {"name": "quality-loop", "version": quality_loop.__version__}}
129
+ elif method == "ping":
130
+ result = {}
131
+ elif method == "tools/list":
132
+ result = {"tools": tools(manifest)}
133
+ elif method == "tools/call":
134
+ params = message.get("params") or {}
135
+ try:
136
+ text, failed = call(params.get("name", ""), params.get("arguments") or {},
137
+ manifest), False
138
+ except ToolError as error:
139
+ text, failed = str(error), True
140
+ result = {"content": [{"type": "text", "text": text}], "isError": failed}
141
+ else:
142
+ return {"jsonrpc": "2.0", "id": ident,
143
+ "error": {"code": -32601, "message": f"no method {method!r}"}}
144
+ return {"jsonrpc": "2.0", "id": ident, "result": result}
145
+
146
+
147
+ def main() -> int:
148
+ manifest = _manifest()
149
+ for line in sys.stdin:
150
+ try:
151
+ message = json.loads(line)
152
+ except ValueError:
153
+ continue
154
+ if isinstance(message, dict) and (reply := handle(message, manifest)) is not None:
155
+ sys.stdout.write(json.dumps(reply) + "\n")
156
+ sys.stdout.flush()
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())