jobbsok/tests/test_mcp_jobbsok_tools.py

543 lines
21 KiB
Python

"""The `jobbsok-tools` host MCP server and its launcher (plan Step 12).
The server exists so Cowork can reach the same logic the Claude Code CLI
reaches (operator decision 7). That framing decides what is worth testing
here. The arithmetic is already covered by Steps 8 and 10; what is not covered
anywhere else is the seam -- whether the MCP layer hands back what the library
computed, whole and unaltered, or quietly reshapes it on the way out.
Three properties carry that seam, and each has a test of its own:
* **The tool surface is pinned to a golden file.** A tool list is an API. A
renamed argument is a silent break in Cowork, where nothing type-checks the
call, so the list is blessed once and compared byte for byte after.
* **Every tool takes an explicit `workspace`.** `jobbsok_lib.paths` refuses to
guess at a home directory; a server that defaulted the workspace would put
the guess back on the far side of an unsandboxed process (risk C6).
* **`avvisninger` and `advarsler` stay apart, and `vekt_hash` comes along.**
Session 8 decided that a warning is a filter that could not run or a soft
filter that fired, and a rejection is a hard no. Collapsing the two in the
`tools/call` answer would erase divergences 3 and 5 at the transport layer,
where no scoring test would ever see it.
The launcher is tested through a real subprocess in both directions, because
what it exists to prevent -- silently serving on the 3.9.6 that a GUI-spawned
process finds on an empty PATH (risk H2) -- is a property of process startup
and cannot be observed in-process. That test also covers the stdio loop's
framing, which the in-process helper deliberately does not.
Style note: this file follows tests/test_kandidatvurdering_nowrite.py.
"""
import json
import os
import subprocess
import sys
import pytest
import jobbsok_tools
from helpers import mcp_stdio
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(REPO, "scripts")
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json")
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.py")
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
TODAY = "2026-09-15"
#: The tool surface, in the order the server declares it. A tool list is an
#: API: M1 shipped the first three, Step 24 added the four M2 tools.
VERKTOY = [
"kandidat_valider",
"annonse_vurder",
"selvsjekk",
"sak_status",
"sak_status_check",
"dagens",
"beslutning_append",
]
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
#: A sub-score payload on the 0-100 contract. The corpus registers 0-10 for
#: readability, so the bridge is built here rather than in the corpus, which
#: `test_fixture_hygiene.py` holds to an exact file count.
DELSCORE = {
"delscore": {"fagomrade": 80, "oppgavetype": 70, "teknologi": 60, "selskapstype": 50},
"bekymringer": ["konseptfase, ikke drift"],
}
@pytest.fixture
def klient():
"""An initialized in-process MCP client bound to the server module."""
client = mcp_stdio.Client(jobbsok_tools)
client.initialize()
return client
@pytest.fixture
def arbeidsomrade(empty_workspace, fixtures_dir):
"""A scaffolded workspace holding the valid fixture profile."""
with open(os.path.join(fixtures_dir, *PROFIL_FIXTURE), "r", encoding="utf-8") as handle:
profil = handle.read()
with open(
os.path.join(empty_workspace, "profil", "kandidat.md"), "w", encoding="utf-8"
) as handle:
handle.write(profil)
return empty_workspace
def les_annonse(fixtures_dir):
from jobbsok_lib import frontmatter
with open(os.path.join(fixtures_dir, *ANNONSE_FIXTURE), "r", encoding="utf-8") as handle:
return frontmatter.parse(handle.read())
def test_initialize_and_tools_list_match_the_golden_surface(klient, golden):
hilsen = klient.initialize()
assert hilsen["protocolVersion"] == mcp_stdio.PROTOCOL_VERSION, (
"the server must echo the client's protocol version, not choose its own"
)
assert hilsen["serverInfo"]["name"] == "jobbsok-tools", (
"the server name is what .mcp.json declares; they cannot disagree"
)
assert "tools" in hilsen["capabilities"]
verktoy = klient.tools()
golden(GOLDEN, json.dumps({"tools": verktoy}, ensure_ascii=False, indent=2) + "\n")
# The golden pins the shape; this pins the surface, so a tool appearing or
# disappearing is a visible change and not a drift. M1 shipped the first
# three; Step 24 added the four M2 tools.
assert [t["name"] for t in verktoy] == VERKTOY
def test_every_tool_requires_an_explicit_workspace(klient, arbeidsomrade):
for verktoy in klient.tools():
skjema = verktoy["inputSchema"]
assert "workspace" in skjema["properties"], (
"%s does not take a workspace argument" % verktoy["name"]
)
assert "workspace" in skjema.get("required", []), (
"%s does not require workspace; there is no implicit default"
% verktoy["name"]
)
# Declared as required is one thing; refused at the call is the thing
# that matters, because Cowork does not enforce the schema for us.
svar = klient.call(verktoy["name"], {})
assert "error" in svar or svar["result"].get("isError") is True, (
"%s answered a call with no workspace instead of refusing it"
% verktoy["name"]
)
# And a workspace that escapes its root is refused by the same check the
# library uses, rather than being read from wherever it resolved.
svar = klient.call(
"kandidat_valider", {"workspace": arbeidsomrade, "sti": "../../etc/passwd"}
)
assert svar["result"]["isError"] is True
assert "workspace" in svar["result"]["content"][0]["text"].lower()
def test_the_server_and_the_library_produce_identical_output(
klient, arbeidsomrade, fixtures_dir
):
import kandidat_schema
import vurdering
fra_server = klient.call_text(
"kandidat_valider", {"workspace": arbeidsomrade, "sti": "profil/kandidat.md"}
)
fra_bibliotek = kandidat_schema.report_json(
kandidat_schema.validate_file(arbeidsomrade, "profil", "kandidat.md")
)
assert fra_server == fra_bibliotek, (
"the MCP layer reshaped the validation report on its way out"
)
annonse, brodtekst = les_annonse(fixtures_dir)
fra_server = klient.call_text(
"annonse_vurder",
{
"workspace": arbeidsomrade,
"annonse": annonse,
"brodtekst": brodtekst,
"delscore": DELSCORE["delscore"],
"bekymringer": DELSCORE["bekymringer"],
},
)
profil = vurdering.les_profil_fil(arbeidsomrade, "profil", "kandidat.md")
fra_bibliotek = jobbsok_tools.til_json(
vurdering.vurder(profil, annonse, brodtekst, DELSCORE)
)
assert fra_server == fra_bibliotek, (
"the MCP layer reshaped the scoring result on its way out"
)
def test_the_scoring_tool_keeps_avvisninger_advarsler_and_vekt_hash_apart(
klient, arbeidsomrade, fixtures_dir
):
annonse, brodtekst = les_annonse(fixtures_dir)
# A listing that passes every hard filter but leaves salary unstated: one
# warning, no rejection. Collapsing the two would make this read as a no.
uten_lonn = dict(annonse)
uten_lonn.pop("lonn_nok")
resultat = json.loads(
klient.call_text(
"annonse_vurder",
{
"workspace": arbeidsomrade,
"annonse": uten_lonn,
"brodtekst": brodtekst,
"delscore": DELSCORE["delscore"],
},
)
)
assert resultat["verdikt"] == "vurderes"
assert resultat["avvisninger"] == []
assert any("lonn" in a["nokkel"] for a in resultat["advarsler"]), (
"an unstated salary must surface as a warning, not vanish: %r"
% (resultat["advarsler"],)
)
assert resultat["vekt_hash"].startswith("sha256:")
import vurdering
assert resultat["vekt_hash"] == vurdering.vekt_hash(resultat["vekter"]), (
"vekt_hash must be the fingerprint of the weights actually used"
)
def test_importing_the_server_does_not_import_the_capability_modules():
"""Cold start is a budget: Cowork times an MCP server out on startup."""
proc = subprocess.run(
[
sys.executable,
"-c",
"import sys; import jobbsok_tools; "
"print(sorted(m for m in ('kandidat_schema', 'vurdering', "
"'sak_status', 'dagens', 'beslutninger', 'llm_ingestion_guard') "
"if m in sys.modules))",
],
cwd=REPO,
env=dict(os.environ, PYTHONPATH=SCRIPTS),
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == "[]", (
"importing the server pulled in %s at module load; the capability "
"modules must be imported inside the handlers" % proc.stdout.strip()
)
def test_selvsjekk_reports_the_interpreter_and_the_pinned_guard(klient, arbeidsomrade):
rapport = json.loads(klient.call_text("selvsjekk", {"workspace": arbeidsomrade}))
assert rapport["python_executable"] == sys.executable
major, minor = rapport["python_version_info"][:2]
assert (major, minor) >= (3, 10), (
"the server is serving on Python %r; 3.10+ is the floor"
% (rapport["python_version"],)
)
assert rapport["guard_versjon"] == "1.3.0", (
"selvsjekk must report the guard version actually installed, and the "
"pin in pyproject.toml is v1.3.0; got %r" % (rapport["guard_versjon"],)
)
assert rapport["workspace"] == os.path.realpath(arbeidsomrade)
# BUILD_STAMP arrives in Step 13. Whether it is there or not, saying so
# plainly is the contract -- a missing stamp must never read as a match.
assert "build_stamp" in rapport
def falsk_tolk(katalog, navn, versjon="3.9.6"):
"""Write a program that fails the version gate, in the host's own form.
The gate probes a candidate by running it, so a fake has to be a program
and not a Python file -- a .py would be run by the real interpreter and
would measure that one instead. On Windows that means a .cmd, which
shutil.which resolves through PATHEXT exactly as it resolves python.exe.
The two forms differ in one way, deliberately: cmd cannot inspect the
probe's arguments without re-quoting a string full of parentheses and
semicolons, so the Windows form answers unconditionally. Both fail the
gate, which is the only thing the gate reads.
The Windows branch is written but NOT measured -- this repository's suite
has only ever run on macOS.
"""
if os.name == "nt":
sti = katalog / (navn + ".cmd")
sti.write_text("@echo off\r\necho %s\r\nexit /b 1\r\n" % versjon)
else:
sti = katalog / navn
sti.write_text(
"#!/bin/sh\n"
"# Answers the version probe as %s and fails the version gate.\n"
'case "$*" in\n'
" *print*) echo '%s'; exit 0 ;;\n"
"esac\n"
"exit 1\n" % (versjon, versjon)
)
sti.chmod(0o755)
return sti
def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one(tmp_path):
falsk = falsk_tolk(tmp_path, "python3.9")
avvist = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(falsk)),
input="",
capture_output=True,
text=True,
)
assert avvist.returncode != 0, (
"the launcher started on a 3.9 interpreter instead of refusing"
)
assert "3.10" in avvist.stderr, (
"the refusal must name the floor it enforced: %r" % (avvist.stderr,)
)
# With no candidate at all, the launcher must refuse rather than take
# whatever python3 the PATH offers -- which under an empty launchd PATH on
# this Mac is 3.9.6 (risk H2). Nothing in the branch above reaches this
# path, so it needs its own case: a mutation that reinstated an ungated
# PATH fallback survived the JOBBSOK_PYTHON case untouched.
falsk_bin = tmp_path / "bin"
falsk_bin.mkdir()
falsk_tolk(falsk_bin, "python3")
tom_rot = tmp_path / "tom-plugin-rot"
(tom_rot / "scripts").mkdir(parents=True)
miljo = dict(os.environ)
miljo.pop("JOBBSOK_PYTHON", None)
miljo.pop("CLAUDE_PLUGIN_DATA", None)
miljo["CLAUDE_PLUGIN_ROOT"] = str(tom_rot)
miljo["PATH"] = str(falsk_bin) + os.pathsep + os.path.dirname(sys.executable)
uten_kandidat = subprocess.run(
[sys.executable, LAUNCHER],
env=miljo,
input="",
capture_output=True,
text=True,
)
assert uten_kandidat.returncode != 0, (
"the launcher fell through to the python3 on PATH instead of refusing"
)
assert "refusing to start" in uten_kandidat.stderr, (
"the refusal must say so plainly: %r" % (uten_kandidat.stderr,)
)
# The other direction, over the real stdio loop: framing, flush and all.
forespoersler = (
json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": mcp_stdio.PROTOCOL_VERSION},
}
)
+ "\n"
+ json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"})
+ "\n"
+ "\n" # a blank line the loop must tolerate rather than answer
+ json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
+ "\n"
)
servert = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=sys.executable),
input=forespoersler,
capture_output=True,
text=True,
timeout=30,
)
assert servert.returncode == 0, servert.stderr
svar = [json.loads(line) for line in servert.stdout.splitlines() if line.strip()]
assert [s["id"] for s in svar] == [1, 2], (
"expected exactly two replies, one per request: %r" % (servert.stdout,)
)
assert svar[0]["result"]["serverInfo"]["name"] == "jobbsok-tools"
assert [t["name"] for t in svar[1]["result"]["tools"]] == VERKTOY
def cli(script, *argv):
"""Run one of the command-line entry points and return its stdout."""
env = dict(os.environ)
env.pop("JOBBSOK_WORKSPACE", None)
env.pop("CLAUDE_PLUGIN_DATA", None)
proc = subprocess.run(
[sys.executable, os.path.join(SCRIPTS, script)] + list(argv),
capture_output=True, text=True, env=env,
)
return proc.stdout
def test_the_m2_tools_answer_with_the_bytes_the_command_line_produces(
klient, empty_workspace, tmp_path
):
"""Parity is what makes the degradation branch honest.
Every skill degrades to reading what the CLI produced when the server is
absent. That instruction is only true if the two paths produce the same
content -- otherwise the degraded operator is reading a different report
and does not know it.
"""
for verktoy, argumenter, script, argv in (
("sak_status",
{"workspace": KORPUS, "today": TODAY, "format": "json"},
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--format", "json"]),
("sak_status_check",
{"workspace": KORPUS, "today": TODAY},
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--check"]),
("dagens",
{"workspace": KORPUS, "today": TODAY},
"dagens.py", ["--workspace", KORPUS, "--today", TODAY]),
):
fra_server = klient.call_text(verktoy, argumenter)
fra_kommandolinja = cli(script, *argv)
assert fra_server == fra_kommandolinja, (
"%s and the command line disagree; the degradation branch tells "
"the operator these are the same content" % verktoy
)
# `sak_status_check` must carry the verdict in the payload: over MCP there
# is no exit code, and a check whose answer was only an exit status would
# arrive at Cowork as an unqualified report.
svar = klient.call_text("sak_status_check", {"workspace": KORPUS, "today": TODAY})
assert "utdatert hurtigbuffer" in svar, (
"the corpus holds a deliberately divergent case and the check never "
"says so: %r" % (svar[-200:],)
)
# The writing tool, appended through each path into its own fresh
# workspace, because the log refuses a reused id by design.
from jobbsok_lib import paths as _paths
andre = str(tmp_path / "andre-workspace")
_paths.scaffold(andre)
post = {
"id": "b-0001", "dato": "2026-09-01T09:00:00+02:00", "kilde": "manuell",
"url": "https://stillinger.example/sak-1", "tittel": "Dataingeniør",
"arbeidsgiver": "Værøy Sjømat AS", "beslutning": "ja",
"arsak": ["fagomrade"], "notat": "treffer kjernen", "score_da": 78,
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68,
"selskapstype": 63},
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20,
"selskapstype": 10},
"vekt_hash": "sha256:" + "e4" * 32,
}
fra_server = klient.call_text(
"beslutning_append", {"workspace": empty_workspace, "post": post}
)
fra_kommandolinja = cli(
"beslutninger.py", "--workspace", andre, "--json", json.dumps(post)
)
assert fra_server == fra_kommandolinja
# And it actually wrote, in both places: a parity test over two no-ops
# would pass just as quietly.
for rot in (empty_workspace, andre):
with open(os.path.join(rot, "beslutninger.jsonl"), "r", encoding="utf-8") as handle:
assert len(handle.read().splitlines()) == 1
def tolk_uten_vakt(katalog, navn, versjon="3.14.0", har_vakt=False):
"""A program that clears the version gate and answers the guard probe.
Two probes, and the fake has to tell them apart: the version gate runs
`sys.version_info >= ...` and the guard gate runs `import
llm_ingestion_guard`. With ``har_vakt`` false the second one fails, which
is exactly the measured M1 state -- an interpreter at 3.14.0 that clears
the floor and has no guard (O4 in docs/cowork-probe.md).
The Windows form is written but NOT measured; this suite has only ever run
on macOS.
"""
if os.name == "nt":
sti = katalog / (navn + ".cmd")
linjer = ["@echo off"]
if not har_vakt:
linjer.append('echo %* | findstr /C:"llm_ingestion_guard" >nul && exit /b 1')
linjer.append('echo %%* | findstr /C:"print" >nul && (echo %s & exit /b 0)' % versjon)
linjer.append("exit /b 0")
sti.write_text("\r\n".join(linjer) + "\r\n")
return sti
avvis = "" if har_vakt else " *llm_ingestion_guard*) exit 1 ;;\n"
sti = katalog / navn
sti.write_text(
"#!/bin/sh\n"
"# Clears the version gate at %s; the guard import is the variable.\n"
'case "$*" in\n'
"%s"
" *print*) echo '%s'; exit 0 ;;\n"
"esac\n"
"exit 0\n" % (versjon, avvis, versjon)
)
sti.chmod(0o755)
return sti
def test_the_launcher_refuses_to_serve_without_the_ingestion_guard(tmp_path):
"""O4, decided in docs/cowork-probe.md and implemented here.
The measured M1 incident: the server served on the host's 3.14.0, cleared
the version floor, and reported `guard_versjon: null` because nothing had
ever asked. From M2 the case folder and the decision log persist employer
names, titles, URLs and notes that came out of a listing, so serving
without the guard is a defect rather than an observation.
Failing closed is cheap here and that is the deciding argument: every
skill degrades to manual paste with the server absent, so a launcher that
refuses removes a connector -- it does not strand anyone.
"""
(tmp_path / "uten").mkdir()
uten = tolk_uten_vakt(tmp_path / "uten", "python3.14", har_vakt=False)
avvist = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(uten)),
input="",
capture_output=True,
text=True,
timeout=30,
)
assert avvist.returncode != 0, (
"the launcher served on an interpreter that cannot import the guard"
)
assert "llm_ingestion_guard" in avvist.stderr, (
"the refusal must name what was missing: %r" % (avvist.stderr,)
)
assert "scripts/bootstrap.py" in avvist.stderr, (
"the refusal must name the command that fixes it, or it is a dead end: "
"%r" % (avvist.stderr,)
)
# The positive control, without which the assertion above could be passing
# on the version gate, on a missing file, or on nothing at all: the same
# fake with the guard import answering must NOT be refused.
(tmp_path / "med").mkdir()
med = tolk_uten_vakt(tmp_path / "med", "python3.14", har_vakt=True)
sluppet_gjennom = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(med)),
input="",
capture_output=True,
text=True,
timeout=30,
)
assert sluppet_gjennom.returncode == 0, (
"an interpreter that clears both gates was still refused: %r"
% (sluppet_gjennom.stderr,)
)
assert "refusing" not in sluppet_gjennom.stderr