405 lines
12 KiB
Python
405 lines
12 KiB
Python
import importlib.util
|
|
import pathlib
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
def load_module():
|
|
script_path = (
|
|
pathlib.Path(__file__).resolve().parents[1]
|
|
/ "resources"
|
|
/ "scripts"
|
|
/ "napseer_mcp_server.py"
|
|
)
|
|
sys.path.insert(0, str(script_path.parent))
|
|
spec = importlib.util.spec_from_file_location(
|
|
"napseer_mcp_server_lifecycle_test", script_path
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def wait_until(predicate, timeout=2):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(0.01)
|
|
raise AssertionError("condition was not reached before timeout")
|
|
|
|
|
|
def test_worker_runtime_completes_fast_read_before_slow_read():
|
|
mod = load_module()
|
|
emitted = []
|
|
emitted_lock = threading.Lock()
|
|
|
|
def handler(message):
|
|
if message["id"] == 1:
|
|
time.sleep(0.4)
|
|
return mod.rpc_result(message["id"], {"done": True})
|
|
|
|
def emit(response):
|
|
with emitted_lock:
|
|
emitted.append(response)
|
|
|
|
runtime = mod.McpRequestRuntime(handler=handler, sender=emit, max_workers=4)
|
|
try:
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call"})
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": 2, "method": "tools/call"})
|
|
|
|
wait_until(lambda: len(emitted) == 2)
|
|
|
|
assert [response["id"] for response in emitted] == [2, 1]
|
|
finally:
|
|
runtime.close()
|
|
|
|
|
|
def test_worker_runtime_cancellation_suppresses_unused_result():
|
|
mod = load_module()
|
|
emitted = []
|
|
|
|
def handler(message):
|
|
if message["id"] == 1:
|
|
time.sleep(0.3)
|
|
return mod.rpc_result(message["id"], {"done": True})
|
|
|
|
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append, max_workers=2)
|
|
try:
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call"})
|
|
runtime.dispatch(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/cancelled",
|
|
"params": {"requestId": 1, "reason": "unused"},
|
|
}
|
|
)
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": 2, "method": "tools/call"})
|
|
|
|
wait_until(lambda: any(response["id"] == 2 for response in emitted))
|
|
time.sleep(0.35)
|
|
|
|
assert [response["id"] for response in emitted] == [2]
|
|
finally:
|
|
runtime.close()
|
|
|
|
|
|
def test_initialize_cannot_be_cancelled():
|
|
mod = load_module()
|
|
emitted = []
|
|
|
|
def handler(message):
|
|
time.sleep(0.1)
|
|
return mod.rpc_result(message["id"], {"initialized": True})
|
|
|
|
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append, max_workers=2)
|
|
try:
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": 1, "method": "initialize"})
|
|
runtime.dispatch(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/cancelled",
|
|
"params": {"requestId": 1},
|
|
}
|
|
)
|
|
|
|
wait_until(lambda: len(emitted) == 1)
|
|
|
|
assert emitted[0]["id"] == 1
|
|
finally:
|
|
runtime.close()
|
|
|
|
|
|
def test_unknown_notification_never_returns_a_response():
|
|
mod = load_module()
|
|
|
|
response = mod.handle(
|
|
{"jsonrpc": "2.0", "method": "notifications/unknown", "params": {}}
|
|
)
|
|
|
|
assert response is None
|
|
|
|
|
|
def test_mutating_tools_are_serialized_while_reads_can_overlap(monkeypatch):
|
|
mod = load_module()
|
|
active = 0
|
|
max_active = 0
|
|
state_lock = threading.Lock()
|
|
|
|
def fake_call(_name, _args):
|
|
nonlocal active, max_active
|
|
with state_lock:
|
|
active += 1
|
|
max_active = max(max_active, active)
|
|
time.sleep(0.1)
|
|
with state_lock:
|
|
active -= 1
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
|
|
|
|
read_threads = [
|
|
threading.Thread(target=mod.call_tool, args=("nap_whoami", {}))
|
|
for _ in range(2)
|
|
]
|
|
for thread in read_threads:
|
|
thread.start()
|
|
for thread in read_threads:
|
|
thread.join()
|
|
assert max_active == 2
|
|
|
|
max_active = 0
|
|
write_threads = [
|
|
threading.Thread(target=mod.call_tool, args=("nap_create_node", {}))
|
|
for _ in range(2)
|
|
]
|
|
for thread in write_threads:
|
|
thread.start()
|
|
for thread in write_threads:
|
|
thread.join()
|
|
assert max_active == 1
|
|
|
|
|
|
def test_concurrent_first_reads_share_one_project_bootstrap(tmp_path, monkeypatch):
|
|
mod = load_module()
|
|
mod.TOKEN = "synthetic-configured-access"
|
|
mod.AUTH_PATH = tmp_path / "missing-auth.json"
|
|
mod.DEFAULT_PROJECT_ID = None
|
|
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
|
|
calls = []
|
|
|
|
def bootstrap(_args):
|
|
calls.append("bootstrap")
|
|
time.sleep(0.1)
|
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
|
return {"project_id": "project-1"}
|
|
|
|
monkeypatch.setattr(mod, "bootstrap_project", bootstrap)
|
|
results = []
|
|
threads = [
|
|
threading.Thread(target=lambda: results.append(mod.resolve_project_id({})))
|
|
for _ in range(3)
|
|
]
|
|
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
assert results == ["project-1"] * 3
|
|
assert calls == ["bootstrap"]
|
|
|
|
|
|
def test_concurrent_auth_updates_are_atomic_and_merge_fields(tmp_path):
|
|
mod = load_module()
|
|
mod.AUTH_DIR = tmp_path / ".napseer"
|
|
mod.AUTH_PATH = mod.AUTH_DIR / "auth.json"
|
|
mod.VAULT_PATH = mod.AUTH_DIR / "vault.json"
|
|
mod.AUTH_DIR.mkdir()
|
|
mod.write_public_auth({"base_url": "https://api.example.test"})
|
|
barrier = threading.Barrier(3)
|
|
|
|
def update(values):
|
|
barrier.wait()
|
|
mod.save_auth(values)
|
|
|
|
threads = [
|
|
threading.Thread(target=update, args=({"project_slug": "demo"},)),
|
|
threading.Thread(target=update, args=({"project_name": "Demo"},)),
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
barrier.wait()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
saved = mod.load_public_auth_file()
|
|
assert saved["project_slug"] == "demo"
|
|
assert saved["project_name"] == "Demo"
|
|
assert not list(mod.AUTH_DIR.glob(".auth.json.*.tmp"))
|
|
|
|
|
|
def test_auth_renewal_reuses_token_refreshed_by_another_request(monkeypatch):
|
|
mod = load_module()
|
|
mod.TOKEN = "fresh-access"
|
|
mod.TOKEN_EXPIRES_AT = "future"
|
|
mod.REFRESH_EXPIRES_AT = "later"
|
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
|
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
|
|
monkeypatch.setattr(
|
|
mod,
|
|
"_renew_auth_locked",
|
|
lambda: (_ for _ in ()).throw(AssertionError("must not rotate again")),
|
|
)
|
|
|
|
result = mod.renew_auth(stale_token="stale-access")
|
|
|
|
assert result == {
|
|
"status": "already_renewed",
|
|
"method": "concurrent_refresh",
|
|
"token_expires_at": "future",
|
|
"refresh_expires_at": "later",
|
|
"project_id": "project-1",
|
|
}
|
|
|
|
|
|
def test_tool_call_notification_cannot_execute_a_mutation(monkeypatch):
|
|
mod = load_module()
|
|
calls = []
|
|
monkeypatch.setattr(mod, "call_tool", lambda *args: calls.append(args))
|
|
assert mod.handle({"jsonrpc": "2.0", "method": "tools/call", "params": {
|
|
"name": "nap_create_node", "arguments": {"name": "unacknowledged"},
|
|
}}) is None
|
|
assert calls == []
|
|
|
|
|
|
def test_worker_admission_is_bounded_and_recovers():
|
|
mod = load_module()
|
|
release = threading.Event()
|
|
emitted = []
|
|
executed = []
|
|
|
|
def handler(message):
|
|
executed.append(message["id"])
|
|
release.wait(2)
|
|
return mod.rpc_result(message["id"], {"ok": True})
|
|
|
|
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append,
|
|
max_workers=2, max_in_flight=2)
|
|
try:
|
|
for request_id in (1, 2, 3):
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": request_id, "method": "tools/call"})
|
|
wait_until(lambda: any(item["id"] == 3 for item in emitted))
|
|
rejection = next(item for item in emitted if item["id"] == 3)
|
|
assert rejection["error"]["code"] == -32097
|
|
assert 3 not in executed
|
|
release.set()
|
|
wait_until(lambda: len(emitted) == 3)
|
|
runtime.dispatch({"jsonrpc": "2.0", "id": 4, "method": "tools/call"})
|
|
wait_until(lambda: any(item["id"] == 4 for item in emitted))
|
|
finally:
|
|
release.set()
|
|
runtime.close()
|
|
|
|
|
|
def test_cancelled_mutation_waiting_for_lock_never_starts(monkeypatch):
|
|
mod = load_module()
|
|
cancelled = threading.Event()
|
|
waiting = threading.Event()
|
|
calls = []
|
|
errors = []
|
|
monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args))
|
|
monkeypatch.setattr(mod, "send_telemetry_event_async", lambda *a, **kw: None)
|
|
|
|
def run():
|
|
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
|
|
waiting.set()
|
|
try:
|
|
mod.call_tool("nap_create_node", {})
|
|
except mod.SafeToolError as exc:
|
|
errors.append(exc)
|
|
|
|
with mod.MCP_MUTATION_LOCK:
|
|
thread = threading.Thread(target=run)
|
|
thread.start()
|
|
assert waiting.wait(1)
|
|
cancelled.set()
|
|
thread.join(2)
|
|
assert not thread.is_alive()
|
|
assert calls == []
|
|
assert len(errors) == 1
|
|
|
|
|
|
def test_cancelled_queue_entries_remain_bounded_until_consumed():
|
|
mod = load_module()
|
|
release = threading.Event()
|
|
executed = []
|
|
emitted = []
|
|
|
|
def handler(message):
|
|
executed.append(message["id"])
|
|
release.wait(2)
|
|
return mod.rpc_result(message["id"], {"ok": True})
|
|
|
|
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append,
|
|
max_workers=2, max_in_flight=3)
|
|
try:
|
|
for request_id in (1, 2, 3):
|
|
runtime.dispatch({"id": request_id, "method": "tools/call"})
|
|
wait_until(lambda: len(executed) == 2)
|
|
runtime.cancel(3)
|
|
runtime.dispatch({"id": 4, "method": "tools/call"})
|
|
wait_until(lambda: any(item["id"] == 4 for item in emitted))
|
|
assert emitted[0]["error"]["code"] == -32097
|
|
release.set()
|
|
wait_until(lambda: len(emitted) == 3)
|
|
assert executed == [1, 2]
|
|
finally:
|
|
release.set()
|
|
runtime.close()
|
|
|
|
|
|
@pytest.mark.parametrize("cancel_at", ["acquire", "operation"])
|
|
def test_cancelled_write_releases_its_original_project_lock(tmp_path, monkeypatch, cancel_at):
|
|
import http.server
|
|
import json
|
|
|
|
monkeypatch.setenv("NAPSEER_PROJECT_ROOT", str(tmp_path))
|
|
mod = load_module()
|
|
cancelled = threading.Event()
|
|
requests = []
|
|
mutation_ran = []
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_POST(self):
|
|
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
|
requests.append(("POST", self.path))
|
|
if cancel_at == "acquire":
|
|
cancelled.set()
|
|
body = json.dumps({"id": "lock-test", "lease_token": "synthetic-lease"}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_DELETE(self):
|
|
requests.append(("DELETE", self.path))
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
|
mod.TOKEN = "synthetic-access"
|
|
mod.DEFAULT_PROJECT_ID = "original-project"
|
|
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
|
|
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
|
|
|
|
def operation(_headers):
|
|
mutation_ran.append(True)
|
|
mod.DEFAULT_PROJECT_ID = "different-project"
|
|
cancelled.set()
|
|
mod.raise_if_mcp_request_cancelled()
|
|
|
|
try:
|
|
with pytest.raises(mod.SafeToolError):
|
|
mod.with_project_lock({}, operation)
|
|
assert requests == [
|
|
("POST", "/v1/projects/original-project/locks"),
|
|
("DELETE", "/v1/projects/original-project/locks/lock-test"),
|
|
]
|
|
assert mutation_ran == ([] if cancel_at == "acquire" else [True])
|
|
finally:
|
|
del mod.MCP_REQUEST_LOCAL.cancelled
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(2)
|