from __future__ import annotations import json import os import queue import subprocess import sys import threading from collections.abc import Callable, Mapping from http.server import ThreadingHTTPServer from pathlib import Path from typing import Any, Self from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService from cognitive_agent.application.seat_service import SeatDemoService from cognitive_agent.auth import AuthConfig, AuthRuntime from cognitive_agent.server import SeatDemoHandler, VehicleDisplaySync ROOT = Path(__file__).resolve().parents[2] ORIGINAL_INSTRUCTION = "打开遮阳帘" WRONG_REQUEST_TOKEN = "mcp-loopback-wrong-token-456" _EOF = object() SHADE_PLAN: dict[str, object] = { "detected_domains": ["roof"], "requests": [ { "domain": "roof", "capability": "roof.set_shade_motion", "target_scope": "vehicle", "operation": "set", "duration_ms": None, "parameters": [{"name": "motion", "value": "open"}], "reason": "用户明确要求打开遮阳帘。", } ], "ambiguities": [], "decision_summary": ["打开遮阳帘。"], "confidence": 0.99, } class _StdioMcpClient: """Drive the real MCP child process without platform-specific pipe polling.""" def __init__(self, callback_url: str, callback_token: str) -> None: environment = os.environ.copy() python_paths = [ ROOT / "src", ROOT / "domains" / "_template" / "src", ROOT / "domains" / "ambient_light" / "src", ROOT / "domains" / "climate" / "src", ROOT / "domains" / "roof" / "src", ROOT / "domains" / "seat" / "src", ] inherited_python_path = environment.get("PYTHONPATH", "").strip() environment["PYTHONPATH"] = os.pathsep.join( [ *(str(path) for path in python_paths), *([inherited_python_path] if inherited_python_path else []), ] ) environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_URL"] = callback_url environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TOKEN"] = callback_token environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TIMEOUT_SECONDS"] = "5" # MCP is a UTF-8 JSON-lines protocol. Force the same pipe encoding on # Windows (whose inherited console encoding may otherwise be GBK). environment["PYTHONUTF8"] = "1" environment["PYTHONIOENCODING"] = "utf-8" self._responses: queue.Queue[object] = queue.Queue() self._stderr: list[str] = [] self._next_id = 0 self.process = subprocess.Popen( [ sys.executable, "-u", "-m", "cognitive_agent.infrastructure.codex.vehicle_mcp_server", ], cwd=ROOT, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="strict", bufsize=1, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True) self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) self._stdout_thread.start() self._stderr_thread.start() def _read_stdout(self) -> None: assert self.process.stdout is not None try: for line in self.process.stdout: self._responses.put(line) finally: self._responses.put(_EOF) def _read_stderr(self) -> None: assert self.process.stderr is not None self._stderr.extend(self.process.stderr) def request(self, method: str, params: Mapping[str, object]) -> dict[str, Any]: self._next_id += 1 request_id = self._next_id self._write( { "jsonrpc": "2.0", "id": request_id, "method": method, "params": dict(params), } ) try: raw = self._responses.get(timeout=10) except queue.Empty as exc: raise AssertionError( f"MCP response timed out; stderr={''.join(self._stderr)!r}" ) from exc if raw is _EOF: raise AssertionError( f"MCP process exited before responding; stderr={''.join(self._stderr)!r}" ) response = json.loads(str(raw)) assert response["jsonrpc"] == "2.0" assert response["id"] == request_id return response def notify(self, method: str, params: Mapping[str, object]) -> None: self._write({"jsonrpc": "2.0", "method": method, "params": dict(params)}) def _write(self, message: Mapping[str, object]) -> None: assert self.process.stdin is not None assert self.process.poll() is None, f"MCP child exited: {''.join(self._stderr)}" self.process.stdin.write( json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" ) self.process.stdin.flush() def close(self) -> None: if self.process.stdin is not None and not self.process.stdin.closed: self.process.stdin.close() try: return_code = self.process.wait(timeout=5) except subprocess.TimeoutExpired: self.process.terminate() try: return_code = self.process.wait(timeout=3) except subprocess.TimeoutExpired: self.process.kill() return_code = self.process.wait(timeout=3) self._stdout_thread.join(timeout=2) self._stderr_thread.join(timeout=2) if self.process.stdout is not None: self.process.stdout.close() if self.process.stderr is not None: self.process.stderr.close() assert return_code == 0, ( f"MCP child exited with {return_code}; stderr={''.join(self._stderr)!r}" ) def __enter__(self) -> Self: return self def __exit__(self, *_args: object) -> None: self.close() def _complete_turn(turn: ActiveCodexTurn) -> None: item_id = f"answer-{turn.turn_id}" answer = "已完成遮阳帘模拟编排。" turn.events.put( { "method": "item/completed", "params": { "item": { "type": "agentMessage", "id": item_id, "text": answer, "phase": "final_answer", } }, } ) turn.events.put( { "method": "turn/completed", "params": { "turn": { "id": turn.turn_id, "status": "completed", "items": [], "error": None, } }, } ) class _McpDrivingCodexClient: """Minimal model-side driver; all vehicle work still crosses the real MCP process.""" available = True def __init__(self) -> None: self.rpc: _StdioMcpClient | None = None self.started_with: tuple[str, str] | None = None self.capability_response: dict[str, Any] | None = None self.wrong_token_response: dict[str, Any] | None = None self.execution_response: dict[str, Any] | None = None self.active_request_token = "" @property def status(self) -> str: return "ready" def start(self, callback_url: str, callback_token: str) -> None: self.started_with = (callback_url, callback_token) def close(self) -> None: return None def start_thread(self, *, developer_instructions: str, ephemeral: bool = True) -> str: assert "get_n7web_capabilities" in developer_instructions assert "execute_cockpit_request" in developer_instructions assert ephemeral is True return "thread-real-mcp-loopback" def start_turn( self, thread_id: str, text: str, request_token: str, *, application_context: Mapping[str, object] | None = None, ) -> ActiveCodexTurn: assert self.rpc is not None assert thread_id == "thread-real-mcp-loopback" assert text == ORIGINAL_INSTRUCTION assert request_token != WRONG_REQUEST_TOKEN assert len(request_token) >= 8 assert application_context self.active_request_token = request_token turn = ActiveCodexTurn( thread_id=thread_id, turn_id="turn-real-mcp-loopback", events=queue.Queue(), ) self.capability_response = self.rpc.request( "tools/call", { "name": "get_n7web_capabilities", "arguments": {"request_token": request_token, "query": "遮阳帘"}, }, ) self.wrong_token_response = self.rpc.request( "tools/call", { "name": "execute_cockpit_request", "arguments": { "request_token": WRONG_REQUEST_TOKEN, "plan": SHADE_PLAN, }, }, ) self.execution_response = self.rpc.request( "tools/call", { "name": "execute_cockpit_request", # No instruction crosses MCP. The application must recover the # exact original text from this turn-bound opaque token. "arguments": {"request_token": request_token, "plan": SHADE_PLAN}, }, ) _complete_turn(turn) return turn def finish_turn(self, _turn: ActiveCodexTurn) -> None: return None def interrupt(self, _turn: ActiveCodexTurn) -> None: return None def unsubscribe_thread(self, _thread_id: str) -> None: return None def _auth_disabled() -> AuthRuntime: return AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False)) def test_real_mcp_stdio_process_reaches_bound_loopback_dry_run(tmp_path: Path) -> None: service = SeatDemoService(ROOT) service.artifact_root = tmp_path / "runs" catalog = AssistantCapabilityCatalog( service, peripheral_provider=list, nfc_provider=lambda: {"reader": {"state": "ready"}, "card": None}, ) client = _McpDrivingCodexClient() assistant = CodexAssistantService(client) handler = type("RealMcpLoopbackHandler", (SeatDemoHandler,), {}) handler.service = service handler.assistant = assistant handler.auth = _auth_disabled() handler.vehicle_display_sync = VehicleDisplaySync() handler.assistant_capability_catalog = catalog handler.log_message = lambda *_args: None server = ThreadingHTTPServer(("127.0.0.1", 0), handler) server_thread = threading.Thread(target=server.serve_forever, daemon=True) server_thread.start() callback_url = ( f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control" ) raw_results: list[dict[str, object]] = [] observed_instructions: list[str] = [] def execute_control( instruction: str, progress: Callable[[dict[str, object]], None], assistant_plan: Mapping[str, object], ) -> dict[str, object]: observed_instructions.append(instruction) result = service.preview_native_can( instruction, provider="local", conversation_id="conv-real-mcp-vehicle", execution_mode="dry_run", on_commands_validated=progress, assistant_plan=dict(assistant_plan), ) raw_results.append(result) return result try: assistant.start(callback_url) assert client.started_with == (callback_url, assistant.internal_token) with _StdioMcpClient(callback_url, assistant.internal_token) as rpc: client.rpc = rpc initialized = rpc.request( "initialize", { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "n7web-integration-test", "version": "1.0"}, }, ) assert initialized["result"]["protocolVersion"] == "2025-06-18" assert initialized["result"]["serverInfo"]["name"] == "n7web-cockpit" rpc.notify("notifications/initialized", {}) listed = rpc.request("tools/list", {}) tools = listed["result"]["tools"] assert [tool["name"] for tool in tools] == [ "get_n7web_capabilities", "execute_cockpit_request", ] execute_schema = tools[1]["inputSchema"]["properties"] assert set(execute_schema) == {"request_token", "plan"} assert "instruction" not in execute_schema snapshot = catalog.snapshot() result = assistant.stream( ORIGINAL_INSTRUCTION, conversation_id="conv-real-mcp-assistant", principal="real-mcp-integration-test", history=[], emit=lambda _event: True, execute_control=execute_control, capability_snapshot=snapshot, capability_context=catalog.compact_context(snapshot), control_terms=catalog.control_terms(snapshot), query_capabilities=catalog.query, ) assert result["route"] == "agent" assert client.capability_response is not None capability_result = client.capability_response["result"] assert capability_result["isError"] is False capability_catalog = capability_result["structuredContent"]["catalog"] assert capability_catalog["matched"] is True assert "roof.set_shade_motion" in { item["id"] for item in capability_catalog["capabilities"] } assert client.wrong_token_response is not None rejected = client.wrong_token_response["result"] assert rejected["isError"] is True assert rejected["structuredContent"]["error"]["code"] == "n7web_tool_failed" assert "HTTP 409" in rejected["structuredContent"]["error"]["message"] assert client.execution_response is not None execution = client.execution_response["result"] assert execution["isError"] is False structured = execution["structuredContent"] assert structured["status"] == "succeeded" assert structured["execution_mode"] == "dry_run" assert structured["verified"] is False assert structured["verification_status"] == "simulated" assert structured["utterance"] == ORIGINAL_INSTRUCTION assert {action["capability"] for action in structured["actions"]} == { "roof.set_shade_motion" } assert json.loads(execution["content"][0]["text"]) == structured assert observed_instructions == [ORIGINAL_INSTRUCTION] assert len(raw_results) == 1 assert raw_results[0]["utterance"] == ORIGINAL_INSTRUCTION assert raw_results[0]["execution_mode"] == "dry_run" assert raw_results[0]["status"] == "succeeded" assert rpc.process.poll() == 0 finally: assistant.close() server.shutdown() server.server_close() server_thread.join(timeout=3) service.close() assert not server_thread.is_alive()