mirror of
https://github.com/tw93/Kami.git
synced 2026-09-18 10:18:26 +08:00
c9617b6aad
Separate HTML visibility evidence from document checks and group tests by responsibility without changing the CLI. Restore demo scan coverage after the layout split, reject missing repository surfaces, and keep generated examples out of source control.
291 lines
14 KiB
Python
291 lines
14 KiB
Python
"""MCP protocol and tool contract tests."""
|
|
from __future__ import annotations
|
|
|
|
from support import REPO_ROOT, SKILL_ROOT, check, skip
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from optional_deps import MissingDepError, require_pymupdf
|
|
from pathlib import Path
|
|
from shared import HTML_TEMPLATES, MARP_TEMPLATES
|
|
|
|
|
|
def test_mcp_server_stdio_protocol() -> None:
|
|
"""The server must speak newline-delimited JSON-RPC with nothing else on stdout."""
|
|
script = SKILL_ROOT / "scripts" / "mcp_server.py"
|
|
msgs = [
|
|
{"jsonrpc": "2.0", "method": "initialize", "params": {}},
|
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {"protocolVersion": "2025-03-26"}},
|
|
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
{"jsonrpc": "2.0", "id": None, "method": "ping"},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
|
"params": {"name": "kami_templates", "arguments": {}}},
|
|
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
|
"params": {"name": "kami_doctor", "arguments": {}}},
|
|
{"jsonrpc": "2.0", "id": 5, "method": "tools/call",
|
|
"params": {"name": "nope", "arguments": {}}},
|
|
]
|
|
stdin = "".join(json.dumps(m) + "\n" for m in msgs)
|
|
result = subprocess.run(
|
|
[sys.executable, str(script)],
|
|
input=stdin, capture_output=True, text=True, cwd=REPO_ROOT, timeout=60,
|
|
)
|
|
try:
|
|
replies = {m.get("id"): m for m in map(json.loads, result.stdout.strip().splitlines())}
|
|
except json.JSONDecodeError:
|
|
check("mcp server stdout is newline-delimited JSON", False, result.stdout[:200])
|
|
return
|
|
init = replies.get(1, {}).get("result", {})
|
|
check("mcp initialize echoes protocol version and names the server",
|
|
init.get("protocolVersion") == "2025-03-26"
|
|
and init.get("serverInfo", {}).get("name") == "kami",
|
|
json.dumps(init)[:200])
|
|
tools = [t["name"] for t in replies.get(2, {}).get("result", {}).get("tools", [])]
|
|
check("mcp tools/list exposes the five kami tools",
|
|
tools == ["kami_templates", "kami_doctor", "kami_render", "kami_check", "kami_screenshot"],
|
|
str(tools))
|
|
body = replies.get(3, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
|
|
payload = json.loads(body)
|
|
check("mcp kami_templates returns registries and schema types",
|
|
set(payload.get("document_templates", {})) == set(HTML_TEMPLATES)
|
|
and set(payload.get("pptx_templates", {})) == {"slides", "slides-en"}
|
|
and set(payload.get("marp_templates", {})) == set(MARP_TEMPLATES)
|
|
and payload.get("content_schema_types"),
|
|
body[:200])
|
|
doctor_body = replies.get(4, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
|
|
doctor = json.loads(doctor_body)
|
|
check("mcp kami_doctor reports dependencies, fonts, and capabilities",
|
|
isinstance(doctor.get("ok"), bool)
|
|
and len(doctor.get("dependencies", [])) >= 3
|
|
and len(doctor.get("fonts", [])) >= 3
|
|
and "pdf_visual_review" in doctor.get("capabilities", {})
|
|
and "strict_math" in doctor.get("capabilities", {}),
|
|
doctor_body[:300])
|
|
check("mcp unknown tool returns a JSON-RPC error",
|
|
"error" in replies.get(5, {}), json.dumps(replies.get(5, {}))[:200])
|
|
check("mcp explicit null id remains a request",
|
|
replies.get(None, {}).get("result") == {}, str(replies.get(None)))
|
|
check("mcp notifications produced no reply", len(replies) == 6, str(sorted(
|
|
("null" if key is None else str(key)) for key in replies
|
|
)))
|
|
|
|
|
|
def test_mcp_check_returns_stable_findings_and_coverage() -> None:
|
|
from mcp_server import CHECK_REGISTRY, tool_check
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
clean = Path(d) / "clean.html"
|
|
broken = Path(d) / "broken.html"
|
|
math_broken = Path(d) / "math-broken.html"
|
|
clean.write_text("<html><body><p>Ready</p></body></html>", encoding="utf-8")
|
|
broken.write_text("<html><body><p>{{ missing }}</p></body></html>", encoding="utf-8")
|
|
math_broken.write_text(
|
|
r"<html><body><p>\(x^2\)</p></body></html>",
|
|
encoding="utf-8",
|
|
)
|
|
invalid_content = Path(d) / "invalid-content.json"
|
|
invalid_content.write_text(
|
|
json.dumps({"type": "letter", "lang": "en", "content": {}}),
|
|
encoding="utf-8",
|
|
)
|
|
valid_content = Path(d) / "valid-content.json"
|
|
valid_content.write_text(json.dumps({
|
|
"type": "letter",
|
|
"lang": "en",
|
|
"content": {
|
|
"sender": "Ada Lovelace, London",
|
|
"date": "2026-07-13",
|
|
"recipient": "Charles Babbage",
|
|
"salutation": "Dear Charles,",
|
|
"paragraphs": [
|
|
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
|
|
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
|
|
"My ask is specific: review the table this month so we can test it on the mill.",
|
|
],
|
|
"signoff": "Sincerely,",
|
|
"signature": "Ada",
|
|
},
|
|
}), encoding="utf-8")
|
|
clean_result = tool_check({"path": str(clean)})
|
|
broken_result = tool_check({"path": str(broken)})
|
|
math_broken_result = tool_check({"path": str(math_broken)})
|
|
invalid_content_result = tool_check({
|
|
"path": str(clean), "content": str(invalid_content),
|
|
})
|
|
missing_coverage_result = tool_check({
|
|
"path": str(clean), "content": str(valid_content),
|
|
})
|
|
|
|
check("MCP check registry carries unique stable rule IDs",
|
|
CHECK_REGISTRY and clean_result["ruleset_version"] == 3
|
|
and len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
|
|
and all({"scope", "severity", "required_engine", "explanation"} <= set(rule)
|
|
for rule in CHECK_REGISTRY.values()),
|
|
str(CHECK_REGISTRY))
|
|
check("MCP clean check returns coverage without findings",
|
|
clean_result["ok"] is True
|
|
and clean_result["degraded"] is False
|
|
and clean_result["findings"] == []
|
|
and [item["id"] for item in clean_result["coverage"]]
|
|
== ["html.placeholders", "html.math", "html.markdown-residue"]
|
|
and clean_result["report"],
|
|
json.dumps(clean_result)[:500])
|
|
check("MCP failed check returns a stable finding and legacy report",
|
|
broken_result["ok"] is False
|
|
and broken_result["findings"][0]["id"] == "html.placeholders"
|
|
and broken_result["findings"][0]["status"] == "failed"
|
|
and "placeholder" in broken_result["report"].lower(),
|
|
json.dumps(broken_result)[:500])
|
|
check("MCP HTML check rejects unrendered standard LaTeX",
|
|
math_broken_result["ok"] is False
|
|
and [finding["id"] for finding in math_broken_result["findings"]]
|
|
== ["html.math"],
|
|
json.dumps(math_broken_result)[:500])
|
|
invalid_ids = [item["id"] for item in invalid_content_result["findings"]]
|
|
invalid_coverage = {
|
|
item["id"]: item for item in invalid_content_result["coverage"]
|
|
}
|
|
check("MCP labels invalid content IR as contract failure before coverage",
|
|
"content.contract" in invalid_ids
|
|
and "content.coverage" not in invalid_ids
|
|
and invalid_coverage["content.coverage"]["status"] == "not_run"
|
|
and invalid_coverage["content.coverage"]["blocked_by"]
|
|
== "content.contract",
|
|
json.dumps(invalid_content_result)[:800])
|
|
missing_ids = [item["id"] for item in missing_coverage_result["findings"]]
|
|
missing_statuses = {
|
|
item["id"]: item["status"]
|
|
for item in missing_coverage_result["coverage"]
|
|
}
|
|
check("MCP runs coverage once after a valid content contract",
|
|
missing_ids == ["content.coverage"]
|
|
and missing_statuses["content.contract"] == "passed"
|
|
and missing_statuses["content.coverage"] == "failed",
|
|
json.dumps(missing_coverage_result)[:800])
|
|
|
|
|
|
def test_mcp_server_rejects_bad_frames_without_exiting() -> None:
|
|
"""Wrong-shaped JSON-RPC frames return errors and do not kill the server."""
|
|
script = SKILL_ROOT / "scripts" / "mcp_server.py"
|
|
msgs = [
|
|
[],
|
|
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": "bad"},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
"params": {"name": "kami_templates", "arguments": ["bad"]}},
|
|
{"jsonrpc": "2.0", "id": 3, "method": "ping"},
|
|
]
|
|
result = subprocess.run(
|
|
[sys.executable, str(script)],
|
|
input="".join(json.dumps(m) + "\n" for m in msgs),
|
|
capture_output=True, text=True, cwd=REPO_ROOT, timeout=60,
|
|
)
|
|
replies = [json.loads(line) for line in result.stdout.strip().splitlines()]
|
|
by_id = {reply.get("id"): reply for reply in replies}
|
|
check("mcp malformed frames keep the process alive",
|
|
result.returncode == 0 and len(replies) == 4 and "result" in by_id.get(3, {}),
|
|
(result.stdout + result.stderr)[:400])
|
|
check("mcp wrong params and arguments return invalid-params errors",
|
|
by_id.get(1, {}).get("error", {}).get("code") == -32602
|
|
and by_id.get(2, {}).get("error", {}).get("code") == -32602,
|
|
result.stdout[:400])
|
|
|
|
|
|
def test_mcp_all_tools_succeed_over_stdio() -> None:
|
|
"""Exercise render, check, and screenshot through the installed protocol path."""
|
|
try:
|
|
from optional_deps import require_pypdf_reader, require_weasyprint_html
|
|
require_weasyprint_html()
|
|
require_pypdf_reader()
|
|
require_pymupdf()
|
|
except MissingDepError as exc:
|
|
skip("MCP all-tools stdio success path", str(exc), ci_required=True)
|
|
return
|
|
|
|
script = SKILL_ROOT / "scripts" / "mcp_server.py"
|
|
with tempfile.TemporaryDirectory() as d:
|
|
root = Path(d)
|
|
html = root / "source.html"
|
|
pdf = root / "output.pdf"
|
|
html.write_text(
|
|
"<!doctype html><html><head><style>"
|
|
"@page{size:A4;margin:20mm}body{font-family:serif}"
|
|
"</style></head><body><h1>Kami MCP smoke</h1><p>Rendered.</p></body></html>",
|
|
encoding="utf-8",
|
|
)
|
|
msgs = [
|
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {"protocolVersion": "2025-06-18"}},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
"params": {"name": "kami_render", "arguments": {
|
|
"html": str(html), "out": str(pdf)}}},
|
|
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
|
"params": {"name": "kami_check", "arguments": {"path": str(html)}}},
|
|
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
|
"params": {"name": "kami_screenshot", "arguments": {"pdf": str(pdf)}}},
|
|
]
|
|
result = subprocess.run(
|
|
[sys.executable, str(script)],
|
|
input="".join(json.dumps(m) + "\n" for m in msgs),
|
|
capture_output=True, text=True, cwd=REPO_ROOT, timeout=120,
|
|
)
|
|
try:
|
|
replies = {m.get("id"): m for m in map(json.loads, result.stdout.strip().splitlines())}
|
|
payloads = {
|
|
reply_id: json.loads(
|
|
replies[reply_id]["result"]["content"][0]["text"]
|
|
)
|
|
for reply_id in (2, 3, 4)
|
|
}
|
|
except (KeyError, json.JSONDecodeError) as exc:
|
|
check("MCP all-tools success path returns JSON results", False,
|
|
f"{exc}: {(result.stdout + result.stderr)[:500]}")
|
|
return
|
|
|
|
render_result = payloads[2]
|
|
check_result = payloads[3]
|
|
screenshot_result = payloads[4]
|
|
check("MCP render succeeds over stdio",
|
|
result.returncode == 0 and render_result.get("pages") == 1 and pdf.is_file(),
|
|
json.dumps(render_result)[:300])
|
|
check("MCP check succeeds over stdio",
|
|
check_result.get("ok") is True and check_result.get("exit_code") == 0,
|
|
json.dumps(check_result)[:300])
|
|
page_paths = [Path(path) for path in screenshot_result.get("pages", [])]
|
|
check("MCP screenshot returns evidence without a false perceptual verdict",
|
|
"ok" not in screenshot_result
|
|
and screenshot_result.get("rasterized") is True
|
|
and screenshot_result.get("review_pending") is True
|
|
and screenshot_result.get("font_check", {}).get("ok") is True
|
|
and len(page_paths) == 1 and all(path.is_file() for path in page_paths),
|
|
json.dumps(screenshot_result)[:500])
|
|
|
|
|
|
def test_mcp_render_guards_source_and_output_types() -> None:
|
|
from mcp_server import tool_render
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
root = Path(d)
|
|
html = root / "source.html"
|
|
html.write_text("<html><body>safe source</body></html>", encoding="utf-8")
|
|
original = html.read_bytes()
|
|
hardlink = root / "hardlink.pdf"
|
|
hardlink.hardlink_to(html)
|
|
victim = root / "victim.txt"
|
|
victim.write_bytes(b"do-not-overwrite")
|
|
symlink = root / "symlink.pdf"
|
|
symlink.symlink_to(victim)
|
|
rejected = 0
|
|
for out in (html, hardlink, symlink, root / "not-pdf.txt"):
|
|
try:
|
|
tool_render({"html": str(html), "out": str(out)})
|
|
except ValueError:
|
|
rejected += 1
|
|
check("mcp render rejects source aliases and non-PDF outputs",
|
|
rejected == 4 and html.read_bytes() == original
|
|
and victim.read_bytes() == b"do-not-overwrite",
|
|
f"rejected={rejected} source_changed={html.read_bytes() != original}")
|