jobbsok could not start on stock Windows. `.mcp.json` named `bash` as the
command, and the five entry points behind it reached for grep, sed, find, awk,
zip and unzip. `bootstrap.sh` built the virtualenv, so a Windows adopter could
not even reach an interpreter. Two adopters are waiting and neither is
guaranteed to be on macOS, so this is the install, not a rough edge.
The Python layer was already clean -- no /tmp, no /usr, no os.uname, no home
directory assumption -- so only the shell layer moved. Behaviour is carried
over unchanged; the deliberate exceptions are listed in docs/.
THE ONE OPEN DECISION, AND WHY IT WAS FORCED
How does .mcp.json start an interpreter without a POSIX shell, when it is
called python3 on macOS and python or py on Windows? Measured against the
installed CLI, not assumed:
- The plugin mcpServers stdio schema has NO platform-conditional form. A
config carrying invented windows/darwin/platform keys was accepted and the
keys were silently discarded -- it fails quietly, not loudly.
- ${VAR:-default} IS expanded, in command, args and env.
- ${VAR} without a default is not safe: unset, it is passed through
unexpanded, so the spawn would try to run a program named ${VAR}.
- Windows spawns with shell:false, so a .py path as command is out.
- No single literal works. On this Mac, python and py are not on PATH.
So the default form is the only lever the schema offers:
"${JOBBSOK_LAUNCH_PYTHON:-python3}". macOS and Linux keep working with nothing
set; Windows sets one variable and needs no shell.
A SECOND VARIABLE, NOT A REUSE OF JOBBSOK_PYTHON
JOBBSOK_PYTHON names the interpreter to SERVE on: the launcher treats it as an
explicit operator choice, so it wins over the bootstrapped virtualenv. A
Windows adopter setting it merely to spell `python` would silently bypass that
virtualenv and serve WITHOUT the ingestion guard. JOBBSOK_LAUNCH_PYTHON only
says how to start the launcher. A test asserts the two never collapse into one.
O4 IS LEFT STANDING
The launcher still gates on the interpreter's version rather than on the guard
being importable. That is the shell version's semantics carried over on
purpose: harmless while nothing writes, a defect from M2, and an M2 decision.
VERIFY
- pytest tests/ -> 124 passed, exit 0 (was 112)
- grep -c '"command": "bash"' .mcp.json -> 0
- git ls-files 'scripts/*.sh' -> 0
- README install block names Windows, and neither WSL nor Git Bash
- server started end to end exactly as .mcp.json expands, and answered
initialize and tools/list
- the ported probe checker reproduces the shell version's output and exits 0
NOT MEASURED, AND NOT ASSUMED
Nothing here has ever run on Windows. Whether Cowork on Windows bridges to a
host-side stdio MCP as it does on this Mac is unmeasured -- docs/cowork-probe.md
covered macOS only. docs/cross-platform-port.md says what a Windows probe would
have to measure, and records two findings left deliberately untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
9.6 KiB
Python
257 lines
9.6 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.
|
|
|
|
The gate is on the VERSION, not on the guard being importable. That is the
|
|
shell version's semantics carried over unchanged, and it is a known weakness
|
|
(O4 in docs/cowork-probe.md): `selvsjekk` can report `guard_versjon: null`
|
|
while the server runs happily. It is harmless while nothing writes and becomes
|
|
a defect in M2. Fixing it is an M2 decision, not a porting one.
|
|
"""
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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):
|
|
if env is None:
|
|
env = os.environ
|
|
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
|
|
|
|
# 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:]))
|