aiagent-runner 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,698 @@
1
+ # src/aiagent_runner/mcp_client.py
2
+ # MCP client for communication with AI Agent PM server
3
+ # Reference: docs/plan/PHASE3_PULL_ARCHITECTURE.md - Phase 3-5
4
+ # Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
5
+ # Reference: docs/design/MULTI_DEVICE_IMPLEMENTATION_PLAN.md - Phase 4.3
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Optional
14
+
15
+ # HTTP transport support (optional dependency)
16
+ try:
17
+ import aiohttp
18
+ HAS_AIOHTTP = True
19
+ except ImportError:
20
+ HAS_AIOHTTP = False
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class AuthenticationError(Exception):
26
+ """Raised when authentication fails."""
27
+ pass
28
+
29
+
30
+ class SessionExpiredError(Exception):
31
+ """Raised when session token has expired."""
32
+ pass
33
+
34
+
35
+ class MCPError(Exception):
36
+ """General MCP communication error."""
37
+ pass
38
+
39
+
40
+ # Phase 4: Coordinator API data classes
41
+
42
+ @dataclass
43
+ class HealthCheckResult:
44
+ """Result of health check."""
45
+ status: str
46
+ version: Optional[str] = None
47
+ timestamp: Optional[str] = None
48
+
49
+
50
+ @dataclass
51
+ class ProjectWithAgents:
52
+ """Project with its assigned agents."""
53
+ project_id: str
54
+ project_name: str
55
+ working_directory: str
56
+ agents: list[str] = field(default_factory=list)
57
+
58
+
59
+ @dataclass
60
+ class AgentActionResult:
61
+ """Result of get_agent_action check."""
62
+ action: str # "start", "hold", "stop", "restart"
63
+ reason: Optional[str] = None # Reason for the action
64
+ provider: Optional[str] = None # "claude", "gemini", "openai", "other"
65
+ model: Optional[str] = None # "claude-sonnet-4-5", "gemini-2.0-flash", etc.
66
+ kick_command: Optional[str] = None # Custom CLI command (takes priority if set)
67
+ task_id: Optional[str] = None # Phase 4: タスクID(ログファイル登録用)
68
+
69
+
70
+ # Phase 3/4: Agent API data classes
71
+
72
+ @dataclass
73
+ class AuthResult:
74
+ """Result of authentication."""
75
+ session_token: str
76
+ expires_in: int
77
+ agent_name: Optional[str] = None
78
+ system_prompt: Optional[str] = None
79
+ instruction: Optional[str] = None
80
+
81
+
82
+ @dataclass
83
+ class TaskInfo:
84
+ """Information about a pending task."""
85
+ task_id: str
86
+ project_id: str
87
+ title: str
88
+ description: str
89
+ priority: str
90
+ working_directory: Optional[str] = None
91
+ context: Optional[dict] = None
92
+ handoff: Optional[dict] = None
93
+
94
+
95
+ @dataclass
96
+ class ExecutionStartResult:
97
+ """Result of reporting execution start."""
98
+ execution_id: str
99
+ started_at: datetime
100
+
101
+
102
+ class MCPClient:
103
+ """Client for MCP server communication.
104
+
105
+ Handles authentication, task retrieval, and execution reporting.
106
+
107
+ Supports two transport modes:
108
+ - Unix socket: For local connections (default)
109
+ - HTTP: For remote connections (multi-device operation)
110
+
111
+ The transport is automatically selected based on the URL:
112
+ - http:// or https:// → HTTP transport
113
+ - Everything else → Unix socket transport
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ socket_path: Optional[str] = None,
119
+ coordinator_token: Optional[str] = None
120
+ ):
121
+ """Initialize MCP client.
122
+
123
+ Args:
124
+ socket_path: Path to MCP Unix socket, or HTTP URL for remote connections.
125
+ Unix socket: ~/Library/Application Support/AIAgentPM/mcp.sock
126
+ HTTP URL: http://hostname:port/mcp
127
+ Defaults to standard Unix socket location.
128
+ coordinator_token: Token for Coordinator-only API calls (Phase 5).
129
+ If not provided, reads from MCP_COORDINATOR_TOKEN env var.
130
+ """
131
+ # Determine transport type based on URL scheme
132
+ if socket_path and socket_path.startswith(("http://", "https://")):
133
+ self._url = socket_path
134
+ self._use_http = True
135
+ logger.info(f"Using HTTP transport: {self._url}")
136
+ else:
137
+ # Unix socket path - expand tilde
138
+ if socket_path:
139
+ self._url = os.path.expanduser(socket_path)
140
+ else:
141
+ self._url = self._default_socket_path()
142
+ self._use_http = False
143
+ logger.info(f"Using Unix socket transport: {self._url}")
144
+
145
+ # Backward compatibility
146
+ self.socket_path = self._url if not self._use_http else None
147
+
148
+ self._session_token: Optional[str] = None
149
+ # Phase 5: Coordinator token for Coordinator-only API calls
150
+ self._coordinator_token = coordinator_token or os.environ.get("MCP_COORDINATOR_TOKEN")
151
+
152
+ def _default_socket_path(self) -> str:
153
+ """Get default MCP socket path."""
154
+ return os.path.expanduser(
155
+ "~/Library/Application Support/AIAgentPM/mcp.sock"
156
+ )
157
+
158
+ async def _call_tool(self, tool_name: str, args: dict) -> dict:
159
+ """Call an MCP tool via Unix socket or HTTP.
160
+
161
+ Automatically selects the transport based on the URL scheme.
162
+
163
+ Args:
164
+ tool_name: Name of the tool to call
165
+ args: Arguments for the tool
166
+
167
+ Returns:
168
+ Tool result as dictionary
169
+
170
+ Raises:
171
+ MCPError: If communication fails
172
+ """
173
+ if self._use_http:
174
+ return await self._call_tool_http(tool_name, args)
175
+ else:
176
+ return await self._call_tool_unix(tool_name, args)
177
+
178
+ async def _call_tool_unix(self, tool_name: str, args: dict) -> dict:
179
+ """Call an MCP tool via Unix socket.
180
+
181
+ Args:
182
+ tool_name: Name of the tool to call
183
+ args: Arguments for the tool
184
+
185
+ Returns:
186
+ Tool result as dictionary
187
+
188
+ Raises:
189
+ MCPError: If communication fails
190
+ """
191
+ try:
192
+ reader, writer = await asyncio.open_unix_connection(self._url)
193
+ except (ConnectionRefusedError, FileNotFoundError) as e:
194
+ raise MCPError(f"Cannot connect to MCP server at {self._url}: {e}")
195
+
196
+ try:
197
+ request = json.dumps({
198
+ "jsonrpc": "2.0",
199
+ "method": "tools/call",
200
+ "params": {"name": tool_name, "arguments": args},
201
+ "id": 1
202
+ })
203
+ writer.write(request.encode() + b"\n")
204
+ await writer.drain()
205
+
206
+ response = await reader.readline()
207
+ data = json.loads(response)
208
+
209
+ return self._parse_response(data)
210
+ finally:
211
+ writer.close()
212
+ await writer.wait_closed()
213
+
214
+ async def _call_tool_http(self, tool_name: str, args: dict) -> dict:
215
+ """Call an MCP tool via HTTP.
216
+
217
+ Args:
218
+ tool_name: Name of the tool to call
219
+ args: Arguments for the tool
220
+
221
+ Returns:
222
+ Tool result as dictionary
223
+
224
+ Raises:
225
+ MCPError: If communication fails or aiohttp is not installed
226
+ """
227
+ if not HAS_AIOHTTP:
228
+ raise MCPError(
229
+ "HTTP transport requires aiohttp. Install with: pip install aiohttp"
230
+ )
231
+
232
+ request_body = {
233
+ "jsonrpc": "2.0",
234
+ "method": "tools/call",
235
+ "params": {"name": tool_name, "arguments": args},
236
+ "id": 1
237
+ }
238
+
239
+ headers = {"Content-Type": "application/json"}
240
+
241
+ # Add coordinator token as Authorization header if available
242
+ if self._coordinator_token:
243
+ headers["Authorization"] = f"Bearer {self._coordinator_token}"
244
+
245
+ try:
246
+ async with aiohttp.ClientSession() as session:
247
+ async with session.post(
248
+ self._url,
249
+ json=request_body,
250
+ headers=headers,
251
+ timeout=aiohttp.ClientTimeout(total=30)
252
+ ) as response:
253
+ if response.status != 200:
254
+ text = await response.text()
255
+ raise MCPError(f"HTTP {response.status}: {text}")
256
+
257
+ data = await response.json()
258
+ return self._parse_response(data)
259
+
260
+ except aiohttp.ClientError as e:
261
+ raise MCPError(f"Cannot connect to MCP server at {self._url}: {e}")
262
+
263
+ def _parse_response(self, data: dict) -> dict:
264
+ """Parse MCP JSON-RPC response.
265
+
266
+ Args:
267
+ data: Raw JSON-RPC response
268
+
269
+ Returns:
270
+ Parsed tool result
271
+
272
+ Raises:
273
+ MCPError: If response contains an error
274
+ """
275
+ if "error" in data:
276
+ raise MCPError(data["error"].get("message", "Unknown error"))
277
+
278
+ # Parse MCP protocol response format
279
+ # MCP returns: {"result": {"content": [{"type": "text", "text": "JSON"}]}}
280
+ result = data.get("result", {})
281
+ content = result.get("content", [])
282
+ if content and isinstance(content, list) and len(content) > 0:
283
+ first_content = content[0]
284
+ if isinstance(first_content, dict) and first_content.get("type") == "text":
285
+ text = first_content.get("text", "{}")
286
+ try:
287
+ return json.loads(text)
288
+ except json.JSONDecodeError:
289
+ return {"text": text}
290
+ return result
291
+
292
+ # ==========================================================================
293
+ # Phase 4: Coordinator API
294
+ # Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
295
+ # ==========================================================================
296
+
297
+ async def health_check(self) -> HealthCheckResult:
298
+ """Check MCP server health.
299
+
300
+ The Coordinator calls this first to verify the server is available.
301
+ Phase 5: Requires coordinator_token for authorization.
302
+
303
+ Returns:
304
+ HealthCheckResult with server status
305
+
306
+ Raises:
307
+ MCPError: If server is not available or unauthorized
308
+ """
309
+ args = {}
310
+ if self._coordinator_token:
311
+ args["coordinator_token"] = self._coordinator_token
312
+ result = await self._call_tool("health_check", args)
313
+ return HealthCheckResult(
314
+ status=result.get("status", "ok"),
315
+ version=result.get("version"),
316
+ timestamp=result.get("timestamp")
317
+ )
318
+
319
+ async def list_active_projects_with_agents(
320
+ self, root_agent_id: Optional[str] = None
321
+ ) -> list[ProjectWithAgents]:
322
+ """Get all active projects with their assigned agents.
323
+
324
+ The Coordinator calls this to discover what (agent_id, project_id)
325
+ combinations exist and need to be monitored.
326
+ Phase 5: Requires coordinator_token for authorization.
327
+
328
+ Multi-device operation:
329
+ When root_agent_id is specified, the server uses that agent's
330
+ working directories instead of the project defaults.
331
+
332
+ Args:
333
+ root_agent_id: Optional human agent ID for working directory resolution
334
+
335
+ Returns:
336
+ List of ProjectWithAgents
337
+
338
+ Raises:
339
+ MCPError: If request fails or unauthorized
340
+ """
341
+ args = {}
342
+ if self._coordinator_token:
343
+ args["coordinator_token"] = self._coordinator_token
344
+ logger.debug("list_active_projects_with_agents: passing coordinator_token")
345
+ else:
346
+ logger.warning("list_active_projects_with_agents: NO coordinator_token set!")
347
+
348
+ # Multi-device: Pass root_agent_id for working directory resolution
349
+ if root_agent_id:
350
+ args["root_agent_id"] = root_agent_id
351
+ logger.debug(f"list_active_projects_with_agents: passing root_agent_id={root_agent_id}")
352
+
353
+ result = await self._call_tool("list_active_projects_with_agents", args)
354
+ logger.debug(f"list_active_projects_with_agents result: {result}")
355
+
356
+ if not result.get("success", True):
357
+ raise MCPError(result.get("error", "Failed to list projects"))
358
+
359
+ projects = []
360
+ for p in result.get("projects", []):
361
+ projects.append(ProjectWithAgents(
362
+ project_id=p.get("project_id", p.get("projectId", "")),
363
+ project_name=p.get("project_name", p.get("projectName", p.get("name", ""))),
364
+ working_directory=p.get("working_directory", p.get("workingDirectory", "")),
365
+ agents=p.get("agents", [])
366
+ ))
367
+ return projects
368
+
369
+ async def get_agent_action(self, agent_id: str, project_id: str) -> AgentActionResult:
370
+ """Get the action an Agent Instance should take.
371
+
372
+ The Coordinator calls this for each (agent_id, project_id) pair
373
+ to determine what action to take.
374
+ Phase 5: Requires coordinator_token for authorization.
375
+
376
+ Args:
377
+ agent_id: Agent ID
378
+ project_id: Project ID
379
+
380
+ Returns:
381
+ AgentActionResult with action (start/hold/stop/restart), reason, provider, model, and kick_command
382
+
383
+ Raises:
384
+ MCPError: If request fails or unauthorized
385
+ """
386
+ args = {
387
+ "agent_id": agent_id,
388
+ "project_id": project_id
389
+ }
390
+ if self._coordinator_token:
391
+ args["coordinator_token"] = self._coordinator_token
392
+ result = await self._call_tool("get_agent_action", args)
393
+
394
+ return AgentActionResult(
395
+ action=result.get("action", "hold"),
396
+ reason=result.get("reason"),
397
+ provider=result.get("provider"),
398
+ model=result.get("model"),
399
+ kick_command=result.get("kick_command"),
400
+ task_id=result.get("task_id") # Phase 4: Coordinatorがログファイルパス登録に使用
401
+ )
402
+
403
+ async def register_execution_log_file(
404
+ self, agent_id: str, task_id: str, log_file_path: str
405
+ ) -> bool:
406
+ """Register log file path for an execution log.
407
+
408
+ Called by Coordinator after Agent Instance process completes.
409
+ Phase 5: Requires coordinator_token for authorization.
410
+
411
+ Args:
412
+ agent_id: Agent ID
413
+ task_id: Task ID
414
+ log_file_path: Absolute path to the log file
415
+
416
+ Returns:
417
+ True if successful, False otherwise
418
+
419
+ Raises:
420
+ MCPError: If request fails or unauthorized
421
+ """
422
+ args = {
423
+ "agent_id": agent_id,
424
+ "task_id": task_id,
425
+ "log_file_path": log_file_path
426
+ }
427
+ if self._coordinator_token:
428
+ args["coordinator_token"] = self._coordinator_token
429
+ result = await self._call_tool("register_execution_log_file", args)
430
+
431
+ return result.get("success", False)
432
+
433
+ async def invalidate_session(self, agent_id: str, project_id: str) -> bool:
434
+ """Invalidate session for an agent-project pair.
435
+
436
+ Called by Coordinator when Agent Instance process exits.
437
+ This allows shouldStart to return True again for the next instance.
438
+ Phase 5: Requires coordinator_token for authorization.
439
+
440
+ Args:
441
+ agent_id: Agent ID
442
+ project_id: Project ID
443
+
444
+ Returns:
445
+ True if successful, False otherwise
446
+
447
+ Raises:
448
+ MCPError: If request fails or unauthorized
449
+ """
450
+ args = {
451
+ "agent_id": agent_id,
452
+ "project_id": project_id
453
+ }
454
+ if self._coordinator_token:
455
+ args["coordinator_token"] = self._coordinator_token
456
+ result = await self._call_tool("invalidate_session", args)
457
+
458
+ return result.get("success", False)
459
+
460
+ async def report_agent_error(
461
+ self, agent_id: str, project_id: str, error_message: str
462
+ ) -> bool:
463
+ """Report agent error to chat.
464
+
465
+ Called by Coordinator when Agent Instance process exits with error.
466
+ The error message will be displayed in the chat.
467
+
468
+ Args:
469
+ agent_id: Agent ID
470
+ project_id: Project ID
471
+ error_message: Error message to display
472
+
473
+ Returns:
474
+ True if successful, False otherwise
475
+
476
+ Raises:
477
+ MCPError: If request fails or unauthorized
478
+ """
479
+ args = {
480
+ "agent_id": agent_id,
481
+ "project_id": project_id,
482
+ "error_message": error_message
483
+ }
484
+ if self._coordinator_token:
485
+ args["coordinator_token"] = self._coordinator_token
486
+ result = await self._call_tool("report_agent_error", args)
487
+
488
+ return result.get("success", False)
489
+
490
+ # ==========================================================================
491
+ # Phase 3/4: Agent Instance API
492
+ # ==========================================================================
493
+
494
+ async def authenticate(self, agent_id: str, passkey: str, project_id: str) -> AuthResult:
495
+ """Authenticate with the MCP server.
496
+
497
+ Args:
498
+ agent_id: Agent ID
499
+ passkey: Agent passkey
500
+ project_id: Project ID (Phase 4: required for session management)
501
+
502
+ Returns:
503
+ AuthResult with session token
504
+
505
+ Raises:
506
+ AuthenticationError: If authentication fails
507
+ """
508
+ result = await self._call_tool("authenticate", {
509
+ "agent_id": agent_id,
510
+ "passkey": passkey,
511
+ "project_id": project_id
512
+ })
513
+
514
+ if not result.get("success"):
515
+ raise AuthenticationError(result.get("error", "Authentication failed"))
516
+
517
+ self._session_token = result["session_token"]
518
+ return AuthResult(
519
+ session_token=result["session_token"],
520
+ expires_in=result.get("expires_in", 3600),
521
+ agent_name=result.get("agent_name"),
522
+ system_prompt=result.get("system_prompt"),
523
+ instruction=result.get("instruction")
524
+ )
525
+
526
+ async def get_pending_tasks(self) -> list[TaskInfo]:
527
+ """Get pending tasks for the authenticated agent.
528
+
529
+ Returns:
530
+ List of pending TaskInfo objects
531
+
532
+ Raises:
533
+ SessionExpiredError: If session has expired
534
+ MCPError: If request fails or not authenticated
535
+ """
536
+ if not self._session_token:
537
+ raise MCPError("Not authenticated. Call authenticate() first.")
538
+
539
+ result = await self._call_tool("get_pending_tasks", {
540
+ "session_token": self._session_token
541
+ })
542
+
543
+ if not result.get("success"):
544
+ error = result.get("error", "")
545
+ if "expired" in error.lower() or "invalid" in error.lower():
546
+ raise SessionExpiredError(error)
547
+ raise MCPError(error)
548
+
549
+ tasks = []
550
+ for t in result.get("tasks", []):
551
+ tasks.append(TaskInfo(
552
+ task_id=t.get("task_id", t.get("taskId", t.get("id", ""))),
553
+ project_id=t.get("project_id", t.get("projectId", "")),
554
+ title=t.get("title", ""),
555
+ description=t.get("description", ""),
556
+ priority=t.get("priority", "medium"),
557
+ working_directory=t.get("working_directory", t.get("workingDirectory")),
558
+ context=t.get("context"),
559
+ handoff=t.get("handoff")
560
+ ))
561
+ return tasks
562
+
563
+ async def report_execution_start(
564
+ self, task_id: str
565
+ ) -> ExecutionStartResult:
566
+ """Report that task execution has started.
567
+
568
+ Args:
569
+ task_id: Task ID being executed
570
+
571
+ Returns:
572
+ ExecutionStartResult with execution ID
573
+
574
+ Raises:
575
+ MCPError: If reporting fails or not authenticated
576
+ """
577
+ if not self._session_token:
578
+ raise MCPError("Not authenticated. Call authenticate() first.")
579
+
580
+ result = await self._call_tool("report_execution_start", {
581
+ "session_token": self._session_token,
582
+ "task_id": task_id
583
+ })
584
+
585
+ if not result.get("success"):
586
+ raise MCPError(result.get("error", "Failed to report execution start"))
587
+
588
+ started_at_str = result.get("started_at", datetime.now().isoformat())
589
+ if started_at_str.endswith("Z"):
590
+ started_at_str = started_at_str[:-1] + "+00:00"
591
+
592
+ return ExecutionStartResult(
593
+ execution_id=result.get("execution_log_id", result.get("execution_id", "")),
594
+ started_at=datetime.fromisoformat(started_at_str)
595
+ )
596
+
597
+ async def report_execution_complete(
598
+ self,
599
+ execution_id: str,
600
+ exit_code: int,
601
+ duration_seconds: float,
602
+ log_file_path: Optional[str] = None,
603
+ error_message: Optional[str] = None
604
+ ) -> None:
605
+ """Report that task execution has completed.
606
+
607
+ Args:
608
+ execution_id: Execution log ID from report_execution_start
609
+ exit_code: Exit code of the CLI process
610
+ duration_seconds: Duration of execution in seconds
611
+ log_file_path: Path to log file (optional)
612
+ error_message: Error message if execution failed (optional)
613
+
614
+ Raises:
615
+ MCPError: If reporting fails or not authenticated
616
+ """
617
+ if not self._session_token:
618
+ raise MCPError("Not authenticated. Call authenticate() first.")
619
+
620
+ args = {
621
+ "session_token": self._session_token,
622
+ "execution_log_id": execution_id,
623
+ "exit_code": exit_code,
624
+ "duration_seconds": duration_seconds
625
+ }
626
+ if log_file_path:
627
+ args["log_file_path"] = log_file_path
628
+ if error_message:
629
+ args["error_message"] = error_message
630
+
631
+ result = await self._call_tool("report_execution_complete", args)
632
+
633
+ if not result.get("success"):
634
+ raise MCPError(result.get("error", "Failed to report execution complete"))
635
+
636
+ async def update_task_status(
637
+ self, task_id: str, status: str, reason: Optional[str] = None
638
+ ) -> None:
639
+ """Update task status.
640
+
641
+ Args:
642
+ task_id: Task ID to update
643
+ status: New status (todo, in_progress, done, etc.)
644
+ reason: Reason for status change (optional)
645
+
646
+ Raises:
647
+ MCPError: If update fails
648
+ """
649
+ args = {
650
+ "task_id": task_id,
651
+ "status": status
652
+ }
653
+ if reason:
654
+ args["reason"] = reason
655
+
656
+ result = await self._call_tool("update_task_status", args)
657
+
658
+ if not result.get("success"):
659
+ raise MCPError(result.get("error", "Failed to update task status"))
660
+
661
+ async def save_context(
662
+ self,
663
+ task_id: str,
664
+ progress: Optional[str] = None,
665
+ findings: Optional[str] = None,
666
+ blockers: Optional[str] = None,
667
+ next_steps: Optional[str] = None,
668
+ agent_id: Optional[str] = None
669
+ ) -> None:
670
+ """Save task context.
671
+
672
+ Args:
673
+ task_id: Task ID
674
+ progress: Current progress description
675
+ findings: Findings or discoveries
676
+ blockers: Current blockers
677
+ next_steps: Recommended next steps
678
+ agent_id: Agent ID (optional)
679
+
680
+ Raises:
681
+ MCPError: If save fails
682
+ """
683
+ args = {"task_id": task_id}
684
+ if progress:
685
+ args["progress"] = progress
686
+ if findings:
687
+ args["findings"] = findings
688
+ if blockers:
689
+ args["blockers"] = blockers
690
+ if next_steps:
691
+ args["next_steps"] = next_steps
692
+ if agent_id:
693
+ args["agent_id"] = agent_id
694
+
695
+ result = await self._call_tool("save_context", args)
696
+
697
+ if not result.get("success"):
698
+ raise MCPError(result.get("error", "Failed to save context"))