315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""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:]))
|