Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
271
tests/test_mcp_tools.py
Normal file
271
tests/test_mcp_tools.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue