Ticket graphs

Every ticket as the root of its provenance graph — goal, patch, approval, runs and (as the ticket-class build lands) mandate, manifestations, guarantees, journey, artifacts and children. Rendered through the <<<TicketGraphList>>> tag.

← all ticket-graphs

REQUESTED#431 forge-refactor

Rewrite agent_tools_suite:mcplib.py as behavior-preserving, installable, control-config'd Python [forge-rw:6c779b342584ea39]

goal

Rewrite ONE unique forge module as entirely new, behavior-preserving, installable, control-config'd Python, per the FORGE-wide refactor standard #200 (#std). This ticket covers ONE module — the one identified below — and nothing else. It was filed against the module's DEDUPLICATED identity, so if that module was copy-pasted across the forge, every copy is covered by this single ticket. MODULE KEY : forge-rw:6c779b342584ea39 (the ticketer's idempotency marker — it lives in this ticket's TITLE. Do not edit the title, or the module will be re-filed as a duplicate.) IDENTITY : AST-NORMALIZED — codebean's `_ast_norm` (#183), i.e. ast.dump(ast.parse(ast.unparse(ast.parse(src)))). Copies of this module that differ only in comments, blank lines, quoting or spacing carry the SAME module-sha and are covered by THIS ticket. A differing docstring is real content, so a module whose docstring differs is a DIFFERENT module with its own ticket. SIZE : 10103 bytes EXTENSION : .py CANONICAL SOURCE (the location this ticket is named for): repo : agent_tools_suite path : mcplib.py ref : HEAD (HEAD) blob : c3796f354363df36faeb4f7a35de2f790d81166b (git blob sha1) REFERENCE ONLY — you do NOT need this command. This module's bytes are embedded COMPLETE in THE ORIGINAL SOURCE block below, and that is what the rewrite is based on. For a reader who does have forge access: git --git-dir=/Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes/agent_tools_suite.git cat-file blob c3796f354363df36faeb4f7a35de2f790d81166b THE SAME MODULE ALSO LIVES AT THESE FORGE TIPS: none — this module is unique to agent_tools_suite among the repos scanned so far. HISTORICAL OCCURRENCES of this exact blob — every OTHER DISTINCT {repo, path} the omni-git index (`occ`, joined on the git blob sha1) records this byte-identical file at, with the index's own `ts` for the first row of each. Any place this run enumerated LIVE is listed under the forge tips above, not here. Context for the rewrite, not extra work: testmonkeyalpha-group/jazz-forge/agent_tools_suite:mcplib.py [occ.ts gitlab] THE ORIGINAL SOURCE, EMBEDDED — YOU NEED NO FORGE ACCESS TO DO THIS TICKET. The module's own bytes are reproduced below, inside this ticket. THEY are the authoritative copy and the behavior-preserving basis: rewrite from them. The `git --git-dir=... cat-file blob` command under CANONICAL SOURCE above is kept only as a REFERENCE for a reader who happens to have forge access — running it is NOT part of this ticket. (Why the bytes are here at all: the pair_loop worker that executes this ticket is sandboxed to the ticket's own congruency worktree and cannot read the forge's bare repos. Ticket #346 is the fix that put them on the ticket.) The block BEGINS at the line ===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)=== and ENDS at the first line ===END SOURCE=== Everything strictly between those two lines is the module — 10103 byte(s), byte for byte, with no re-indentation, no re-wrapping and nothing elided. ===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)=== #!/usr/bin/env python3 """mcplib — zero-dependency MCP stdio helpers, shared by the gate, the coupler, and the leaf servers. MCP stdio is newline-delimited JSON-RPC 2.0. Pure stdlib, so nothing can rot: - Server: the host-facing side (initialize / tools/list / tools/call loop). - Downstream: the client side (spawn a child MCP server, handshake, route calls). Everything is synchronous and sequential — requests over stdio arrive one at a time, which keeps this small and auditable. """ import json import os import subprocess import sys DEFAULT_PROTOCOL = "2024-11-05" def text_result(text, is_error=False): """A standard MCP text tool-result.""" return {"content": [{"type": "text", "text": text}], "isError": is_error} def load_registry(here): """Read the registry beside `here`. `ATS_REGISTRY` overrides the filename (per-agent scoping — e.g. registry.crankfix.json for a crankfix-only launch); it's inherited by the coupler the gate spawns, so one env var scopes the whole stack. A tool that cannot see its registry throws.""" name = os.environ.get("ATS_REGISTRY", "registry.json") path = name if os.path.isabs(name) else os.path.join(here, name) if not os.path.isfile(path): raise SystemExit("registry not found: %s" % path) with open(path) as f: return json.load(f) # --------------------------------------------------------------------------- # # Host-facing server # # --------------------------------------------------------------------------- # class Server: """Drives the host-facing MCP stdio loop. list_tools() -> [ {name, description, inputSchema}, ... ] call_tool(name, arguments) -> result dict (use text_result()). """ def __init__(self, name, version, list_tools, call_tool, watch_tool_changes=False): self.info = {"name": name, "version": version} self._list_tools = list_tools self._call_tool = call_tool # When True, advertise the tools.listChanged capability and push a # notifications/tools/list_changed whenever the tool list changes after a # tools/call — so the host re-fetches without a manual reconnect. self._watch = watch_tool_changes self._last_tool_names = None def _send(self, msg): sys.stdout.write(json.dumps(msg) + "\n") sys.stdout.flush() def _reply(self, mid, result): self._send({"jsonrpc": "2.0", "id": mid, "result": result}) def _error(self, mid, code, message): self._send({"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}) def _notify(self, method, params=None): """A server-initiated notification (no id — the host does not reply).""" self._send({"jsonrpc": "2.0", "method": method, "params": params or {}}) def _maybe_notify_tools_changed(self): """If the tool list changed since it was last observed, tell the host to re-fetch it (MCP notifications/tools/list_changed).""" try: names = [t.get("name") for t in self._list_tools()] except Exception: return if self._last_tool_names is not None and names != self._last_tool_names: self._notify("notifications/tools/list_changed") self._last_tool_names = names def _handle(self, msg): mid = msg.get("id") method = msg.get("method") params = msg.get("params") or {} is_request = mid is not None if method == "initialize": caps = {"tools": {"listChanged": True} if self._watch else {}} self._reply(mid, { "protocolVersion": params.get("protocolVersion", DEFAULT_PROTOCOL), "capabilities": caps, "serverInfo": self.info, }) elif method in ("notifications/initialized", "initialized"): return elif method == "ping": if is_request: self._reply(mid, {}) elif method == "tools/list": tools = self._list_tools() self._reply(mid, {"tools": tools}) if self._watch: self._last_tool_names = [t.get("name") for t in tools] elif method == "tools/call": name = params.get("name") try: result = self._call_tool(name, params.get("arguments") or {}) except Exception as e: # never let one bad call kill the server result = text_result('tool "%s" threw: %s' % (name, e), True) self._reply(mid, result) if self._watch: # a call may have changed the tool list self._maybe_notify_tools_changed() else: if is_request: self._error(mid, -32601, "Method not found: %s" % method) def serve(self): sys.stderr.write("[%s] up\n" % self.info["name"]) # readline() (not `for line in sys.stdin`) — the latter read-ahead-buffers on a # pipe and would stall responses until the buffer fills or stdin closes. while True: line = sys.stdin.readline() if line == "": # EOF: host closed stdin → shut down break line = line.strip() if not line: continue try: msg = json.loads(line) except Exception: continue self._handle(msg) # --------------------------------------------------------------------------- # # Downstream client # # --------------------------------------------------------------------------- # class Downstream: """A child MCP server this process spawns and talks to as a client. `spec` = {command, args?, env?}. A command/arg/env-value written as an explicit relative path (./x or ../x) is resolved against `base_dir`, so servers that ship together never need an absolute path. Bare relative args still work because the child is spawned with cwd=base_dir. """ def __init__(self, name, spec, base_dir): self.name = name self.spec = spec or {} self.base_dir = base_dir self.proc = None self.tools = [] self.dead = False self.error = None self._id = 0 def _resolve(self, s): if isinstance(s, str) and (s.startswith("./") or s.startswith("../")): return os.path.normpath(os.path.join(self.base_dir, s)) return s def start(self): if self.proc is not None or self.dead: return command = self.spec.get("command") if not command: self.dead = True self.error = 'missing "command"' return command = self._resolve(command) args = [self._resolve(a) for a in self.spec.get("args", [])] env = dict(os.environ) for k, v in (self.spec.get("env") or {}).items(): env[k] = self._resolve(v) try: self.proc = subprocess.Popen( [command] + args, cwd=self.base_dir, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr, text=True, bufsize=1, env=env, ) except Exception as e: self.dead = True self.error = str(e) return try: self._request("initialize", { "protocolVersion": DEFAULT_PROTOCOL, "capabilities": {}, "clientInfo": {"name": "mcplib", "version": "1.0.0"}, }) self._notify("notifications/initialized") res = self._request("tools/list", {}) self.tools = (res or {}).get("tools", []) except Exception as e: self.dead = True self.error = str(e) def _request(self, method, params): if self.dead or self.proc is None: raise RuntimeError("downstream '%s' not running (%s)" % (self.name, self.error)) self._id += 1 mid = self._id payload = json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}) + "\n" self.proc.stdin.write(payload) self.proc.stdin.flush() while True: line = self.proc.stdout.readline() if line == "": self.dead = True self.error = self.error or "downstream closed" raise RuntimeError("downstream '%s' closed" % self.name) line = line.strip() if not line: continue try: msg = json.loads(line) except Exception: continue if msg.get("id") == mid: if msg.get("error"): raise RuntimeError(msg["error"].get("message", "downstream error")) return msg.get("result") # ignore notifications / mismatched ids def _notify(self, method, params=None): try: self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method, "params": params or {}}) + "\n") self.proc.stdin.flush() except Exception: pass def call_tool(self, name, arguments): return self._request("tools/call", {"name": name, "arguments": arguments or {}}) def refresh_tools(self): """Re-fetch this child's tool list (e.g. after it reconnected its own children), so an aggregating parent can re-expose the current set.""" if self.dead or self.proc is None: return try: res = self._request("tools/list", {}) self.tools = (res or {}).get("tools", []) except Exception as e: self.dead = True self.error = str(e) def stop(self): if self.proc and not self.dead: try: self.proc.terminate() except Exception: pass ===END SOURCE=== THE CALLABLE SURFACE — every callable in the original, lowered with codebean's `lower_node` (#183). The rewrite must present THIS surface (same names, same parameters, same defaults) so callers of the original keep working: text_result(text, is_error=False) load_registry(here) Server.__init__(self, name, version, list_tools, call_tool, watch_tool_changes=False) Server._send(self, msg) Server._reply(self, mid, result) Server._error(self, mid, code, message) Server._notify(self, method, params=None) Server._maybe_notify_tools_changed(self) Server._handle(self, msg) Server.serve(self) Downstream.__init__(self, name, spec, base_dir) Downstream._resolve(self, s) Downstream.start(self) Downstream._request(self, method, params) Downstream._notify(self, method, params=None) Downstream.call_tool(self, name, arguments) Downstream.refresh_tools(self) Downstream.stop(self) CAPTURED BEHAVIORAL EXEMPLARS — real (input, output) pairs mined from THIS module with criteria_miner (#200's "verified, not asserted"), running the ORIGINAL as the oracle. THE REWRITE MUST REPRODUCE EVERY PAIR BELOW, exactly: text_result(text, is_error=False) — 30 exemplar(s) captured: text_result('content', 'content') == {'content': [{'type': 'text', 'text': 'content'}], 'isError': 'content'} text_result('content', 'A standard MCP text tool-result.') == {'content': [{'type': 'text', 'text': 'content'}], 'isError': 'A standard MCP text tool-result.'} text_result('content', 'type') == {'content': [{'type': 'text', 'text': 'content'}], 'isError': 'type'} text_result('content', 'isError') == {'content': [{'type': 'text', 'text': 'content'}], 'isError': 'isError'} text_result('content', 'text') == {'content': [{'type': 'text', 'text': 'content'}], 'isError': 'text'} text_result('content', 0) == {'content': [{'type': 'text', 'text': 'content'}], 'isError': 0} ... 24 more exemplar(s) for this function — re-mine them with criteria_miner.mine() on the source above HONEST LIMIT (criteria_miner's own): these cover the SAMPLED inputs, probed around the function's own branch constants. They are a strong refactor-drift net, NOT a proof of total equivalence — so treat them as the FLOOR of this ticket's equivalence evidence and add the edge cases they miss (see ACCEPTANCE). THE STANDARD — #200's four requirements, which this ticket exists to enforce. All four are the standard's own words; none is optional: 1. BEHAVIOR-PRESERVING, VERIFIED NOT ASSERTED — the deliverable is ENTIRELY NEW source code (rewritten, not copied). It must reproduce the ORIGINAL's behavior, and the proof is the exemplars above (captured via codebean lower_* #183 + criteria_miner IO-exemplars, exactly as #200 specifies). 2. A PYTHON MODULE — regardless of the source language. This module's source is `.py`; the rewrite is Python either way. 3. INSTALLABLE — a proper package: pyproject.toml, `pip install -e .` works, console entry points where applicable. WRITE ONLY YOUR OWN MODULE FILE at forge2/<repo>/<module>.py. Do NOT hand-author pyproject.toml or __init__.py: the harness DERIVES them ADDITIVELY from the modules present (congruency/tools/ forge_package.py, #410 — the analogue of the tag-registry fix #281), so your unit never conflicts with, or clobbers, a sibling module of the same forge2/<repo>/ package. A package-level pyproject includes every .py in the directory, so your module installs and imports as `<repo>.<module>` the moment it lands. 4. CONTROL-CONFIG'D — NO hardcoded paths or params. A JSON/registry config object drives it (the install.json / registry.json idiom of this repo), and per note-for-claude a tool that cannot see its config THROWS rather than guessing a default. Add ONLY your own `forge2.<repo>.<module>` section to the package's registry.json (create the file if it is absent); NEVER read, edit or drop a sibling module's section — the sections are disjoint keys and stay additive that way. ACCEPTANCE (#200's, per rewrite ticket) — this ticket settles when ALL of: a. the new source reproduces every captured exemplar above, plus the edge cases you add for what sampling cannot reach (paste the runs as evidence); b. it presents the callable surface above, so existing callers keep working; c. `pip install -e .` installs it clean from its own pyproject.toml; d. it runs off a config object with ZERO hardcoded settings, and throws without one. Kind is `build` (a pair: coder + adversarial tester). This ticket hangs off #200 by a `spawned` edge, so a stop ticket aimed at #200 brakes every rewrite in this programme. Filed mechanically by checkouts/current/congruency/tools/forge_refactor_ticketer.py under ticket #201, from the forge bare repos at /Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes. Its original source is embedded above (#346), so this ticket is complete on its own.

patch

none

approval

unapproved

runs

no runs recorded

mandate (clauses)

not yet recorded — lands with the ticket-class build

manifestations

not yet recorded — lands with the ticket-class build

guarantees

not yet recorded — lands with the ticket-class build

journey (blunders & successes)

not yet recorded — lands with the ticket-class build

artifacts (forge)

not yet recorded — lands with the ticket-class build

source (forge tree)

browse congruency source (forge-parked tree)

children

not yet recorded — lands with the ticket-class build