"""Start the jobbsok-tools stdio MCP server on an interpreter that is new enough. Python rather than bash, and standard library only. Stock Windows has no bash, so a shell launcher meant `.mcp.json` could not start the server there at all. This file is deliberately written to parse on an OLD interpreter -- no syntax newer than 3.7 -- because the interpreter that starts it is exactly the one it exists to distrust: refusing 3.9.6 is worth nothing if the refusal is a SyntaxError. Why this file exists at all, rather than .mcp.json naming an interpreter directly: a GUI-spawned process on this Mac can resolve python3 to 3.9.6 under an empty launchd PATH (risk H2). A server running on 3.9.6 is worse than no server, because the degradation branch already covers an absent server and nothing covers a silently wrong one. So every candidate below is gated on the version, and when none passes the launcher refuses instead of falling through to whatever interpreter the PATH happens to offer. Resolution order, first candidate that passes the gate wins: 1. $JOBBSOK_PYTHON -- the manual override. Named explicitly by the operator, so a version failure here is refused outright rather than skipped: falling back from an interpreter someone asked for would hide their mistake. 2. $CLAUDE_PLUGIN_DATA/venv -- what scripts/bootstrap.py builds against an installed plugin. This is the supported route in an installed copy. 3. $CLAUDE_PLUGIN_ROOT/.venv -- the development environment. The packaged archive excludes the virtualenv, so this branch never fires in an installed copy. 4. python on PATH -- last resort, and only at 3.10 or newer. Two environment variables, and they are NOT the same one: JOBBSOK_LAUNCH_PYTHON names the interpreter that STARTS this file, and is read by .mcp.json, never here. It exists because the plugin MCP schema has no platform-conditional command (measured 2026-09-05: an invented per-OS key is silently discarded) and no literal name exists on both macOS and Windows. It defaults to python3, so nothing changes on macOS or Linux. JOBBSOK_PYTHON names the interpreter to SERVE on, and is candidate 1 below. Setting it wins over the bootstrapped virtualenv, so it must not be borrowed for the spelling problem above: a Windows adopter setting it merely to say `python` would serve without the guard. Two gates, in this order (O4, decided in docs/cowork-probe.md and implemented at plan Step 24): 1. VERSION. The interpreter reports at least 3.10, or it is not used. 2. THE GUARD. The interpreter can import `llm_ingestion_guard`, or the launcher refuses to serve at all and says which command installs it. Until M2 there was only the first, and the measured consequence is the reason for the second: the M1 probe found the server running happily on the host's 3.14.0 with `selvsjekk` reporting `guard_versjon: null`, because nothing had ever asked. That cost nothing while no tool wrote anything. From M2 the case folder and the decision log persist employer names, titles, URLs and notes that came out of a listing, and the guard is the boundary those writes are supposed to cross. Refusing is cheap here, and that is the deciding argument rather than a consolation: every skill in this plugin degrades to manual paste with `jobbsok-tools` absent. A launcher that refuses removes a connector; it does not strand the operator. Failing open would persist untrusted text through the one boundary this plugin exists to hold. The guard gate costs one extra interpreter start-up at launch, which is paid once per session against Cowork's MCP start-up timeout. It is a real cost and it is worth naming; it is not a reason to check something cheaper that answers a different question. """ import os import shutil import subprocess import sys MIN_MAJOR = 3 MIN_MINOR = 10 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) class Avvist(Exception): """An interpreter the operator named explicitly and that cannot be used. Separate from "no candidate found" on purpose: skipping past a broken interpreter someone asked for by name would hide their mistake. """ def __init__(self, linjer): Exception.__init__(self, linjer[0]) self.linjer = linjer def path_candidates(os_name=None): """What the interpreter is called on PATH, on ``os_name``. POSIX keeps exactly what the shell version tried, so nothing changes there. Windows has no `python3` unless Python came from the Microsoft Store -- the python.org installer ships `python` and `py`, and a bare `python3` there resolves to the Store stub, which opens a shop rather than running anything. """ if os_name is None: os_name = os.name if os_name == "nt": return ("python", "py") return ("python3",) def venv_python(venv_dir, os_name=None): """The interpreter inside a virtualenv, in the layout this platform uses. POSIX venvs carry both `bin/python` and `bin/python3`; Windows venvs carry `Scripts/python.exe` and no `python3` at all, so `python` is the one name that is right everywhere. """ if os_name is None: os_name = os.name if os_name == "nt": return os.path.join(venv_dir, "Scripts", "python.exe") return os.path.join(venv_dir, "bin", "python") def version_ok(tolk): """True when ``tolk`` reports at least the floor. Runs it; does not parse a name.""" return 0 == subprocess.call( [ tolk, "-c", "import sys; sys.exit(0 if sys.version_info >= (%d, %d) else 1)" % (MIN_MAJOR, MIN_MINOR), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) #: The ingestion guard, by the name the interpreter has to be able to import. #: build-brief calls this the trust boundary, and the boundary is the write. GUARD_MODUL = "llm_ingestion_guard" #: What fixes a failed guard gate. Named in the refusal, because a refusal #: that does not say what to run is a dead end. BOOTSTRAP = "python scripts/bootstrap.py" def guard_ok(tolk): """True when ``tolk`` can import the ingestion guard. Runs it; does not look for a file, because what matters is that interpreter's own path.""" return 0 == subprocess.call( [tolk, "-c", "import " + GUARD_MODUL], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def version_of(tolk): """The version ``tolk`` reports, or "ukjent" when it will not say.""" try: ut = subprocess.run( [tolk, "-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, ) except OSError: return "ukjent" if ut.returncode != 0 or not ut.stdout.strip(): return "ukjent" return ut.stdout.strip() def usable(kandidat, which=None, gate=None): """Return ``kandidat`` resolved as a program if it passes the gate, else None. ``which`` stands in for the shell version's `command -v`: given a path it checks that path, given a bare name it searches PATH, and on Windows it applies PATHEXT, which is how `python.exe` is found from `python`. """ if which is None: which = shutil.which if gate is None: gate = version_ok if not kandidat: return None funnet = which(kandidat) if not funnet: return None if not gate(funnet): return None return funnet def resolve_interpreter(env, plugin_root, which=None, gate=None): """Return the first candidate that passes the gate, or None. Raises ``Avvist`` for the one case that must not fall through: an interpreter the operator named in JOBBSOK_PYTHON that cannot be used. """ if which is None: which = shutil.which if gate is None: gate = version_ok navngitt = env.get("JOBBSOK_PYTHON") if navngitt: funnet = which(navngitt) if not funnet: raise Avvist( [ "jobbsok-tools: JOBBSOK_PYTHON=%s is not executable." % navngitt, "jobbsok-tools: refusing to start. Point it at a Python " "%d.%d+ interpreter." % (MIN_MAJOR, MIN_MINOR), ] ) if not gate(funnet): raise Avvist( [ "jobbsok-tools: JOBBSOK_PYTHON=%s is Python %s." % (navngitt, version_of(funnet)), "jobbsok-tools: %d.%d+ is required; refusing to start on it." % (MIN_MAJOR, MIN_MINOR), ] ) return funnet plugin_data = env.get("CLAUDE_PLUGIN_DATA") if plugin_data: funnet = usable(venv_python(os.path.join(plugin_data, "venv")), which, gate) if funnet: return funnet funnet = usable(venv_python(os.path.join(plugin_root, ".venv")), which, gate) if funnet: return funnet for navn in path_candidates(): funnet = usable(navn, which, gate) if funnet: return funnet return None def main(argv, env=None, guard=None): if env is None: env = os.environ if guard is None: guard = guard_ok plugin_root = env.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(SCRIPT_DIR) server = os.path.join(plugin_root, "scripts", "jobbsok_tools.py") try: tolk = resolve_interpreter(env, plugin_root) except Avvist as avvist: for linje in avvist.linjer: sys.stderr.write(linje + "\n") return 1 if not tolk: sys.stderr.write( "jobbsok-tools: found no Python %d.%d+ interpreter.\n" % (MIN_MAJOR, MIN_MINOR) ) sys.stderr.write( "jobbsok-tools: tried JOBBSOK_PYTHON, $CLAUDE_PLUGIN_DATA/venv, " "$CLAUDE_PLUGIN_ROOT/.venv and %s on PATH.\n" % " or ".join(path_candidates()) ) sys.stderr.write( "jobbsok-tools: run 'python3 scripts/bootstrap.py' against the " "installed plugin, or set JOBBSOK_PYTHON.\n" ) sys.stderr.write( "jobbsok-tools: refusing to start rather than serving on an " "interpreter below %d.%d.\n" % (MIN_MAJOR, MIN_MINOR) ) return 1 if not os.path.isfile(server): sys.stderr.write("jobbsok-tools: server not found at %s\n" % server) return 1 # Gate two. Last, because it is the expensive one and there is no point # probing an interpreter that already failed the floor. if not guard(tolk): sys.stderr.write( "jobbsok-tools: %s cannot import %s.\n" % (tolk, GUARD_MODUL) ) sys.stderr.write( "jobbsok-tools: run '%s' against the installed plugin; the guard " "is installed with it.\n" % BOOTSTRAP ) sys.stderr.write( "jobbsok-tools: refusing to start rather than serving tools that " "write without the ingestion guard. Every skill still works by " "manual paste.\n" ) return 1 # POSIX replaces this process, as the shell version's `exec` did: the MCP # client's child stays the process it spawned, and killing it kills the # server. Windows has no such replacement -- os.execv there starts a new # process and lets this one exit, which would leave the client holding a # dead pid and a live pipe -- so there the launcher stays as the parent. if os.name != "nt": os.execv(tolk, [tolk, server] + list(argv)) return subprocess.call([tolk, server] + list(argv)) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))