feat(m1): add jobbsok-tools stdio mcp server and launcher

This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 06:48:55 +02:00
commit 89bcd1b038
6 changed files with 1057 additions and 0 deletions

409
scripts/jobbsok_tools.py Normal file
View file

@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""The `jobbsok-tools` host MCP server (plan Step 12).
Cowork and the Claude Code CLI have to reach the same logic (operator
decision 7). The CLI reaches it by importing `scripts/`; Cowork reaches it
through a plugin-declared stdio MCP server, which is this file.
Three decisions are worth stating, because each of them is a place a later
hand would reasonably do the other thing:
**Standard library only, protocol written out by hand.** The official Python
SDK would add a PyPI dependency the Cowork sandbox cannot install, and would
cost import time at every cold start against an MCP start-up timeout. Three
JSON-RPC methods is less code than the dependency it would replace.
**Nothing heavy is imported at module load.** `kandidat_schema` and
`vurdering` are imported inside the handlers, not at the top. The server has
to answer `initialize` fast enough that Cowork does not time it out, and it
answers that one without touching either module.
**Every tool takes an explicit `workspace`.** `jobbsok_lib.paths` refuses to
guess at a home directory, and this process runs unsandboxed on the operator's
machine (risk C6). A default here would put the guess back at exactly the
layer where a caller-supplied path meets the file system. `selvsjekk` takes it
too, and reports what it resolved to -- a self-check that could not tell you
which workspace it was looking at would be answering a smaller question than
the one asked.
Also usable from the command line, which is not decoration: the
`kandidatvurdering` skill's degradation branch tells the operator to run the
arithmetic from a terminal when the server is absent, and a CLI that did not
exist would make that instruction false.
python3 scripts/jobbsok_tools.py # serve on stdio
python3 scripts/jobbsok_tools.py --verktoy # print tools/list
python3 scripts/jobbsok_tools.py kandidat_valider \\
--workspace ~/jobbsok-workspace # call one tool
"""
import json
import os
import sys
SERVER_NAME = "jobbsok-tools"
SERVER_VERSION = "0.1.0"
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
#: JSON-RPC error codes this server emits. Method-not-found and invalid-params
#: are the standard ones; a tool that raised is reported as an MCP tool error
#: (`isError`) rather than a protocol error, because the call was well-formed.
INVALID_PARAMS = -32602
METHOD_NOT_FOUND = -32601
STANDARD_PROFIL = "profil/kandidat.md"
WORKSPACE_ARG = {
"type": "string",
"description": (
"Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen "
"implisitt standard — serveren gjetter aldri på hjemmekatalogen."
),
}
TOOLS = [
{
"name": "kandidat_valider",
"description": (
"Valider kandidatprofilen mot kontrakten i build-brief 5.1 og "
"returner rapporten som JSON: gyldig, feil, advarsler, seksjoner, "
"vekter og vekter_kilde. Skriver ingenting."
),
"inputSchema": {
"type": "object",
"properties": {
"workspace": WORKSPACE_ARG,
"sti": {
"type": "string",
"description": (
"Sti til profilen, relativt til arbeidsområdet. "
"Standard: %s" % STANDARD_PROFIL
),
},
},
"required": ["workspace"],
"additionalProperties": False,
},
},
{
"name": "annonse_vurder",
"description": (
"Kjør hardfiltrene og regn ut den vektede scoren for en annonse "
"mot kandidatprofilen. Returnerer score, delscore, vekter, "
"vekt_hash, verdikt, avvisninger, advarsler og bekymringer. "
"Avvisninger og advarsler er to utfall, ikke ett. Skriver "
"ingenting."
),
"inputSchema": {
"type": "object",
"properties": {
"workspace": WORKSPACE_ARG,
"annonse": {
"type": "object",
"description": "Frontmatteren fra annonsen, som et kart.",
},
"brodtekst": {
"type": "string",
"description": "Annonsens brødtekst, uten frontmatter.",
},
"delscore": {
"type": "object",
"description": (
"Modellens bidrag: ett heltall 0–100 per kriterium. "
"Alle kriteriene må være med."
),
},
"bekymringer": {
"type": "array",
"items": {"type": "string"},
"description": "Fritekst som følger med uendret.",
},
"profil_sti": {
"type": "string",
"description": (
"Sti til profilen, relativt til arbeidsområdet. "
"Standard: %s" % STANDARD_PROFIL
),
},
},
"required": ["workspace", "annonse", "brodtekst", "delscore"],
"additionalProperties": False,
},
},
{
"name": "selvsjekk",
"description": (
"Rapporter hvilken tolk som faktisk serverer verktøyene, hvilken "
"versjon av ingest-vakten som er installert, hvilket byggestempel "
"plugin-en har, og hvilket arbeidsområde som ble resolvert. "
"Brukes til å avsløre en hurtigbufret plugin-versjon."
),
"inputSchema": {
"type": "object",
"properties": {"workspace": WORKSPACE_ARG},
"required": ["workspace"],
"additionalProperties": False,
},
},
]
class ToolError(Exception):
"""A tool refused or failed. Reported as an MCP tool error, not a protocol one."""
def log(message):
"""Diagnostics go to stderr; stdout carries protocol traffic only."""
print("[%s] %s" % (SERVER_NAME, message), file=sys.stderr, flush=True)
def plugin_root():
"""The plugin root: what the host declared, else this file's parent."""
declared = (os.environ.get("CLAUDE_PLUGIN_ROOT") or "").strip()
if declared:
return os.path.realpath(os.path.expanduser(declared))
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def til_json(payload):
"""The canonical JSON this server returns.
`ensure_ascii=False` for the same reason `kandidat_schema.report_json`
uses it: the payload quotes the operator's own words back, and an escaped
o-slash is no longer the word that was written.
"""
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
def _workspace(arguments):
from jobbsok_lib import paths
raw = arguments.get("workspace")
if not isinstance(raw, str) or not raw.strip():
raise ToolError(
"workspace mangler. Hvert verktøy tar et eksplisitt arbeidsområde; "
"det finnes ingen standard, og serveren gjetter ikke."
)
try:
return paths.workspace_root(raw)
except paths.WorkspaceError as exc:
raise ToolError(str(exc))
def _safe(root, relativ):
from jobbsok_lib import paths
try:
return paths.safe_join(root, *relativ.split("/"))
except paths.WorkspaceError as exc:
raise ToolError(str(exc))
def tool_kandidat_valider(arguments):
import kandidat_schema
root = _workspace(arguments)
relativ = arguments.get("sti") or STANDARD_PROFIL
_safe(root, relativ)
try:
rapport = kandidat_schema.validate_file(root, *relativ.split("/"))
except OSError as exc:
raise ToolError("kunne ikke lese %s: %s" % (relativ, exc))
return kandidat_schema.report_json(rapport)
def tool_annonse_vurder(arguments):
import vurdering
root = _workspace(arguments)
relativ = arguments.get("profil_sti") or STANDARD_PROFIL
_safe(root, relativ)
annonse = arguments.get("annonse")
if not isinstance(annonse, dict):
raise ToolError("annonse mangler; forventet frontmatteren som et kart")
brodtekst = arguments.get("brodtekst")
if not isinstance(brodtekst, str):
raise ToolError("brodtekst mangler; forventet annonsens brødtekst som tekst")
try:
profil = vurdering.les_profil_fil(root, *relativ.split("/"))
except OSError as exc:
raise ToolError("kunne ikke lese %s: %s" % (relativ, exc))
payload = {
"delscore": arguments.get("delscore"),
"bekymringer": arguments.get("bekymringer") or [],
}
try:
resultat = vurdering.vurder(profil, annonse, brodtekst, payload)
except vurdering.DelscoreError as exc:
raise ToolError(str(exc))
return til_json(resultat)
def tool_selvsjekk(arguments):
root = _workspace(arguments)
try:
import llm_ingestion_guard
guard = getattr(llm_ingestion_guard, "__version__", None)
except ImportError:
guard = None
stamp_sti = os.path.join(plugin_root(), "BUILD_STAMP")
try:
with open(stamp_sti, "r", encoding="utf-8") as handle:
stamp = handle.read().strip() or None
except OSError:
# Absent is a fact, and it is reported as one. A self-check that
# silently omitted the stamp would let a cached build pass unnoticed,
# which is the whole reason the stamp exists (risk H11).
stamp = None
return til_json(
{
"server": SERVER_NAME,
"server_versjon": SERVER_VERSION,
"python_executable": sys.executable,
"python_version": sys.version.split()[0],
"python_version_info": list(sys.version_info[:3]),
"guard_versjon": guard,
"plugin_root": plugin_root(),
"build_stamp": stamp,
"workspace": root,
}
)
HANDLERS = {
"kandidat_valider": tool_kandidat_valider,
"annonse_vurder": tool_annonse_vurder,
"selvsjekk": tool_selvsjekk,
}
def call_tool(name, arguments):
"""Run one tool and return its text, or raise :class:`ToolError`."""
handler = HANDLERS.get(name)
if handler is None:
raise ToolError("ukjent verktøy: %r" % (name,))
return handler(arguments or {})
def handle(request):
"""Return a response dict, or None for a notification (no reply expected)."""
method = request.get("method")
req_id = request.get("id")
if req_id is None:
return None
if method == "initialize":
params = request.get("params") or {}
# Echo the client's protocol version when it offers one; announcing a
# newer version than the client speaks is how handshakes fail silently.
result = {
"protocolVersion": params.get("protocolVersion") or DEFAULT_PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
params = request.get("params") or {}
name = params.get("name")
if name not in HANDLERS:
return _error(req_id, INVALID_PARAMS, "ukjent verktøy: %r" % (name,))
try:
text = call_tool(name, params.get("arguments"))
except ToolError as exc:
result = {"content": [{"type": "text", "text": str(exc)}], "isError": True}
except Exception as exc: # noqa: BLE001 -- a crash must not kill the server
log("%s raised %s: %s" % (name, type(exc).__name__, exc))
result = {
"content": [
{"type": "text", "text": "%s: %s" % (type(exc).__name__, exc)}
],
"isError": True,
}
else:
result = {"content": [{"type": "text", "text": text}], "isError": False}
elif method == "ping":
result = {}
else:
return _error(req_id, METHOD_NOT_FOUND, "ukjent metode: %r" % (method,))
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def _error(req_id, code, message):
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
def serve(stdin=None, stdout=None):
"""The stdio loop: newline-delimited JSON-RPC, nothing else on stdout."""
stdin = sys.stdin if stdin is None else stdin
stdout = sys.stdout if stdout is None else stdout
for line in stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except ValueError as exc:
log("undecodable line: %s" % exc)
continue
response = handle(request)
if response is not None:
stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
stdout.flush()
return 0
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if not argv:
log("serving on %s" % sys.version.split()[0])
return serve()
if argv[0] in ("-h", "--help"):
sys.stdout.write(__doc__)
return 0
if argv[0] == "--verktoy":
sys.stdout.write(til_json({"tools": TOOLS}))
return 0
name = argv[0]
arguments = {}
rest = argv[1:]
while rest:
flagg = rest.pop(0)
if not flagg.startswith("--"):
print("jobbsok-tools: forventet et --flagg, fikk %r" % flagg, file=sys.stderr)
return 2
if not rest:
print("jobbsok-tools: %s mangler en verdi" % flagg, file=sys.stderr)
return 2
verdi = rest.pop(0)
nokkel = flagg[2:]
# `--json` carries the arguments a shell cannot express: the listing
# mapping and the sub-score table. Scalars stay scalars.
if nokkel == "json":
arguments.update(json.loads(verdi))
else:
arguments[nokkel] = verdi
try:
sys.stdout.write(call_tool(name, arguments))
except ToolError as exc:
print("jobbsok-tools: %s" % exc, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())