272 lines
9.6 KiB
Python
272 lines
9.6 KiB
Python
|
|
"""End-to-end integration tests for the TypeScript MCP wrapper.
|
||
|
|
|
||
|
|
Spawns the built wrapper as a subprocess, sends MCP-shaped JSON-RPC requests,
|
||
|
|
and verifies the wrapper exposes the 5 Phase-1 tools and round-trips the
|
||
|
|
autistic-kernel profile defaults (D-12, D-11).
|
||
|
|
|
||
|
|
Plan 07.1-04 deviation Rule 3 update: pre-7.1 the spawned wrapper would
|
||
|
|
self-spawn the Python daemon on first connect (the spawn-fallback chain
|
||
|
|
in bridge.ts that 07.1-04 deleted). Tests in this file relied on either
|
||
|
|
that fallback OR the user's live production daemon. wrappers
|
||
|
|
are pure connectors — if no daemon is up, they throw
|
||
|
|
DaemonUnreachableError and exit non-zero. Tests now pre-start an
|
||
|
|
isolated tmp daemon (manual `python -m iai_mcp.daemon` per D7.1-09
|
||
|
|
backward compat) via the `daemon_sock` module fixture and pass the
|
||
|
|
socket path to the wrapper through IAI_DAEMON_SOCKET_PATH so the test
|
||
|
|
never touches the user's real ~/.iai-mcp.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import shutil
|
||
|
|
import signal
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import psutil
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
REPO = Path(__file__).resolve().parent.parent
|
||
|
|
WRAPPER = REPO / "mcp-wrapper"
|
||
|
|
|
||
|
|
|
||
|
|
def _wrapper_ready() -> bool:
|
||
|
|
return (WRAPPER / "dist" / "index.js").exists()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def built_wrapper() -> Path:
|
||
|
|
if not (WRAPPER / "node_modules").exists():
|
||
|
|
subprocess.run(["npm", "install"], cwd=WRAPPER, check=True)
|
||
|
|
subprocess.run(["npm", "run", "build"], cwd=WRAPPER, check=True)
|
||
|
|
dist = WRAPPER / "dist" / "index.js"
|
||
|
|
assert dist.exists(), "npm run build should have produced dist/index.js"
|
||
|
|
return dist
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def daemon_sock() -> "Path":
|
||
|
|
"""Pre-start an isolated tmp daemon for the wrapper to connect to.
|
||
|
|
|
||
|
|
(Plan 07.1-04) removed the wrapper-side spawn-fallback;
|
||
|
|
wrappers now ONLY connect to an existing daemon socket. In
|
||
|
|
production launchd handles daemon spawn via socket activation; in
|
||
|
|
tests we use the manual-run code path (no LISTEN_FDS env)
|
||
|
|
per D7.1-09 backward compat.
|
||
|
|
|
||
|
|
Module-scoped to amortize the ~3-10s daemon cold-start (bge-small
|
||
|
|
embedder load + LanceDB open) across all 3 tests in this file.
|
||
|
|
"""
|
||
|
|
sock_dir = Path(f"/tmp/iai-mcp-tools-{os.getpid()}")
|
||
|
|
sock_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
sock_path = sock_dir / "d.sock"
|
||
|
|
store_dir = sock_dir / "store"
|
||
|
|
store_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
env = os.environ.copy()
|
||
|
|
env["IAI_DAEMON_SOCKET_PATH"] = str(sock_path)
|
||
|
|
env["IAI_MCP_STORE"] = str(store_dir)
|
||
|
|
# Module-scoped fixture can run before conftest's autouse env patch; the
|
||
|
|
# daemon subprocess must always have a deterministic passphrase-derived
|
||
|
|
# key path (matches tests/conftest.py _TEST_PASSPHRASE).
|
||
|
|
env.setdefault(
|
||
|
|
"IAI_MCP_CRYPTO_PASSPHRASE",
|
||
|
|
"iai-mcp-test-passphrase-2026-04-30-phase-07.10",
|
||
|
|
)
|
||
|
|
env["IAI_DAEMON_IDLE_SHUTDOWN_SECS"] = "300" # outlive the test module
|
||
|
|
env["PYTHONPATH"] = str(REPO / "src") + os.pathsep + env.get("PYTHONPATH", "")
|
||
|
|
daemon_proc = subprocess.Popen(
|
||
|
|
[sys.executable, "-m", "iai_mcp.daemon"],
|
||
|
|
cwd=str(REPO),
|
||
|
|
env=env,
|
||
|
|
stdout=subprocess.DEVNULL,
|
||
|
|
stderr=subprocess.DEVNULL,
|
||
|
|
)
|
||
|
|
# Wait for daemon to bind socket (cold start = 3-10s on macOS).
|
||
|
|
deadline = time.monotonic() + 30.0
|
||
|
|
while time.monotonic() < deadline:
|
||
|
|
if sock_path.exists():
|
||
|
|
break
|
||
|
|
time.sleep(0.1)
|
||
|
|
else:
|
||
|
|
try:
|
||
|
|
daemon_proc.kill()
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
raise RuntimeError(f"test daemon did not bind socket {sock_path} within 30s")
|
||
|
|
|
||
|
|
yield sock_path
|
||
|
|
|
||
|
|
# Teardown: stop the test daemon (matched by Popen handle, then
|
||
|
|
# defensive env-match sweep).
|
||
|
|
try:
|
||
|
|
daemon_proc.terminate()
|
||
|
|
daemon_proc.wait(timeout=10)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
daemon_proc.kill()
|
||
|
|
sock_str = str(sock_path)
|
||
|
|
for p in psutil.process_iter(["cmdline", "environ"]):
|
||
|
|
try:
|
||
|
|
cl = " ".join(p.info.get("cmdline") or [])
|
||
|
|
if "iai_mcp.daemon" not in cl:
|
||
|
|
continue
|
||
|
|
penv = p.info.get("environ") or {}
|
||
|
|
if penv.get("IAI_DAEMON_SOCKET_PATH") == sock_str:
|
||
|
|
p.send_signal(signal.SIGTERM)
|
||
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||
|
|
continue
|
||
|
|
time.sleep(0.3)
|
||
|
|
try:
|
||
|
|
sock_path.unlink()
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
shutil.rmtree(sock_dir, ignore_errors=True)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _mcp_call(proc: subprocess.Popen, method: str, params: dict, rpc_id: int) -> dict:
|
||
|
|
"""Send a single MCP JSON-RPC message and read one response line."""
|
||
|
|
req = {"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params}
|
||
|
|
assert proc.stdin is not None
|
||
|
|
proc.stdin.write((json.dumps(req) + "\n").encode())
|
||
|
|
proc.stdin.flush()
|
||
|
|
assert proc.stdout is not None
|
||
|
|
line = proc.stdout.readline()
|
||
|
|
if not line:
|
||
|
|
raise RuntimeError("wrapper closed stdout before replying")
|
||
|
|
return json.loads(line.decode())
|
||
|
|
|
||
|
|
|
||
|
|
def _spawn_wrapper(built_wrapper: Path, daemon_sock: Path | None = None) -> subprocess.Popen:
|
||
|
|
env = os.environ.copy()
|
||
|
|
env["IAI_MCP_PYTHON"] = sys.executable
|
||
|
|
# route the wrapper to the test daemon socket (HIGH-4
|
||
|
|
# lock at bridge.ts module top reads IAI_DAEMON_SOCKET_PATH from
|
||
|
|
# process.env on each spawn).
|
||
|
|
if daemon_sock is not None:
|
||
|
|
env["IAI_DAEMON_SOCKET_PATH"] = str(daemon_sock)
|
||
|
|
# Ensure the python core can find the src/ package by adding it to PYTHONPATH.
|
||
|
|
env["PYTHONPATH"] = str(REPO / "src") + os.pathsep + env.get("PYTHONPATH", "")
|
||
|
|
return subprocess.Popen(
|
||
|
|
["node", str(built_wrapper)],
|
||
|
|
cwd=str(REPO),
|
||
|
|
env=env,
|
||
|
|
stdin=subprocess.PIPE,
|
||
|
|
stdout=subprocess.PIPE,
|
||
|
|
stderr=subprocess.PIPE,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _initialize(proc: subprocess.Popen, rpc_id: int = 1) -> None:
|
||
|
|
"""Perform the MCP initialize handshake so subsequent tools/* calls are accepted."""
|
||
|
|
resp = _mcp_call(
|
||
|
|
proc,
|
||
|
|
"initialize",
|
||
|
|
{
|
||
|
|
"protocolVersion": "2025-03-26",
|
||
|
|
"capabilities": {},
|
||
|
|
"clientInfo": {"name": "iai-mcp-test", "version": "0.1.0"},
|
||
|
|
},
|
||
|
|
rpc_id,
|
||
|
|
)
|
||
|
|
assert "result" in resp, f"initialize failed: {resp}"
|
||
|
|
# Send the initialized notification (no id) to complete the handshake.
|
||
|
|
assert proc.stdin is not None
|
||
|
|
note = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
||
|
|
proc.stdin.write((json.dumps(note) + "\n").encode())
|
||
|
|
proc.stdin.flush()
|
||
|
|
|
||
|
|
|
||
|
|
def test_wrapper_lists_twelve_tools(built_wrapper: Path, daemon_sock: Path) -> None:
|
||
|
|
"""Hot surface: 5 Phase-1 + 3 + 3 Plan 03 + 1 Plan 06 = 12 tools."""
|
||
|
|
proc = _spawn_wrapper(built_wrapper, daemon_sock)
|
||
|
|
try:
|
||
|
|
_initialize(proc, 1)
|
||
|
|
resp = _mcp_call(proc, "tools/list", {}, 2)
|
||
|
|
assert "result" in resp, f"tools/list error: {resp}"
|
||
|
|
tools = resp["result"]["tools"]
|
||
|
|
names = {t["name"] for t in tools}
|
||
|
|
assert names == {
|
||
|
|
"memory_recall",
|
||
|
|
"memory_reinforce",
|
||
|
|
"memory_contradict",
|
||
|
|
"memory_consolidate",
|
||
|
|
"profile_get_set",
|
||
|
|
# additions
|
||
|
|
"curiosity_pending",
|
||
|
|
"schema_list",
|
||
|
|
"events_query",
|
||
|
|
# Plan 03 additions
|
||
|
|
"memory_recall_structural",
|
||
|
|
"topology",
|
||
|
|
"camouflaging_status",
|
||
|
|
# Plan 06 addition (ambient WRITE-side capture)
|
||
|
|
"memory_capture",
|
||
|
|
}
|
||
|
|
finally:
|
||
|
|
proc.terminate()
|
||
|
|
try:
|
||
|
|
proc.wait(timeout=5)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
proc.kill()
|
||
|
|
|
||
|
|
|
||
|
|
def test_wrapper_profile_get_returns_live_knobs(built_wrapper: Path, daemon_sock: Path) -> None:
|
||
|
|
proc = _spawn_wrapper(built_wrapper, daemon_sock)
|
||
|
|
try:
|
||
|
|
_initialize(proc, 1)
|
||
|
|
resp = _mcp_call(
|
||
|
|
proc,
|
||
|
|
"tools/call",
|
||
|
|
{"name": "profile_get_set", "arguments": {"operation": "get"}},
|
||
|
|
2,
|
||
|
|
)
|
||
|
|
assert "result" in resp, f"tools/call error: {resp}"
|
||
|
|
content = resp["result"]["content"][0]["text"]
|
||
|
|
payload = json.loads(content)
|
||
|
|
assert payload["live"]["literal_preservation"] == "strong"
|
||
|
|
assert payload["live"]["masking_off"] is True
|
||
|
|
assert payload["live"]["task_support"] == "cued_recognition"
|
||
|
|
assert payload["live"]["scene_construction_scaffold"] is True
|
||
|
|
# Plan 07.12-02: 10 autistic-kernel + wake_depth = 11 live (AUTIST-02/08/11/12 removed).
|
||
|
|
assert len(payload["live"]) == 11
|
||
|
|
assert len(payload["deferred"]) == 0
|
||
|
|
finally:
|
||
|
|
proc.terminate()
|
||
|
|
try:
|
||
|
|
proc.wait(timeout=5)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
proc.kill()
|
||
|
|
|
||
|
|
|
||
|
|
def test_wrapper_memory_consolidate_runs_heavy(built_wrapper: Path, daemon_sock: Path) -> None:
|
||
|
|
"""Plan 02-02 memory_consolidate returns real sleep-cycle output
|
||
|
|
instead of the stub ({status:queued, phase:placeholder})."""
|
||
|
|
proc = _spawn_wrapper(built_wrapper, daemon_sock)
|
||
|
|
try:
|
||
|
|
_initialize(proc, 1)
|
||
|
|
resp = _mcp_call(
|
||
|
|
proc,
|
||
|
|
"tools/call",
|
||
|
|
{"name": "memory_consolidate", "arguments": {}},
|
||
|
|
2,
|
||
|
|
)
|
||
|
|
assert "result" in resp, f"tools/call error: {resp}"
|
||
|
|
content = resp["result"]["content"][0]["text"]
|
||
|
|
payload = json.loads(content)
|
||
|
|
assert payload["mode"] == "heavy"
|
||
|
|
assert payload["tier"] in ("tier0", "tier1")
|
||
|
|
assert "summaries_created" in payload
|
||
|
|
finally:
|
||
|
|
proc.terminate()
|
||
|
|
try:
|
||
|
|
proc.wait(timeout=5)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
proc.kill()
|