jobbsok/scripts/jobbsok_tools.py

409 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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