diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index e372c95..dfd381a 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -582,6 +582,15 @@ MCP_MUTATION_LOCK = threading.RLock() MCP_STDOUT_LOCK = threading.Lock() MCP_REQUEST_LOCAL = threading.local() MCP_MAX_WORKERS = max(2, int(os.environ.get("NAPSEER_MCP_MAX_WORKERS", "8"))) +MCP_BATCH_MAX_ACTIONS = 32 +MCP_BATCH_MAX_PARALLEL = max( + 1, + min(8, int(os.environ.get("NAPSEER_MCP_BATCH_MAX_PARALLEL", "8"))), +) +MCP_BATCH_READ_EXECUTOR = concurrent.futures.ThreadPoolExecutor( + max_workers=MCP_BATCH_MAX_PARALLEL, + thread_name_prefix="napseer-mcp-batch-read", +) GATEWAY_LOG_LOCK = threading.Lock() GATEWAY_VAULT_SETUP_THREAD_STARTED = False GATEWAY_PERIODIC_LOG_STATE = {} @@ -10015,6 +10024,388 @@ def finish_work(args): "stopped_at": stopped_at, } + +BATCH_ALLOWED_CATEGORIES = {"memory", "planning", "kanban", "libraries", "agents"} + + +def _batch_error(code, message): + raise SafeToolError(code, message) + + +def _batch_order_predecessors(actions): + """Return implicit completion barriers and reject cycles in the full DAG.""" + order_predecessors = {action["id"]: set() for action in actions} + action_ids = [action["id"] for action in actions] + for index, action in enumerate(actions): + if action["side_effect"] == "none": + continue + # A mutation is an array-order barrier: everything before it must be + # terminal, and nothing after it may start until it is terminal. + order_predecessors[action["id"]].update(action_ids[:index]) + for later_id in action_ids[index + 1:]: + order_predecessors[later_id].add(action["id"]) + + predecessors = { + action["id"]: set(action["depends_on"]) | order_predecessors[action["id"]] + for action in actions + } + followers = {action_id: set() for action_id in action_ids} + indegree = {action_id: len(dependencies) for action_id, dependencies in predecessors.items()} + for action_id, dependencies in predecessors.items(): + for dependency_id in dependencies: + followers[dependency_id].add(action_id) + ready = [action_id for action_id in action_ids if indegree[action_id] == 0] + visited = 0 + while ready: + action_id = ready.pop(0) + visited += 1 + for follower_id in sorted(followers[action_id]): + indegree[follower_id] -= 1 + if indegree[follower_id] == 0: + ready.append(follower_id) + if visited != len(actions): + _batch_error( + "batch_dependency_cycle", + "Batch dependencies conflict with each other or with mutation barriers.", + ) + return order_predecessors + + +def validate_batch(args): + if args is None: + args = {} + if not isinstance(args, dict): + _batch_error("invalid_batch", "nap_batch arguments must be an object") + args = dict(args) + unknown_top_level = set(args) - { + "actions", + "failure_policy", + "dry_run", + "max_parallel", + } + if unknown_top_level: + _batch_error( + "invalid_batch", + "Unknown nap_batch fields: " + ", ".join(sorted(unknown_top_level)), + ) + raw_actions = require_operation_items( + args, + key="actions", + max_items=MCP_BATCH_MAX_ACTIONS, + ) + enabled_names = enabled_tool_names() + actions = [] + seen_ids = set() + for index, raw_action in enumerate(raw_actions): + unknown_action_fields = set(raw_action) - { + "id", + "tool", + "reason", + "arguments", + "depends_on", + } + if unknown_action_fields: + _batch_error( + "invalid_batch_action", + f"Unknown actions[{index}] fields: " + + ", ".join(sorted(unknown_action_fields)), + ) + if not isinstance(raw_action.get("id"), str): + _batch_error("invalid_batch_action", f"actions[{index}].id must be a string") + action_id = raw_action["id"].strip() + if not action_id: + _batch_error("invalid_batch_action", f"actions[{index}].id is required") + if len(action_id) > 80 or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]*", action_id): + _batch_error( + "invalid_batch_action", + f"actions[{index}].id must be 1-80 safe identifier characters", + ) + if action_id in seen_ids: + _batch_error("duplicate_batch_action", f"duplicate batch action id: {action_id}") + seen_ids.add(action_id) + + if not isinstance(raw_action.get("tool"), str): + _batch_error("invalid_batch_action", f"actions[{index}].tool must be a string") + tool_name = raw_action["tool"].strip() + if tool_name not in CANONICAL_TOOL_NAMES or tool_name == "nap_batch": + _batch_error( + "batch_tool_not_allowed", + f"actions[{index}].tool is not an allowed canonical batch tool", + ) + category = tool_category(tool_name) + side_effect = tool_side_effect(tool_name) + if ( + category not in BATCH_ALLOWED_CATEGORIES + or side_effect not in {"none", "remote-write"} + or tool_name in SECRET_ACCEPTING_TOOLS + ): + _batch_error( + "batch_tool_not_allowed", + f"{tool_name} is outside the project-memory batch capability boundary", + ) + if tool_name not in enabled_names: + profile = tool_profile(tool_name) + _batch_error( + "tool_profile_required", + f"{tool_name} requires the '{profile}' tool profile before it can be batched", + ) + if "arguments" not in raw_action: + _batch_error("invalid_batch_action", f"actions[{index}].arguments is required") + arguments = raw_action["arguments"] + if not isinstance(arguments, dict): + _batch_error("invalid_batch_action", f"actions[{index}].arguments must be an object") + if not isinstance(raw_action.get("reason"), str): + _batch_error("invalid_batch_action", f"actions[{index}].reason must be a string") + reason = require_operation_reason(raw_action.get("reason"), f"actions[{index}].reason") + depends_on = raw_action.get("depends_on") or [] + if not isinstance(depends_on, list) or any(not isinstance(value, str) for value in depends_on): + _batch_error("invalid_batch_action", f"actions[{index}].depends_on must be an array of action ids") + if len(depends_on) != len(set(depends_on)): + _batch_error("invalid_batch_action", f"actions[{index}].depends_on must contain unique ids") + actions.append({ + "index": index, + "id": action_id, + "tool": tool_name, + "arguments": arguments, + "reason": reason, + "depends_on": list(depends_on), + "side_effect": side_effect, + }) + + for action in actions: + for dependency_id in action["depends_on"]: + if dependency_id not in seen_ids: + _batch_error( + "unknown_batch_dependency", + f"{action['id']} depends on unknown action id: {dependency_id}", + ) + if dependency_id == action["id"]: + _batch_error( + "batch_dependency_cycle", + f"{action['id']} cannot depend on itself", + ) + + order_predecessors = _batch_order_predecessors(actions) + contains_mutations = any(action["side_effect"] != "none" for action in actions) + failure_policy = normalize_failure_policy( + args, + "stop_on_error" if contains_mutations else "continue_on_error", + ) + if "max_parallel" in args and ( + isinstance(args["max_parallel"], bool) + or not isinstance(args["max_parallel"], int) + ): + _batch_error("invalid_batch", "max_parallel must be an integer") + max_parallel = normalize_int( + args.get("max_parallel"), + min(4, MCP_BATCH_MAX_PARALLEL), + 1, + MCP_BATCH_MAX_PARALLEL, + ) + return { + "actions": actions, + "order_predecessors": order_predecessors, + "contains_mutations": contains_mutations, + "failure_policy": failure_policy, + "max_parallel": max_parallel, + } + + +def _batch_action_result(action, status, *, result=None, error=None, duration_ms=0, skip_reason=None): + item = { + "index": action["index"], + "id": action["id"], + "tool": action["tool"], + "reason": action["reason"], + "depends_on": action["depends_on"], + "side_effect": action["side_effect"], + "status": status, + "ok": status in {"succeeded", "planned"}, + "duration_ms": max(0, int(duration_ms)), + } + if result is not None: + item["result"] = result + if error is not None: + item["error"] = error + if skip_reason is not None: + item["skip_reason"] = skip_reason + return item + + +def _call_batch_action(action, cancelled): + sentinel = object() + previous_cancelled = getattr(MCP_REQUEST_LOCAL, "cancelled", sentinel) + if cancelled is not None: + MCP_REQUEST_LOCAL.cancelled = cancelled + started_at = time.monotonic() + try: + raise_if_mcp_request_cancelled() + if action["side_effect"] == "none": + result = call_tool_impl(action["tool"], action["arguments"]) + else: + with MCP_MUTATION_LOCK: + raise_if_mcp_request_cancelled() + result = call_tool_impl(action["tool"], action["arguments"]) + return _batch_action_result( + action, + "succeeded", + result=result, + duration_ms=(time.monotonic() - started_at) * 1000, + ) + except Exception as exc: + return _batch_action_result( + action, + "failed", + error=safe_exception_payload(exc), + duration_ms=(time.monotonic() - started_at) * 1000, + ) + finally: + if previous_cancelled is sentinel: + try: + del MCP_REQUEST_LOCAL.cancelled + except AttributeError: + pass + else: + MCP_REQUEST_LOCAL.cancelled = previous_cancelled + + +def execute_batch(args): + plan = validate_batch(args) + actions = plan["actions"] + failure_policy = plan["failure_policy"] + max_parallel = plan["max_parallel"] + if failure_policy == "dry_run": + return { + "ok": True, + "mode": "dependency_aware", + "failure_policy": failure_policy, + "contains_mutations": plan["contains_mutations"], + "max_parallel": max_parallel, + "results": [ + _batch_action_result( + action, + "planned", + result={ + "argument_keys": sorted(action["arguments"]), + "mutation_barrier": action["side_effect"] != "none", + }, + ) + for action in actions + ], + "execution_order": [], + "execution_waves": [], + "stopped_at": None, + } + + action_by_id = {action["id"]: action for action in actions} + pending = set(action_by_id) + results_by_id = {} + execution_order = [] + execution_waves = [] + stopped_at = None + parallel_read_groups = 0 + cancelled = getattr(MCP_REQUEST_LOCAL, "cancelled", None) + + while pending: + raise_if_mcp_request_cancelled() + if stopped_at is not None and failure_policy == "stop_on_error": + for action in actions: + if action["id"] in pending: + results_by_id[action["id"]] = _batch_action_result( + action, + "skipped", + skip_reason=f"stopped after failed action {stopped_at}", + ) + pending.clear() + break + + skipped_any = False + for action in actions: + if action["id"] not in pending: + continue + failed_dependencies = [ + dependency_id + for dependency_id in action["depends_on"] + if dependency_id in results_by_id + and results_by_id[dependency_id]["status"] != "succeeded" + ] + if failed_dependencies: + results_by_id[action["id"]] = _batch_action_result( + action, + "skipped", + skip_reason="dependency did not succeed: " + ", ".join(failed_dependencies), + ) + pending.remove(action["id"]) + skipped_any = True + if skipped_any: + continue + + ready = [] + for action in actions: + if action["id"] not in pending: + continue + required_terminal = set(action["depends_on"]) | plan["order_predecessors"][action["id"]] + if all(dependency_id in results_by_id for dependency_id in required_terminal): + ready.append(action) + if not ready: + _batch_error( + "batch_scheduler_stalled", + "Batch scheduler could not make progress after validation.", + ) + + ready_mutations = [action for action in ready if action["side_effect"] != "none"] + if ready_mutations: + selected = min(ready_mutations, key=lambda action: action["index"]) + result = _call_batch_action(selected, cancelled) + results_by_id[selected["id"]] = result + execution_order.append(selected["id"]) + execution_waves.append([selected["id"]]) + pending.remove(selected["id"]) + if ( + result["status"] == "failed" + and failure_policy == "stop_on_error" + and stopped_at is None + ): + stopped_at = selected["id"] + continue + + selected_reads = ready[:max_parallel] + parallel_read_groups += 1 + selected_ids = [action["id"] for action in selected_reads] + execution_order.extend(selected_ids) + execution_waves.append(selected_ids) + future_actions = { + MCP_BATCH_READ_EXECUTOR.submit(_call_batch_action, action, cancelled): action + for action in selected_reads + } + for future in concurrent.futures.as_completed(future_actions): + action = future_actions[future] + result = future.result() + results_by_id[action["id"]] = result + pending.remove(action["id"]) + if failure_policy == "stop_on_error" and stopped_at is None: + failed_reads = [ + action for action in selected_reads + if results_by_id[action["id"]]["status"] == "failed" + ] + if failed_reads: + stopped_at = min(failed_reads, key=lambda action: action["index"])["id"] + raise_if_mcp_request_cancelled() + + ordered_results = [results_by_id[action["id"]] for action in actions] + return { + "ok": all(result["status"] == "succeeded" for result in ordered_results), + "mode": "dependency_aware", + "failure_policy": failure_policy, + "contains_mutations": plan["contains_mutations"], + "max_parallel": max_parallel, + "parallel_read_groups": parallel_read_groups, + "results": ordered_results, + "execution_order": execution_order, + "execution_waves": execution_waves, + "stopped_at": stopped_at, + } + def kanban_card_by_path(path): node = read_node_by_path({"path": normalize_node_path(path), "view": "render"}) if not normalize_node_path(node.get("full_path") or "").startswith("/kanban/"): @@ -14217,6 +14608,7 @@ CANONICAL_TOOL_NAMES = { "nap_create_node", "nap_node_patch", "nap_bulk", + "nap_batch", "nap_ln", "nap_mv", "nap_mv_folder", @@ -14319,7 +14711,7 @@ HIDDEN_COMPATIBILITY_TOOL_NAMES = { "nap_update_self", } -SYNTHETIC_TOOL_NAMES = {"nap_plan_transition", "nap_node_restore"} +SYNTHETIC_TOOL_NAMES = {"nap_plan_transition", "nap_node_restore", "nap_batch"} IDENTITY_REQUIRED_TOOLS = { "nap_node_get": ("node_id", "uri"), @@ -14437,6 +14829,7 @@ TOOL_CATEGORIES.update( "nap_create_node", "nap_node_patch", "nap_bulk", + "nap_batch", "nap_ln", "nap_mv", "nap_mv_folder", @@ -14502,6 +14895,7 @@ CORE_TOOL_NAMES = { "nap_create_node", "nap_node_patch", "nap_bulk", + "nap_batch", "nap_ln", "nap_mv", "nap_rm", @@ -14742,9 +15136,12 @@ def tool_auth_mode(name): def tool_contract_metadata(name): metadata = _base_tool_contract_metadata(name) - if name == "nap_plan_transition": + if name in {"nap_batch", "nap_plan_transition"}: metadata["dry_run_supported"] = True - if name in {"nap_plan_transition", "nap_crontab_put"}: + if name == "nap_batch": + metadata["lock_policy"] = "per-action-mutation-barrier" + metadata["idempotency"] = "mixed-no-replay" + if name in {"nap_batch", "nap_plan_transition", "nap_crontab_put"}: metadata["dangerous"] = True return metadata @@ -15031,6 +15428,75 @@ def _require_canonical_identity(name, schema): def _synthetic_tools(): return [ + { + "name": "nap_batch", + "description": ( + "Execute a validated dependency graph of project-memory actions in one MCP call. " + "Independent reads run concurrently; every mutation is an ordered, exclusive barrier " + "and is never replayed. Each action requires a concise user-visible reason." + ), + "inputSchema": { + "type": "object", + "required": ["actions"], + "properties": { + "failure_policy": { + "type": "string", + "enum": ["stop_on_error", "continue_on_error", "dry_run"], + "description": ( + "Defaults to continue_on_error for read-only batches and " + "stop_on_error when any action mutates state." + ), + }, + "dry_run": {"type": "boolean", "default": False}, + "max_parallel": { + "type": "integer", + "minimum": 1, + "maximum": MCP_BATCH_MAX_PARALLEL, + "default": min(4, MCP_BATCH_MAX_PARALLEL), + "description": "Maximum dependency-ready reads from this call that may overlap.", + }, + "actions": { + "type": "array", + "minItems": 1, + "maxItems": MCP_BATCH_MAX_ACTIONS, + "items": { + "type": "object", + "required": ["id", "tool", "reason", "arguments"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$", + }, + "tool": { + "type": "string", + "description": ( + "Enabled canonical memory, planning, Kanban, library, " + "or agent-memory tool. nap_batch cannot invoke itself." + ), + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 600, + "description": "Concise user-visible intent for this action.", + }, + "arguments": {"type": "object"}, + "depends_on": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": True, + "default": [], + }, + }, + "additionalProperties": False, + }, + }, + }, + "additionalProperties": False, + }, + }, { "name": "nap_plan_transition", "description": ( @@ -15489,6 +15955,8 @@ def call_tool_impl(name, args): return move_folder(args) if name == "nap_bulk": return bulk_upsert_nodes(args) + if name == "nap_batch": + return execute_batch(args) if name == "nap_finish_work": return finish_work(args) if name == "nap_project_create": @@ -15631,7 +16099,11 @@ def call_tool(name, args): started_at = time.time() tool_name = telemetry_safe_text(name, 96) try: - if tool_side_effect(name) == "none": + if name == "nap_batch": + # Batch validates its complete graph first, overlaps only reads, + # and acquires the mutation lock around each write barrier. + result = call_tool_impl(name, args) + elif tool_side_effect(name) == "none": result = call_tool_impl(name, args) else: # Remote writes and local lifecycle mutations share process-wide @@ -16304,6 +16776,7 @@ def main(): runtime.dispatch(message) finally: runtime.close() + MCP_BATCH_READ_EXECUTOR.shutdown(wait=True, cancel_futures=True) if __name__ == "__main__": diff --git a/tests/test_mcp_batch.py b/tests/test_mcp_batch.py new file mode 100644 index 0000000..7c51436 --- /dev/null +++ b/tests/test_mcp_batch.py @@ -0,0 +1,303 @@ +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_batch_test", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + module.DEFAULT_PROJECT_ID = "project-1" + module.CONFIGURED_TOOL_PROFILES = {"all"} + return module + + +@pytest.fixture +def mod(monkeypatch): + monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False) + return load_module() + + +def action(action_id, tool="nap_node_get", *, depends_on=None, arguments=None): + return { + "id": action_id, + "tool": tool, + "reason": f"Run {action_id} for batch verification", + "arguments": arguments or {}, + "depends_on": depends_on or [], + } + + +def test_batch_is_a_conservatively_annotated_core_tool(mod): + tool = next(item for item in mod.tools() if item["name"] == "nap_batch") + + assert tool["annotations"] == { + "title": "nap_batch", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + } + assert tool["napseer"]["lock_policy"] == "per-action-mutation-barrier" + assert tool["napseer"]["idempotency"] == "mixed-no-replay" + schema = tool["inputSchema"] + assert schema["properties"]["actions"]["maxItems"] == 32 + assert schema["properties"]["max_parallel"]["maximum"] == 8 + assert set(schema["properties"]["actions"]["items"]["required"]) == { + "id", "tool", "reason", "arguments", + } + + +def test_independent_reads_overlap_and_results_preserve_input_order(mod, monkeypatch): + state_lock = threading.Lock() + gate = threading.Barrier(2) + active = 0 + max_active = 0 + + def fake_call(name, arguments): + nonlocal active, max_active + with state_lock: + active += 1 + max_active = max(max_active, active) + gate.wait(timeout=1) + if arguments["value"] == "first": + time.sleep(0.04) + with state_lock: + active -= 1 + return {"tool": name, "value": arguments["value"]} + + monkeypatch.setattr(mod, "call_tool_impl", fake_call) + result = mod.execute_batch({ + "max_parallel": 2, + "actions": [ + action("first", arguments={"value": "first"}), + action("second", tool="nap_context", arguments={"value": "second"}), + ], + }) + + assert result["ok"] is True + assert result["failure_policy"] == "continue_on_error" + assert result["parallel_read_groups"] == 1 + assert max_active == 2 + assert [item["id"] for item in result["results"]] == ["first", "second"] + assert [item["result"]["value"] for item in result["results"]] == ["first", "second"] + + +def test_mutation_is_an_exclusive_array_order_barrier(mod, monkeypatch): + state_lock = threading.Lock() + active_reads = 0 + active_writes = 0 + max_reads = 0 + violations = [] + completed = [] + + def fake_call(name, arguments): + nonlocal active_reads, active_writes, max_reads + is_read = mod.tool_side_effect(name) == "none" + with state_lock: + if is_read: + if active_writes: + violations.append("read overlapped write") + active_reads += 1 + max_reads = max(max_reads, active_reads) + else: + if active_reads or active_writes: + violations.append("write was not exclusive") + active_writes += 1 + time.sleep(0.04) + with state_lock: + if is_read: + active_reads -= 1 + else: + active_writes -= 1 + completed.append(arguments["value"]) + return {"value": arguments["value"]} + + monkeypatch.setattr(mod, "call_tool_impl", fake_call) + result = mod.execute_batch({ + "actions": [ + action("read-a", arguments={"value": "read-a"}), + action("read-b", tool="nap_context", arguments={"value": "read-b"}), + action("write", tool="nap_create_node", arguments={"value": "write"}), + action("read-c", arguments={"value": "read-c"}), + action("read-d", tool="nap_context", arguments={"value": "read-d"}), + ], + }) + + assert result["ok"] is True + assert result["failure_policy"] == "stop_on_error" + assert max_reads == 2 + assert violations == [] + assert completed.index("write") > max(completed.index("read-a"), completed.index("read-b")) + assert completed.index("write") < min(completed.index("read-c"), completed.index("read-d")) + + +@pytest.mark.parametrize( + ("actions", "error_code"), + [ + ( + [ + action("a", depends_on=["b"]), + action("b", tool="nap_context", depends_on=["a"]), + ], + "batch_dependency_cycle", + ), + ([action("a", depends_on=["missing"])], "unknown_batch_dependency"), + ([action("a", tool="nap_gateway_status")], "batch_tool_not_allowed"), + ([action("a", tool="nap_batch")], "batch_tool_not_allowed"), + ], +) +def test_invalid_graphs_and_capabilities_fail_before_execution( + mod, monkeypatch, actions, error_code +): + calls = [] + monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args)) + + with pytest.raises(mod.SafeToolError) as error: + mod.execute_batch({"actions": actions}) + + assert error.value.code == error_code + assert calls == [] + + +def test_dependency_failure_skips_dependents_but_runs_independent_reads( + mod, monkeypatch +): + calls = [] + + def fake_call(_name, arguments): + calls.append(arguments["value"]) + if arguments["value"] == "fails": + raise mod.SafeToolError("fixture_failure", "fixture failed safely") + return {"value": arguments["value"]} + + monkeypatch.setattr(mod, "call_tool_impl", fake_call) + result = mod.execute_batch({ + "failure_policy": "continue_on_error", + "actions": [ + action("fails", arguments={"value": "fails"}), + action("dependent", depends_on=["fails"], arguments={"value": "dependent"}), + action("independent", tool="nap_context", arguments={"value": "independent"}), + ], + }) + + assert result["ok"] is False + assert result["stopped_at"] is None + assert [item["status"] for item in result["results"]] == [ + "failed", "skipped", "succeeded", + ] + assert set(calls) == {"fails", "independent"} + + +def test_stop_on_error_does_not_schedule_a_later_read_wave(mod, monkeypatch): + calls = [] + + def fake_call(_name, arguments): + calls.append(arguments["value"]) + raise mod.SafeToolError("fixture_failure", "fixture failed safely") + + monkeypatch.setattr(mod, "call_tool_impl", fake_call) + result = mod.execute_batch({ + "failure_policy": "stop_on_error", + "max_parallel": 1, + "actions": [ + action("first", arguments={"value": "first"}), + action("second", tool="nap_context", arguments={"value": "second"}), + ], + }) + + assert calls == ["first"] + assert result["stopped_at"] == "first" + assert [item["status"] for item in result["results"]] == ["failed", "skipped"] + + +def test_dry_run_validates_without_invoking_actions(mod, monkeypatch): + calls = [] + monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args)) + + result = mod.execute_batch({ + "dry_run": True, + "actions": [ + action("read", arguments={"node_id": "node-1"}), + action("write", tool="nap_create_node", arguments={"name": "note"}), + ], + }) + + assert calls == [] + assert result["failure_policy"] == "dry_run" + assert result["contains_mutations"] is True + assert [item["status"] for item in result["results"]] == ["planned", "planned"] + + +def test_cancellation_prevents_unscheduled_actions_from_starting(mod, monkeypatch): + cancelled = threading.Event() + calls = [] + + def fake_call(_name, arguments): + calls.append(arguments["value"]) + cancelled.set() + return {"ok": True} + + monkeypatch.setattr(mod, "call_tool_impl", fake_call) + mod.MCP_REQUEST_LOCAL.cancelled = cancelled + try: + with pytest.raises(mod.SafeToolError) as error: + mod.execute_batch({ + "max_parallel": 1, + "actions": [ + action("first", arguments={"value": "first"}), + action("second", tool="nap_context", arguments={"value": "second"}), + ], + }) + finally: + del mod.MCP_REQUEST_LOCAL.cancelled + + assert error.value.code == "request_cancelled" + assert calls == ["first"] + + +def test_protocol_lists_and_dry_runs_batch(mod): + listed = mod.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + names = [item["name"] for item in listed["result"]["tools"]] + assert "nap_batch" in names + + called = mod.handle({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "nap_batch", + "arguments": { + "dry_run": True, + "actions": [action("read", arguments={"node_id": "node-1"})], + }, + }, + }) + payload = called["result"]["structuredContent"] + assert called["result"]["isError"] is False + assert payload["ok"] is True + assert payload["results"][0]["status"] == "planned" + + +def test_profile_gated_tool_is_rejected_before_execution(mod, monkeypatch): + mod.CONFIGURED_TOOL_PROFILES = set() + calls = [] + monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args)) + + with pytest.raises(mod.SafeToolError) as error: + mod.execute_batch({"actions": [action("plan", tool="nap_plan_list_active")]}) + + assert error.value.code == "tool_profile_required" + assert calls == [] diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index ebc1416..65d92a3 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -255,7 +255,7 @@ def test_node_get_rejects_path_reads(mod): def test_tools_include_node_by_path_descriptor(mod): tool_by_name = {item["name"]: item for item in mod.tools()} - assert len(tool_by_name) == 51 + assert len(tool_by_name) == 52 assert len(tool_by_name) == len(mod.tools()) get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get") diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py index baa4aaf..346d41f 100644 --- a/tests/test_mcp_memory_surface_efficiency.py +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -28,11 +28,11 @@ def mod(monkeypatch): return module -def test_default_surface_is_exactly_the_core_fourteen(mod): +def test_default_surface_is_exactly_the_core_fifteen(mod): assert [tool["name"] for tool in mod.tools()] == [ "nap_apropos", "nap_man", "nap_doctor", "nap_whoami", "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get", - "nap_create_node", "nap_node_patch", "nap_bulk", "nap_ln", + "nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln", "nap_mv", "nap_rm", ]