feat(m1): add jobbsok-tools stdio mcp server and launcher
This commit is contained in:
parent
28a950bcea
commit
89bcd1b038
6 changed files with 1057 additions and 0 deletions
409
scripts/jobbsok_tools.py
Normal file
409
scripts/jobbsok_tools.py
Normal 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())
|
||||
107
scripts/jobbsok_tools_launch.sh
Executable file
107
scripts/jobbsok_tools_launch.sh
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
#!/bin/bash
|
||||
# Start the jobbsok-tools stdio MCP server on an interpreter that is new enough.
|
||||
#
|
||||
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2,
|
||||
# and a multibyte character has crashed a `set -u` script here before.
|
||||
#
|
||||
# Why this file exists at all, rather than .mcp.json naming python3 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 python3 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.sh 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. python3 on PATH -- last resort, and only at 3.10 or newer.
|
||||
|
||||
set -eu
|
||||
|
||||
MIN_MAJOR=3
|
||||
MIN_MINOR=10
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
|
||||
PLUGIN_ROOT="$CLAUDE_PLUGIN_ROOT"
|
||||
else
|
||||
PLUGIN_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
fi
|
||||
SERVER="${PLUGIN_ROOT}/scripts/jobbsok_tools.py"
|
||||
|
||||
version_ok() {
|
||||
"$1" -c "import sys; sys.exit(0 if sys.version_info >= ($MIN_MAJOR, $MIN_MINOR) else 1)" \
|
||||
>/dev/null 2>&1
|
||||
}
|
||||
|
||||
version_of() {
|
||||
"$1" -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>/dev/null || echo "ukjent"
|
||||
}
|
||||
|
||||
usable() {
|
||||
[ -n "$1" ] || return 1
|
||||
command -v "$1" >/dev/null 2>&1 || return 1
|
||||
version_ok "$1"
|
||||
}
|
||||
|
||||
PYTHON_BIN=""
|
||||
|
||||
if [ -n "${JOBBSOK_PYTHON:-}" ]; then
|
||||
if ! command -v "$JOBBSOK_PYTHON" >/dev/null 2>&1; then
|
||||
echo "jobbsok-tools: JOBBSOK_PYTHON=$JOBBSOK_PYTHON is not executable." >&2
|
||||
echo "jobbsok-tools: refusing to start. Point it at a Python ${MIN_MAJOR}.${MIN_MINOR}+ interpreter." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! version_ok "$JOBBSOK_PYTHON"; then
|
||||
echo "jobbsok-tools: JOBBSOK_PYTHON=$JOBBSOK_PYTHON is Python $(version_of "$JOBBSOK_PYTHON")." >&2
|
||||
echo "jobbsok-tools: ${MIN_MAJOR}.${MIN_MINOR}+ is required; refusing to start on it." >&2
|
||||
exit 1
|
||||
fi
|
||||
PYTHON_BIN="$JOBBSOK_PYTHON"
|
||||
fi
|
||||
|
||||
if [ -z "$PYTHON_BIN" ] && [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
|
||||
CANDIDATE="${CLAUDE_PLUGIN_DATA}/venv/bin/python"
|
||||
if usable "$CANDIDATE"; then
|
||||
PYTHON_BIN="$CANDIDATE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
CANDIDATE="${PLUGIN_ROOT}/.venv/bin/python3"
|
||||
if usable "$CANDIDATE"; then
|
||||
PYTHON_BIN="$CANDIDATE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
CANDIDATE=$(command -v python3 2>/dev/null || true)
|
||||
if usable "$CANDIDATE"; then
|
||||
PYTHON_BIN="$CANDIDATE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
echo "jobbsok-tools: found no Python ${MIN_MAJOR}.${MIN_MINOR}+ interpreter." >&2
|
||||
echo "jobbsok-tools: tried JOBBSOK_PYTHON, \$CLAUDE_PLUGIN_DATA/venv, \$CLAUDE_PLUGIN_ROOT/.venv and python3 on PATH." >&2
|
||||
echo "jobbsok-tools: run 'bash scripts/bootstrap.sh' against the installed plugin, or set JOBBSOK_PYTHON." >&2
|
||||
echo "jobbsok-tools: refusing to start rather than serving on an interpreter below ${MIN_MAJOR}.${MIN_MINOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SERVER" ]; then
|
||||
echo "jobbsok-tools: server not found at $SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" "$SERVER" "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue