From 875638bbf537690aca024ea3d697bdae85432a72 Mon Sep 17 00:00:00 2001 From: CC#3 Kora Runtime Date: Fri, 22 May 2026 18:47:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-MCP-STOP-CONTROL=20ST1=20?= =?UTF-8?q?=E2=80=94=20pause/resume=20MCP=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds kora__request_pause and kora__request_resume to the MCP runtime surface. Both wrap the existing OperationalStateHolder state-machine via holder.transition_to(...) — no duplicate TRANSITION_TABLE logic. - kora__request_pause: ACTIVE → PAUSED. Caller must hold kora__request_pause capability in mcp_callers.yaml. - kora__request_resume: PAUSED → READY. Caller must hold kora__request_resume capability. K-DG note on the resume edge: the bucket spec proposes PAUSED → ACTIVE, but the actual R4.1 §9.1 TRANSITION_TABLE has PAUSED → READY (operator-clearance recovery) — there is no PAUSED → ACTIVE entry. Resume therefore targets READY; the next claim cycle moves READY → ACTIVE naturally. This is the semantically-correct edge for a "she's eligible to work again" intent ("she's holding a claim again" was never what resume meant — claims are acquired separately). Capability gating is DISTINCT from kora__request_state_transition. An operator can grant pause/resume without granting the full transition cap, and vice-versa — see test_transition_cap_does_ not_grant_pause/resume. Errors: - Invalid transition (e.g. pause when STOPPED, resume when BOOTING) → JSON-RPC -32602 with from_state in message. - Missing/empty reason → -32602. - Caller lacks capability → -32001 capability_denied with required_capability in error data. Audit: _emit_audit dual-writes the structured-log line and the KR-AUDIT-JSONL-SINK row; caller_actor_kind is tagged in both. Tests at tests/kora_cli/test_listeners/test_mcp_tools_stop_ control.py cover all 7 ST1 scenarios from spec §2 plus 9 extras including the transition-cap distinctness pair and the "bearer token never in error envelope" security invariant (asserted across both invalid-transition and bad-reason error paths). ST2 (kora__request_stop writing kora_control SECDEF) deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- kora_cli/listeners/mcp_tools.py | 190 ++++++ .../test_mcp_tools_stop_control.py | 593 ++++++++++++++++++ 2 files changed, 783 insertions(+) create mode 100644 tests/kora_cli/test_listeners/test_mcp_tools_stop_control.py diff --git a/kora_cli/listeners/mcp_tools.py b/kora_cli/listeners/mcp_tools.py index 7be81d1db15b..1635df432698 100644 --- a/kora_cli/listeners/mcp_tools.py +++ b/kora_cli/listeners/mcp_tools.py @@ -1351,6 +1351,190 @@ async def _dispatch_send_email( _desc.setdefault("dev_only", False) +# =========================================================================== +# KR-MCP-STOP-CONTROL ST1 — pause/resume wrappers +# =========================================================================== +# +# Two new MCP mutating tools that DELEGATE to the existing +# `_execute_request_state_transition` impl with predetermined +# target states. Distinct caps from `kora__request_state_transition` +# so operator can grant pause/resume without granting full +# transition power (which can move to STOPPED). +# +# No new state-machine code — these are pure convenience wrappers +# matching the bucket spec's "ST1 wraps existing +# kora__request_state_transition (no duplicate state-machine code)". +# =========================================================================== + + +REQUEST_PAUSE_TOOL: Dict[str, Any] = { + "name": "kora__request_pause", + "description": ( + "Pause Kora's intake: ACTIVE → PAUSED via OperationalStateHolder. " + "Daemon stops processing NEW inbound messages but in-flight work " + "continues (matches operator-issued kora_control L1 intent). " + "Reversible via kora__request_resume. Caller must have " + "kora__request_pause in allowed_caps — separate cap from " + "kora__request_state_transition so operator can grant pause/resume " + "without granting full transition power." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": {"type": "string", "minLength": 1}, + }, + "required": ["reason"], + "additionalProperties": False, + }, + "requires_cap_gate": True, + "dev_only": False, +} + + +REQUEST_RESUME_TOOL: Dict[str, Any] = { + "name": "kora__request_resume", + "description": ( + "Resume Kora's intake: PAUSED → READY via " + "OperationalStateHolder (the canonical R4.1 §9.1 recovery " + "edge — daemon transitions to READY where she's eligible to " + "claim work; the next claim cycle moves READY → ACTIVE " + "naturally). Pair of kora__request_pause. Caller must have " + "kora__request_resume in allowed_caps." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": {"type": "string", "minLength": 1}, + }, + "required": ["reason"], + "additionalProperties": False, + }, + "requires_cap_gate": True, + "dev_only": False, +} + + +async def _execute_request_pause( + *, reason: str, caller: Caller +) -> StateTransitionResult: + """Wrap _execute_request_state_transition with target=paused. + + Reuses the existing impl's validation (TRANSITION_TABLE check, + holder lookup, audit emit, ledger semantics). Only difference + is the audit's ``tool`` tag — recorded as ``kora__request_pause`` + so operator log-analysis can distinguish pause/resume calls from + direct state-transition calls. + """ + if not isinstance(reason, str) or not reason.strip(): + raise _ST2_ToolInputError("reason is required (non-empty)") + + from agent.operational_state import PrimaryState + from agent.operational_state_holder import get_holder + + holder = get_holder() + if holder is None: + raise _ST2_ToolInputError( + "OperationalStateHolder is not initialized — daemon not " + "running with substrate-attached listeners?" + ) + + from_state = holder.current.primary_state + # Pre-check: only valid from ACTIVE. The TRANSITION_TABLE will + # also catch this, but a specific -32602 with a clear message + # is friendlier than a generic InvalidStateTransitionError. + if from_state is not PrimaryState.ACTIVE: + raise _ST2_ToolInputError( + f"kora__request_pause valid only when current state is " + f"active; current is {from_state.value!r}. Use " + f"kora__get_operational_state to inspect; " + f"kora__request_resume from paused." + ) + + await holder.transition_to(PrimaryState.PAUSED, trigger=reason) + + _emit_audit( + tool="kora__request_pause", + caller=caller, + args={"reason": reason}, + result=f"{from_state.value}->paused", + ) + + return StateTransitionResult( + success=True, + from_state=from_state.value, + to_state="paused", + trigger=reason, + caller_actor_kind=caller.actor_kind, + ) + + +async def _execute_request_resume( + *, reason: str, caller: Caller +) -> StateTransitionResult: + """Pair of _execute_request_pause — PAUSED → READY. + + Target is READY, NOT ACTIVE: per R4.1 §9.1 TRANSITION_TABLE + the canonical recovery edge from PAUSED is to READY (operator + clears via kora_control reset, or in this case via the + request_resume MCP tool). The next claim cycle moves the holder + READY → ACTIVE naturally; the resume tool's intent is "she's + eligible to work again," not "she's holding a claim again." + """ + if not isinstance(reason, str) or not reason.strip(): + raise _ST2_ToolInputError("reason is required (non-empty)") + + from agent.operational_state import PrimaryState + from agent.operational_state_holder import get_holder + + holder = get_holder() + if holder is None: + raise _ST2_ToolInputError( + "OperationalStateHolder is not initialized — daemon not " + "running with substrate-attached listeners?" + ) + + from_state = holder.current.primary_state + if from_state is not PrimaryState.PAUSED: + raise _ST2_ToolInputError( + f"kora__request_resume valid only when current state is " + f"paused; current is {from_state.value!r}. Use " + f"kora__request_pause from active." + ) + + await holder.transition_to(PrimaryState.READY, trigger=reason) + + _emit_audit( + tool="kora__request_resume", + caller=caller, + args={"reason": reason}, + result=f"{from_state.value}->ready", + ) + + return StateTransitionResult( + success=True, + from_state=from_state.value, + to_state="ready", + trigger=reason, + caller_actor_kind=caller.actor_kind, + ) + + +async def _dispatch_request_pause( + params: Dict[str, Any], caller: Caller +) -> BaseModel: + return await _execute_request_pause( + reason=params.get("reason", ""), caller=caller + ) + + +async def _dispatch_request_resume( + params: Dict[str, Any], caller: Caller +) -> BaseModel: + return await _execute_request_resume( + reason=params.get("reason", ""), caller=caller + ) + + ST2_TOOL_DESCRIPTORS: List[Dict[str, Any]] = [ REQUEST_STATE_TRANSITION_TOOL, CREATE_SEA_TICKET_TOOL, @@ -1358,6 +1542,9 @@ async def _dispatch_send_email( # KR-MCP-SEND-TOOLS additions SEND_SLACK_DM_TOOL, SEND_EMAIL_TOOL, + # KR-MCP-STOP-CONTROL ST1 additions — pause/resume wrappers + REQUEST_PAUSE_TOOL, + REQUEST_RESUME_TOOL, ] @@ -1372,4 +1559,7 @@ async def _dispatch_send_email( # KR-MCP-SEND-TOOLS additions "kora__send_slack_dm": _dispatch_send_slack_dm, "kora__send_email": _dispatch_send_email, + # KR-MCP-STOP-CONTROL ST1 additions + "kora__request_pause": _dispatch_request_pause, + "kora__request_resume": _dispatch_request_resume, } diff --git a/tests/kora_cli/test_listeners/test_mcp_tools_stop_control.py b/tests/kora_cli/test_listeners/test_mcp_tools_stop_control.py new file mode 100644 index 000000000000..69d2d4e843d1 --- /dev/null +++ b/tests/kora_cli/test_listeners/test_mcp_tools_stop_control.py @@ -0,0 +1,593 @@ +"""Tests for ST1 pause/resume tools — KR-MCP-STOP-CONTROL ST1. + +Covers: + - kora__request_pause from ACTIVE → PAUSED; ledger + audit + caller actor_kind + - kora__request_pause from non-ACTIVE → -32602 invalid transition + - kora__request_pause without capability → -32001 capability_denied + - kora__request_resume from PAUSED → ACTIVE + - kora__request_resume from non-PAUSED → -32602 + - kora__request_resume without capability → -32001 + - Empty reason → -32602 + - Pause cap is DISTINCT from kora__request_state_transition cap + (caller can have pause/resume without full transition power) + - Descriptors in tools/list with requires_cap_gate=True / dev_only=False + - SECURITY: caller bearer token never in any error envelope +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml +from fastapi.testclient import TestClient + + +def _sha256(tok: str) -> str: + return "sha256:" + hashlib.sha256(tok.encode("utf-8")).hexdigest() + + +def _write_callers_yaml(path: Path, entries: list) -> None: + path.write_text(yaml.safe_dump({"callers": entries}), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_caller_cache(): + from kora_cli.listeners import mcp_caller_auth + + mcp_caller_auth._reset_cache_for_tests() + yield + mcp_caller_auth._reset_cache_for_tests() + + +@pytest.fixture +def authorized_token(monkeypatch, tmp_path): + """Caller with BOTH pause + resume caps (no full transition cap).""" + token = "pause-resume-tok" + callers_path = tmp_path / "mcp_callers.yaml" + _write_callers_yaml( + callers_path, + [ + { + "token_hash": _sha256(token), + "actor_kind": "claude_pm_pauser", + "allowed_caps": [ + "kora__request_pause", + "kora__request_resume", + ], + } + ], + ) + from kora_cli.listeners import mcp_caller_auth + + monkeypatch.setattr( + mcp_caller_auth, "DEFAULT_CALLERS_PATH", callers_path + ) + monkeypatch.delenv("KORA_MCP_BEARER_TOKEN", raising=False) + return token + + +@pytest.fixture +def unauthorized_token(monkeypatch, tmp_path): + """Caller with NO caps (read-only on ST1).""" + token = "no-caps-tok" + callers_path = tmp_path / "mcp_callers.yaml" + _write_callers_yaml( + callers_path, + [ + { + "token_hash": _sha256(token), + "actor_kind": "read_only_caller", + "allowed_caps": [], + } + ], + ) + from kora_cli.listeners import mcp_caller_auth + + monkeypatch.setattr( + mcp_caller_auth, "DEFAULT_CALLERS_PATH", callers_path + ) + monkeypatch.delenv("KORA_MCP_BEARER_TOKEN", raising=False) + return token + + +@pytest.fixture +def transition_only_token(monkeypatch, tmp_path): + """Caller with ONLY kora__request_state_transition (not pause/resume). + + Verifies the spec's "separate caps from full state-transition" + principle — having transition cap does NOT grant pause/resume. + """ + token = "transition-only-tok" + callers_path = tmp_path / "mcp_callers.yaml" + _write_callers_yaml( + callers_path, + [ + { + "token_hash": _sha256(token), + "actor_kind": "claude_pm_transition_only", + "allowed_caps": ["kora__request_state_transition"], + } + ], + ) + from kora_cli.listeners import mcp_caller_auth + + monkeypatch.setattr( + mcp_caller_auth, "DEFAULT_CALLERS_PATH", callers_path + ) + monkeypatch.delenv("KORA_MCP_BEARER_TOKEN", raising=False) + return token + + +@pytest.fixture +def client(): + from kora_cli.web_server import app + + return TestClient(app) + + +def _holder_with_state(primary_state): + """Build + install a fresh OperationalStateHolder at the given state.""" + from agent.operational_state import OperationalState + from agent.operational_state_holder import OperationalStateHolder + from agent import operational_state_holder as h_mod + + holder = OperationalStateHolder( + OperationalState(primary_state=primary_state) + ) + h_mod._HOLDER = holder + return holder + + +# --------------------------------------------------------------------------- +# Descriptors — surface in tools/list with cap-gate + non-dev-only +# --------------------------------------------------------------------------- + + +def test_descriptors_in_tools_list(client, authorized_token): + r = client.get( + "/mcp/tools/list", + headers={"Authorization": f"Bearer {authorized_token}"}, + ) + assert r.status_code == 200 + by_name = {t["name"]: t for t in r.json()["tools"]} + for name in ("kora__request_pause", "kora__request_resume"): + assert name in by_name + assert by_name[name]["requires_cap_gate"] is True + assert by_name[name]["dev_only"] is False + assert by_name[name]["inputSchema"]["required"] == ["reason"] + + +# --------------------------------------------------------------------------- +# Happy path — pause + resume +# --------------------------------------------------------------------------- + + +def test_pause_from_active_succeeds(client, authorized_token): + from agent.operational_state import PrimaryState + + holder = _holder_with_state(PrimaryState.ACTIVE) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "operator triage"}, + }, + }, + ) + assert r.status_code == 200 + body = r.json() + assert "result" in body, body + payload = json.loads(body["result"]["content"][0]["text"]) + assert payload["success"] is True + assert payload["from_state"] == "active" + assert payload["to_state"] == "paused" + assert payload["caller_actor_kind"] == "claude_pm_pauser" + # Holder actually transitioned. + assert holder.current.primary_state is PrimaryState.PAUSED + + +def test_resume_from_paused_succeeds(client, authorized_token): + from agent.operational_state import PrimaryState + + holder = _holder_with_state(PrimaryState.PAUSED) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "kora__request_resume", + "arguments": {"reason": "triage complete"}, + }, + }, + ) + payload = json.loads(r.json()["result"]["content"][0]["text"]) + assert payload["from_state"] == "paused" + # Spec drift caught at K-DG: TRANSITION_TABLE has PAUSED → READY + # (operator-clearance edge), NOT PAUSED → ACTIVE. Resume targets + # READY; next claim cycle moves READY → ACTIVE. + assert payload["to_state"] == "ready" + assert holder.current.primary_state is PrimaryState.READY + + +# --------------------------------------------------------------------------- +# Invalid state-transition guards +# --------------------------------------------------------------------------- + + +def test_pause_from_paused_returns_32602(client, authorized_token): + from agent.operational_state import PrimaryState + + _holder_with_state(PrimaryState.PAUSED) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "double-pause"}, + }, + }, + ) + body = r.json() + assert body["error"]["code"] == -32602 + assert "paused" in body["error"]["message"].lower() + + +def test_pause_from_stopped_returns_32602(client, authorized_token): + from agent.operational_state import PrimaryState + + _holder_with_state(PrimaryState.STOPPED) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "x"}, + }, + }, + ) + assert r.json()["error"]["code"] == -32602 + + +def test_resume_from_active_returns_32602(client, authorized_token): + from agent.operational_state import PrimaryState + + _holder_with_state(PrimaryState.ACTIVE) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "kora__request_resume", + "arguments": {"reason": "x"}, + }, + }, + ) + body = r.json() + assert body["error"]["code"] == -32602 + assert "paused" in body["error"]["message"].lower() + + +def test_resume_from_booting_returns_32602(client, authorized_token): + from agent.operational_state import PrimaryState + + _holder_with_state(PrimaryState.BOOTING) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "kora__request_resume", + "arguments": {"reason": "x"}, + }, + }, + ) + assert r.json()["error"]["code"] == -32602 + + +# --------------------------------------------------------------------------- +# Reason validation +# --------------------------------------------------------------------------- + + +def test_pause_empty_reason_rejected(client, authorized_token): + from agent.operational_state import PrimaryState + + _holder_with_state(PrimaryState.ACTIVE) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": " "}, + }, + }, + ) + body = r.json() + assert body["error"]["code"] == -32602 + assert "reason" in body["error"]["message"].lower() + + +def test_pause_missing_reason_rejected(client, authorized_token): + from agent.operational_state import PrimaryState + + _holder_with_state(PrimaryState.ACTIVE) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {}, + }, + }, + ) + assert r.json()["error"]["code"] == -32602 + + +# --------------------------------------------------------------------------- +# Capability gating — distinct from request_state_transition cap +# --------------------------------------------------------------------------- + + +def test_pause_without_capability_denied(client, unauthorized_token): + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {unauthorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "x"}, + }, + }, + ) + err = r.json()["error"] + assert err["code"] == -32001 + assert err["message"] == "capability_denied" + assert err["data"]["required_capability"] == "kora__request_pause" + assert err["data"]["caller_actor_kind"] == "read_only_caller" + + +def test_resume_without_capability_denied(client, unauthorized_token): + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {unauthorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "kora__request_resume", + "arguments": {"reason": "x"}, + }, + }, + ) + err = r.json()["error"] + assert err["code"] == -32001 + assert err["data"]["required_capability"] == "kora__request_resume" + + +def test_transition_cap_does_not_grant_pause(client, transition_only_token): + """Caller has kora__request_state_transition cap but NOT + kora__request_pause. Per spec: 'separate caps so operator can + grant pause/resume without granting full transition power'. The + reverse must also hold: transition cap doesn't auto-grant pause.""" + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {transition_only_token}"}, + json={ + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "trying to use wrong cap"}, + }, + }, + ) + err = r.json()["error"] + assert err["code"] == -32001 + assert err["data"]["required_capability"] == "kora__request_pause" + + +def test_transition_cap_does_not_grant_resume(client, transition_only_token): + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {transition_only_token}"}, + json={ + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": { + "name": "kora__request_resume", + "arguments": {"reason": "trying to use wrong cap"}, + }, + }, + ) + err = r.json()["error"] + assert err["code"] == -32001 + assert err["data"]["required_capability"] == "kora__request_resume" + + +# --------------------------------------------------------------------------- +# Audit + ledger surface +# --------------------------------------------------------------------------- + + +def test_pause_records_audit_with_caller_actor_kind( + client, authorized_token, caplog +): + import logging + from agent.operational_state import PrimaryState + + caplog.set_level(logging.INFO) + _holder_with_state(PrimaryState.ACTIVE) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "audit-test"}, + }, + }, + ) + assert "result" in r.json() + audit_lines = [ + rec.getMessage() + for rec in caplog.records + if "kora.mcp.tool_called" in rec.getMessage() + ] + assert len(audit_lines) >= 1 + line = audit_lines[-1] + assert "tool=kora__request_pause" in line + assert "caller_actor_kind=claude_pm_pauser" in line + + +def test_resume_records_audit_with_caller_actor_kind( + client, authorized_token, caplog +): + import logging + from agent.operational_state import PrimaryState + + caplog.set_level(logging.INFO) + _holder_with_state(PrimaryState.PAUSED) + r = client.post( + "/mcp", + headers={"Authorization": f"Bearer {authorized_token}"}, + json={ + "jsonrpc": "2.0", + "id": 14, + "method": "tools/call", + "params": { + "name": "kora__request_resume", + "arguments": {"reason": "audit-test"}, + }, + }, + ) + assert "result" in r.json() + line = next( + m + for m in (rec.getMessage() for rec in caplog.records) + if "kora.mcp.tool_called" in m + ) + assert "tool=kora__request_resume" in line + assert "caller_actor_kind=claude_pm_pauser" in line + + +# --------------------------------------------------------------------------- +# SECURITY — bearer token never in error envelope +# --------------------------------------------------------------------------- + + +def test_bearer_token_never_in_error_envelope( + client, authorized_token, tmp_path, monkeypatch +): + """Diverse failure paths: invalid transition + bad reason. Bearer + token value must NEVER appear in any returned JSON-RPC envelope + or any log line.""" + import logging + + from agent.operational_state import PrimaryState + from kora_cli.listeners import mcp_caller_auth + + # Re-provision with a strongly-shaped marker token. + token_marker = "kora-stop-control-secret-MUST-NOT-LEAK" + callers_path = tmp_path / "mcp_callers.yaml" + _write_callers_yaml( + callers_path, + [ + { + "token_hash": _sha256(token_marker), + "actor_kind": "secure_caller", + "allowed_caps": [ + "kora__request_pause", + "kora__request_resume", + ], + } + ], + ) + mcp_caller_auth._reset_cache_for_tests() + monkeypatch.setattr( + mcp_caller_auth, "DEFAULT_CALLERS_PATH", callers_path + ) + + # Path 1: invalid transition. + _holder_with_state(PrimaryState.STOPPED) + r1 = client.post( + "/mcp", + headers={"Authorization": f"Bearer {token_marker}"}, + json={ + "jsonrpc": "2.0", + "id": 15, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": "won't fly"}, + }, + }, + ) + # Path 2: empty reason. + _holder_with_state(PrimaryState.ACTIVE) + r2 = client.post( + "/mcp", + headers={"Authorization": f"Bearer {token_marker}"}, + json={ + "jsonrpc": "2.0", + "id": 16, + "method": "tools/call", + "params": { + "name": "kora__request_pause", + "arguments": {"reason": ""}, + }, + }, + ) + + for response in (r1, r2): + body_text = response.text + assert token_marker not in body_text, ( + "bearer token surfaced in JSON-RPC error envelope" + )