#!/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())