Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ jobs:
cache-dependency-path: playbook/pnpm-lock.yaml
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
# KR-A07's sandbox cases run under bubblewrap and fail (by design) without it.
- name: Install bubblewrap and allow unprivileged user namespaces
run: |
sudo apt-get update && sudo apt-get install -y bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
bwrap --ro-bind / / true
- run: cargo test --workspace
- name: kr0ki-mcp Python tests
run: |
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ crates/
| `KR0KI_TEST_BACKEND` | unset | Live render test backend; use the local/private kroki-compatible service |
| `KR0KI_SYSMLV2_BASE_URL` | unset | Live SysML-v2 client test target (`just sysml-api-up` serves one on `127.0.0.1:9000`; `just test-live-sysml`) |
| `OPENAI_API_URL` | (in `.env`) | The agent's / `/health`'s LLM. Boot default is Qwen3.8 NEO-CODER + mmproj (vision) on `127.0.0.1:8002/v1` via `deploy/systemd/b00t-hive-inference-heretic-neo-coder.service`; Qwen3.6 units are disabled |
| `KR0KI_IDENTITIES_FILE` | unset | Per-identity bearer tokens (stored as SHA-256) and capability grants (`render`, `model.read`, `model.commit`, `assurance.read`, `assurance.propose`, `assurance.verify`, `audit.read`, `ui.steer`, `admin`). When set it **replaces** the shared `KR0KI_AUTH_TOKEN`; every route is classified in `kr0ki-server/src/gateway.rs` and an unclassified route needs `admin`. A tool's visibility or annotations are never what enforces anything |
| `KR0KI_AUDIT_FILE` | `./.kr0ki-audit/audit.jsonl` when identities are set | Hash-chained, append-only audit log (one record per invocation, permitted or denied). If a record cannot be written the request is refused (503) |
| `KR0KI_ASSURANCE_BASELINE` / `KR0KI_REPO_ROOT` / `KR0KI_EVIDENCE_DIR` / `KR0KI_ASSURANCE_PREFIX` | `docs/assurance/kr0ki.assurance.toml` / `.` / `./.kr0ki-evidence` / `kr0ki:assurance:` | Enable `/assurance/*` (the assurance thread) and where its baseline, repository and evidence live |
| `KR0KI_SYSML_MCP_URL` | unset | SysML v2 MCP sidecar (`containers/kr0ki-sysml-mcp`, `127.0.0.1:8790`); enables `/sysml/{validate,parse,symbols,summary}` and the `sysml_mcp` health check |

---
Expand Down
5 changes: 4 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 117 additions & 2 deletions containers/kr0ki-mcp/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
import base64
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request

import manifest_dispatch
Expand Down Expand Up @@ -39,6 +41,77 @@ def mcp_tools_list():
]


MCP_HEADERS = {"X-Kr0ki-Transport": "mcp"}


def fetch_list(path):
"""A manifest the server publishes (`/mcp/resources`, `/mcp/prompts`)."""
_, body = manifest_dispatch.http_call("GET", f"{BASE_URL}{path}", headers=MCP_HEADERS)
return json.loads(body)


def resource_templates():
return [
{
"uriTemplate": r["uriTemplate"],
"name": r["name"],
"description": r["description"],
"mimeType": r["mimeType"],
}
for r in fetch_list("/mcp/resources")
]


def match_resource(uri, manifest):
"""The manifest entry whose `uriTemplate` matches `uri`, with its variables decoded."""
for entry in manifest:
pattern = re.escape(entry["uriTemplate"])
pattern = re.sub(r"\\\{(\w+)\\\}", r"(?P<\1>[^/]+)", pattern)
m = re.fullmatch(pattern, uri)
if m:
return entry, {k: urllib.parse.unquote(v) for k, v in m.groupdict().items()}
return None, None


def read_resource(uri):
"""Resolve a resource URI to the GET route that serves it. No logic of its own: the server
authorises it exactly as it authorises the same request made directly."""
entry, variables = match_resource(uri, fetch_list("/mcp/resources"))
if entry is None:
raise KeyError(f"unknown resource: {uri}")
binding = entry["httpBinding"]
path, query = binding["pathTemplate"], {}
for arg in binding["args"]:
value = variables.get(arg["name"])
if value is None:
continue
if arg["placement"] == "path":
path = path.replace("{" + arg["name"] + "}", urllib.parse.quote(value, safe=""))
else:
query[arg["name"]] = value
url = f"{BASE_URL}{path}" + ("?" + urllib.parse.urlencode(query) if query else "")
content_type, body = manifest_dispatch.http_call("GET", url, headers=MCP_HEADERS)
return {"uri": uri, "mimeType": content_type or entry["mimeType"], "text": body.decode("utf-8", "replace")}


def render_prompt(prompt, arguments):
"""Fill `{{arg}}` placeholders once (a value is never re-expanded). A required argument must
be present and non-empty; an argument the prompt does not declare is refused."""
declared = {a["name"]: a for a in prompt["arguments"]}
unknown = [k for k in arguments if k not in declared]
if unknown:
raise ValueError(f"unknown argument: {unknown[0]}")
values = {}
for name, spec in declared.items():
value = str(arguments.get(name, "")).strip()
if not value:
if spec["required"]:
raise ValueError(f"prompt {prompt['name']} requires argument {name}")
continue
values[name] = value
return re.sub(r"\{\{(\w+)\}\}", lambda m: values.get(m.group(1), m.group(0)), prompt["template"])


def call_tool(arguments):
name = arguments.get("name")
params = arguments.get("arguments", {})
Expand All @@ -64,7 +137,9 @@ def call_tool(arguments):
output = params.get("output", "svg")

try:
content_type, response_body = manifest_dispatch.http_call(method, url, body)
content_type, response_body = manifest_dispatch.http_call(
method, url, body, headers={"X-Kr0ki-Transport": "mcp"}
)
except urllib.error.HTTPError as exc:
return tool_error(f"kr0ki rejected the request ({exc.code}): {exc.read().decode('utf-8', 'replace')}")
except urllib.error.URLError as exc:
Expand All @@ -91,7 +166,7 @@ def handle(message):
request_id,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
"serverInfo": {"name": "kr0ki-mcp", "version": "0.2.0"},
},
)
Expand All @@ -100,6 +175,46 @@ def handle(message):
return response(request_id, {"tools": mcp_tools_list()})
except (urllib.error.URLError, urllib.error.HTTPError) as exc:
return error(request_id, -32000, f"failed to fetch tool manifest: {exc}")
if method == "resources/templates/list":
try:
return response(request_id, {"resourceTemplates": resource_templates()})
except (urllib.error.URLError, urllib.error.HTTPError) as exc:
return error(request_id, -32000, f"failed to fetch resources: {exc}")
if method == "resources/list":
return response(request_id, {"resources": []})
if method == "resources/read":
uri = message.get("params", {}).get("uri", "")
try:
return response(request_id, {"contents": [read_resource(uri)]})
except KeyError as exc:
return error(request_id, -32602, str(exc.args[0]))
except urllib.error.HTTPError as exc:
return error(request_id, -32000, f"kr0ki rejected the request ({exc.code}): {exc.read().decode('utf-8', 'replace')}")
except urllib.error.URLError as exc:
return error(request_id, -32000, f"kr0ki request failed: {exc}")
if method == "prompts/list":
try:
return response(
request_id,
{"prompts": [{"name": p["name"], "description": p["description"], "arguments": p["arguments"]} for p in fetch_list("/mcp/prompts")]},
)
except (urllib.error.URLError, urllib.error.HTTPError) as exc:
return error(request_id, -32000, f"failed to fetch prompts: {exc}")
if method == "prompts/get":
params = message.get("params", {})
try:
prompt = next((p for p in fetch_list("/mcp/prompts") if p["name"] == params.get("name")), None)
if prompt is None:
return error(request_id, -32602, f"unknown prompt: {params.get('name')}")
text = render_prompt(prompt, params.get("arguments") or {})
except ValueError as exc:
return error(request_id, -32602, str(exc))
except (urllib.error.URLError, urllib.error.HTTPError) as exc:
return error(request_id, -32000, f"failed to fetch prompts: {exc}")
return response(
request_id,
{"description": prompt["description"], "messages": [{"role": "user", "content": {"type": "text", "text": text}}]},
)
if method == "tools/call":
return response(request_id, call_tool(message.get("params", {})))
if method == "ping":
Expand Down
91 changes: 91 additions & 0 deletions containers/kr0ki-mcp/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,96 @@ def test_http_call_headers_override_defaults(self, urlopen):
self.assertEqual(request.get_header("Content-type"), "application/json")



RESOURCES = [
{
"uriTemplate": "kr0ki://requirement/{id}",
"name": "requirement",
"description": "d",
"mimeType": "application/json",
"httpBinding": {"method": "GET", "pathTemplate": "/assurance/requirements/{id}", "args": [{"name": "id", "placement": "path"}]},
},
{
"uriTemplate": "kr0ki://evidence/{requirement}",
"name": "evidence",
"description": "d",
"mimeType": "application/json",
"httpBinding": {"method": "GET", "pathTemplate": "/assurance/evidence", "args": [{"name": "requirement", "placement": "query"}]},
},
]

PROMPTS = [
{
"name": "explain_gap",
"description": "Explain a gap.",
"arguments": [{"name": "id", "description": "Requirement id.", "required": True}],
"template": "Explain {{id}}: call trace_requirement with id={{id}}.",
},
{"name": "next", "description": "d", "arguments": [], "template": "Do the next slice."},
]


class ResourcesAndPromptsTest(unittest.TestCase):
def test_a_resource_uri_resolves_to_its_get_route_with_path_and_query_args(self):
with patch("manifest_dispatch.http_call") as call:
call.side_effect = [
("application/json", json.dumps(RESOURCES).encode()),
("application/json", b'{"requirement": {"id": "KR-A01"}}'),
]
result = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://requirement/KR-A01"}})
self.assertEqual(call.call_args_list[1][0][:2], ("GET", f"{bridge.BASE_URL}/assurance/requirements/KR-A01"))
self.assertEqual(call.call_args_list[1][1]["headers"], {"X-Kr0ki-Transport": "mcp"})
self.assertIn("KR-A01", result["result"]["contents"][0]["text"])

call.side_effect = [("application/json", json.dumps(RESOURCES).encode()), ("application/json", b"{}")]
bridge.handle({"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "kr0ki://evidence/KR-A02"}})
self.assertEqual(call.call_args_list[3][0][1], f"{bridge.BASE_URL}/assurance/evidence?requirement=KR-A02")

def test_a_path_variable_is_percent_encoded_so_it_cannot_change_the_route(self):
with patch("manifest_dispatch.http_call") as call:
call.side_effect = [("application/json", json.dumps(RESOURCES).encode()), ("application/json", b"{}")]
bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://requirement/..%2Fadmin"}})
self.assertNotIn("/../admin", call.call_args_list[1][0][1])
self.assertTrue(call.call_args_list[1][0][1].endswith("..%2Fadmin"))

def test_an_unknown_resource_is_an_error_not_a_guess(self):
with patch("manifest_dispatch.http_call") as call:
call.return_value = ("application/json", json.dumps(RESOURCES).encode())
result = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://nope/x"}})
self.assertEqual(result["error"]["code"], -32602)

def test_a_server_refusal_on_a_resource_read_is_surfaced_with_its_status(self):
err = urllib.error.HTTPError("u", 403, "Forbidden", {}, __import__("io").BytesIO(b'{"error":"forbidden"}'))
with patch("manifest_dispatch.http_call") as call:
call.side_effect = [("application/json", json.dumps(RESOURCES).encode()), err]
result = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://requirement/KR-A01"}})
self.assertIn("403", result["error"]["message"])

def test_prompts_list_and_get_render_arguments_once(self):
with patch("manifest_dispatch.http_call") as call:
call.return_value = ("application/json", json.dumps(PROMPTS).encode())
listed = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "prompts/list"})
self.assertEqual([p["name"] for p in listed["result"]["prompts"]], ["explain_gap", "next"])
got = bridge.handle({"jsonrpc": "2.0", "id": 2, "method": "prompts/get", "params": {"name": "explain_gap", "arguments": {"id": "KR-A06"}}})
text = got["result"]["messages"][0]["content"]["text"]
self.assertIn("trace_requirement with id=KR-A06", text)
self.assertNotIn("{{", text)

def test_prompt_arguments_are_checked_and_never_re_expanded(self):
with patch("manifest_dispatch.http_call") as call:
call.return_value = ("application/json", json.dumps(PROMPTS).encode())
def get(args):
return bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "prompts/get", "params": {"name": "explain_gap", "arguments": args}})
self.assertEqual(get({})["error"]["code"], -32602)
self.assertEqual(get({"id": " "})["error"]["code"], -32602)
self.assertEqual(get({"id": "x", "evil": "y"})["error"]["code"], -32602)
injected = get({"id": "{{id}}"})["result"]["messages"][0]["content"]["text"]
self.assertIn("id={{id}}", injected)

def test_initialize_advertises_tools_resources_and_prompts(self):
caps = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize"})["result"]["capabilities"]
self.assertEqual(set(caps), {"tools", "resources", "prompts"})


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion crates/kr0ki-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" }
# exists after ufo-types#29's squash-merge into main (2026-09-20). Re-pinned
# to that merge commit on main -- bump to a `tag =` pin once ufo-types cuts
# a release past v0.15.0 including it.
ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "db8e22fc2c09ff1b58cc4effce6096d17a89f3e4", features = ["reqif"] }
ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "1846e6f20c68e8eb614556386a56cd3077446cdb", features = ["reqif"] }
# kr0ki#13 — box 5 (b00t-graph Turtle -> D2 route): the typed TypeRelationshipGraph /
# CytoscapeGraph the D2 emitter targets. No tagged release exists yet (checked
# 2026-09-16); pinned by git rev per this issue's own operator decision, to be
Expand Down
Loading
Loading