agenthub-multiagent-mcp 1.1.5 → 1.3.0

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.
@@ -2,49 +2,92 @@
2
2
  * MCP tool definitions and handlers
3
3
  */
4
4
 
5
- import { ApiClient, Message } from "../client.js";
5
+ import { ApiClient, Message, TicketTask, TicketTaskContext, CheckpointResponse } from "../client.js";
6
6
  import type { Tool } from "@modelcontextprotocol/sdk/types.js";
7
7
  import * as state from "../state.js";
8
+ import open from "open";
9
+
10
+ export interface PushedItems {
11
+ tasks: PendingTask[];
12
+ messages: Message[];
13
+ }
8
14
 
9
15
  export interface ToolContext {
10
16
  getCurrentAgentId: () => string;
11
17
  setCurrentAgentId: (id: string) => void;
12
18
  stopHeartbeat: () => void;
13
19
  getWorkingDir: () => string;
20
+ getPushedItems: () => PushedItems;
21
+ }
22
+
23
+ interface PendingTask {
24
+ id: string;
25
+ assigned_by: string;
26
+ assigned_by_name?: string;
27
+ task: string;
28
+ priority: string;
29
+ created_at: string;
14
30
  }
15
31
 
16
32
  interface UrgentAction {
17
33
  required: true;
18
34
  instruction: string;
19
- message: Message;
35
+ message?: Message;
36
+ task?: PendingTask;
20
37
  }
21
38
 
22
39
  interface WrappedResponse {
23
40
  result: unknown;
24
41
  pending_messages: Message[];
25
- pending_count: number;
26
- has_more: boolean;
42
+ pending_messages_count: number;
43
+ pending_tasks: PendingTask[];
44
+ pending_tasks_count: number;
27
45
  urgent_action: UrgentAction | null;
28
46
  }
29
47
 
30
48
  /**
31
- * Wraps a tool response with pending messages
32
- * This enables automatic message delivery without extra API calls
49
+ * Wraps a tool response with pending messages and tasks
50
+ * This enables automatic delivery without extra API calls
51
+ * Includes items pushed via WebSocket
33
52
  */
34
- async function wrapWithMessages(
53
+ async function wrapWithPendingItems(
35
54
  client: ApiClient,
36
55
  agentId: string | null,
37
- result: unknown
56
+ result: unknown,
57
+ context?: ToolContext
38
58
  ): Promise<WrappedResponse | unknown> {
39
59
  // If not registered, return raw result
40
60
  if (!agentId) return result;
41
61
 
42
62
  try {
43
- // Fetch unread messages
44
- const inbox = await client.getInbox(agentId, true, 10);
45
- const messages = inbox.messages || [];
46
-
47
- // Sort: urgent first, then by created_at
63
+ // Get items pushed via WebSocket (if connected)
64
+ const pushedItems = context?.getPushedItems() || { tasks: [], messages: [] };
65
+
66
+ // Fetch unread messages and pending tasks in parallel
67
+ const [inbox, tasksResponse] = await Promise.all([
68
+ client.getInbox(agentId, true, 10),
69
+ client.getPendingTasks(agentId),
70
+ ]);
71
+
72
+ // Merge pushed items with fetched items (deduplicating by ID)
73
+ const fetchedMessages = inbox.messages || [];
74
+ const fetchedTasks = (tasksResponse.tasks || []) as PendingTask[];
75
+
76
+ // Deduplicate messages
77
+ const messageIds = new Set(fetchedMessages.map((m) => m.id));
78
+ const messages = [
79
+ ...fetchedMessages,
80
+ ...pushedItems.messages.filter((m) => !messageIds.has(m.id)),
81
+ ];
82
+
83
+ // Deduplicate tasks
84
+ const taskIds = new Set(fetchedTasks.map((t) => t.id));
85
+ const tasks = [
86
+ ...fetchedTasks,
87
+ ...pushedItems.tasks.filter((t) => !taskIds.has(t.id)),
88
+ ];
89
+
90
+ // Sort messages: urgent first, then by created_at
48
91
  messages.sort((a, b) => {
49
92
  const aUrgent = isUrgentMessage(a);
50
93
  const bUrgent = isUrgentMessage(b);
@@ -53,28 +96,40 @@ async function wrapWithMessages(
53
96
  return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
54
97
  });
55
98
 
56
- // Find urgent message for action
57
- const urgentMsg = messages.find(isUrgentMessage);
58
-
99
+ // Determine urgent action - tasks take priority over messages
59
100
  let urgentAction: UrgentAction | null = null;
60
- if (urgentMsg) {
101
+
102
+ // Check for pending tasks first (they're actionable)
103
+ if (tasks.length > 0) {
104
+ const highPriorityTask = tasks.find((t) => t.priority === "high") || tasks[0];
61
105
  urgentAction = {
62
106
  required: true,
63
- instruction: buildUrgentInstruction(urgentMsg),
64
- message: urgentMsg,
107
+ instruction: buildTaskInstruction(highPriorityTask),
108
+ task: highPriorityTask,
65
109
  };
110
+ } else {
111
+ // Check for urgent messages
112
+ const urgentMsg = messages.find(isUrgentMessage);
113
+ if (urgentMsg) {
114
+ urgentAction = {
115
+ required: true,
116
+ instruction: buildUrgentInstruction(urgentMsg),
117
+ message: urgentMsg,
118
+ };
119
+ }
66
120
  }
67
121
 
68
122
  return {
69
123
  result,
70
124
  pending_messages: messages,
71
- pending_count: inbox.total,
72
- has_more: inbox.total > messages.length,
125
+ pending_messages_count: messages.length,
126
+ pending_tasks: tasks,
127
+ pending_tasks_count: tasks.length,
73
128
  urgent_action: urgentAction,
74
129
  };
75
130
  } catch (error) {
76
- // Don't break tool call if message fetch fails
77
- console.warn("Failed to fetch messages for wrapping:", error);
131
+ // Don't break tool call if fetch fails
132
+ console.warn("Failed to fetch pending items for wrapping:", error);
78
133
  return result;
79
134
  }
80
135
  }
@@ -102,6 +157,18 @@ Details: ${msg.body}
102
157
  Spawn a subagent with Task tool to research and respond to this query. Use send_message to reply when done.`;
103
158
  }
104
159
 
160
+ /**
161
+ * Build instruction text for pending tasks
162
+ */
163
+ function buildTaskInstruction(task: PendingTask): string {
164
+ const assignerName = task.assigned_by_name || task.assigned_by;
165
+ return `ACTION REQUIRED: You have a task assigned by ${assignerName}.
166
+
167
+ Task: ${task.task}
168
+
169
+ Use accept_task with task_id="${task.id}" to accept and start working, or decline_task to decline with a reason.`;
170
+ }
171
+
105
172
  export function registerTools(): Tool[] {
106
173
  return [
107
174
  // Registration tools
@@ -141,6 +208,10 @@ export function registerTools(): Tool[] {
141
208
  type: "string",
142
209
  description: "Project name (optional)",
143
210
  },
211
+ task_id: {
212
+ type: "string",
213
+ description: "Optional: ID of a ticket system task to link this work to",
214
+ },
144
215
  },
145
216
  required: ["task"],
146
217
  },
@@ -439,6 +510,77 @@ export function registerTools(): Tool[] {
439
510
  required: ["task_id", "reason"],
440
511
  },
441
512
  },
513
+
514
+ // Ticket system tools (for project management integration)
515
+ {
516
+ name: "get_available_tasks",
517
+ description: "Get tasks from the ticket system that are available for you to claim and work on",
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ status: {
522
+ type: "string",
523
+ description: "Filter by status (default: todo)",
524
+ enum: ["backlog", "todo", "in_progress"],
525
+ },
526
+ project_id: {
527
+ type: "string",
528
+ description: "Filter by project ID (optional)",
529
+ },
530
+ priority: {
531
+ type: "string",
532
+ description: "Filter by priority (optional)",
533
+ enum: ["lowest", "low", "medium", "high", "highest"],
534
+ },
535
+ },
536
+ },
537
+ },
538
+ {
539
+ name: "get_task_context",
540
+ description: "Get full context for a ticket task including its parent story and epic",
541
+ inputSchema: {
542
+ type: "object",
543
+ properties: {
544
+ task_id: {
545
+ type: "string",
546
+ description: "ID of the task to get context for",
547
+ },
548
+ },
549
+ required: ["task_id"],
550
+ },
551
+ },
552
+ {
553
+ name: "claim_ticket_task",
554
+ description: "Claim a ticket task from the project board to work on",
555
+ inputSchema: {
556
+ type: "object",
557
+ properties: {
558
+ task_id: {
559
+ type: "string",
560
+ description: "ID of the task to claim",
561
+ },
562
+ },
563
+ required: ["task_id"],
564
+ },
565
+ },
566
+ {
567
+ name: "reach_checkpoint",
568
+ description: "Signal that you've reached a checkpoint and request human review before continuing",
569
+ inputSchema: {
570
+ type: "object",
571
+ properties: {
572
+ task_id: {
573
+ type: "string",
574
+ description: "ID of the task you're working on",
575
+ },
576
+ description: {
577
+ type: "string",
578
+ description: "Description of what you've accomplished and what you're waiting for review on",
579
+ },
580
+ },
581
+ required: ["task_id", "description"],
582
+ },
583
+ },
442
584
  ];
443
585
  }
444
586
 
@@ -503,35 +645,68 @@ export async function handleToolCall(
503
645
  }
504
646
  }
505
647
 
506
- // Fresh registration
507
- // Auto-detect model from environment or use provided value
508
- const model = args.model as string ||
509
- process.env.CLAUDE_MODEL ||
510
- "claude-opus-4-5-20251101";
511
-
512
- const result = await client.registerAgent(
648
+ // Fresh registration - use browser-based flow
649
+ // Step 1: Initialize registration session
650
+ const initResult = await client.initRegistration(
513
651
  requestedId,
514
- args.name as string | undefined,
515
- owner,
516
- workingDir,
517
- model
652
+ args.name as string | undefined
518
653
  );
519
654
 
520
- context.setCurrentAgentId(requestedId);
655
+ // Step 2: Open browser to dashboard
656
+ const dashboardUrl = client.getBaseUrl().replace('/api', '') + initResult.dashboard_url;
657
+ console.error(`Opening browser for agent registration: ${dashboardUrl}`);
658
+ await open(dashboardUrl);
659
+
660
+ // Step 3: Poll for completion (max 10 minutes)
661
+ const pollInterval = 3000; // 3 seconds
662
+ const maxPolls = 200; // 10 minutes
663
+ let pollCount = 0;
664
+ let registrationResult: Awaited<ReturnType<typeof client.pollRegistrationCallback>> | null = null;
665
+
666
+ console.error('Waiting for browser registration to complete...');
667
+
668
+ while (pollCount < maxPolls) {
669
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
670
+ pollCount++;
671
+
672
+ try {
673
+ const pollResult = await client.pollRegistrationCallback(initResult.session_token);
674
+
675
+ if (pollResult.status === 'completed') {
676
+ registrationResult = pollResult;
677
+ break;
678
+ } else if (pollResult.status === 'expired') {
679
+ throw new Error('Registration session expired. Please try again.');
680
+ }
681
+ // status === 'pending' - continue polling
682
+ } catch (error) {
683
+ // Non-fatal errors during polling - continue
684
+ console.warn('Poll error:', error);
685
+ }
686
+ }
687
+
688
+ if (!registrationResult || registrationResult.status !== 'completed') {
689
+ throw new Error('Registration timed out. Please try again.');
690
+ }
691
+
692
+ const agentId = registrationResult.agent_id!;
693
+ context.setCurrentAgentId(agentId);
521
694
 
522
- // Save state for future reconnection (including name)
695
+ // Save state for future reconnection
523
696
  state.saveState(workingDir, {
524
- agent_id: requestedId,
525
- name: result.name,
697
+ agent_id: agentId,
698
+ name: registrationResult.agent_name || agentId,
526
699
  owner,
527
- token: result.token,
528
- registered_at: result.registered_at,
700
+ token: registrationResult.connect_token!,
701
+ registered_at: new Date().toISOString(),
529
702
  });
530
703
 
531
704
  return {
532
- ...result,
533
- mode: "registered",
534
- message: `Registered as ${result.name || requestedId}. You have ${result.pending_tasks_count} pending tasks and ${result.unread_messages_count} unread messages.`,
705
+ agent_id: agentId,
706
+ name: registrationResult.agent_name,
707
+ agent_type: registrationResult.agent_type,
708
+ mode: 'registered',
709
+ message: `Successfully registered as ${registrationResult.agent_name || agentId}. You can now use other AgentHub tools.`,
535
710
  };
536
711
  }
537
712
 
@@ -540,15 +715,16 @@ export async function handleToolCall(
540
715
  const result = await client.startWork(
541
716
  agentId,
542
717
  args.task as string,
543
- args.project as string | undefined
718
+ args.project as string | undefined,
719
+ args.task_id as string | undefined
544
720
  );
545
- return wrapWithMessages(client, agentId, result);
721
+ return wrapWithPendingItems(client, agentId, result, context);
546
722
  }
547
723
 
548
724
  case "agent_set_status": {
549
725
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
550
726
  const result = await client.heartbeat(agentId, args.status as "online" | "busy");
551
- return wrapWithMessages(client, agentId, result);
727
+ return wrapWithPendingItems(client, agentId, result, context);
552
728
  }
553
729
 
554
730
  case "agent_disconnect": {
@@ -569,7 +745,7 @@ export async function handleToolCall(
569
745
  body: args.body as string,
570
746
  priority: args.priority as string | undefined,
571
747
  });
572
- return wrapWithMessages(client, agentId, result);
748
+ return wrapWithPendingItems(client, agentId, result, context);
573
749
  }
574
750
 
575
751
  case "send_to_channel": {
@@ -581,7 +757,7 @@ export async function handleToolCall(
581
757
  subject: args.subject as string,
582
758
  body: args.body as string,
583
759
  });
584
- return wrapWithMessages(client, agentId, result);
760
+ return wrapWithPendingItems(client, agentId, result, context);
585
761
  }
586
762
 
587
763
  case "broadcast": {
@@ -593,7 +769,7 @@ export async function handleToolCall(
593
769
  subject: args.subject as string,
594
770
  body: args.body as string,
595
771
  });
596
- return wrapWithMessages(client, agentId, result);
772
+ return wrapWithPendingItems(client, agentId, result, context);
597
773
  }
598
774
 
599
775
  case "check_inbox": {
@@ -604,41 +780,41 @@ export async function handleToolCall(
604
780
 
605
781
  case "mark_read": {
606
782
  const result = await client.markRead(args.message_id as string);
607
- return wrapWithMessages(client, agentId, result);
783
+ return wrapWithPendingItems(client, agentId, result, context);
608
784
  }
609
785
 
610
786
  case "reply": {
611
787
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
612
788
  const result = await client.reply(args.message_id as string, agentId, args.body as string);
613
- return wrapWithMessages(client, agentId, result);
789
+ return wrapWithPendingItems(client, agentId, result, context);
614
790
  }
615
791
 
616
792
  // Discovery
617
793
  case "list_agents": {
618
794
  const result = await client.listAgents(args.status as string | undefined);
619
- return wrapWithMessages(client, agentId, result);
795
+ return wrapWithPendingItems(client, agentId, result, context);
620
796
  }
621
797
 
622
798
  case "get_agent": {
623
799
  const result = await client.getAgent(args.id as string);
624
- return wrapWithMessages(client, agentId, result);
800
+ return wrapWithPendingItems(client, agentId, result, context);
625
801
  }
626
802
 
627
803
  case "list_channels": {
628
804
  const result = await client.listChannels();
629
- return wrapWithMessages(client, agentId, result);
805
+ return wrapWithPendingItems(client, agentId, result, context);
630
806
  }
631
807
 
632
808
  case "join_channel": {
633
809
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
634
810
  const result = await client.joinChannel(args.channel as string, agentId);
635
- return wrapWithMessages(client, agentId, result);
811
+ return wrapWithPendingItems(client, agentId, result, context);
636
812
  }
637
813
 
638
814
  case "leave_channel": {
639
815
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
640
816
  const result = await client.leaveChannel(args.channel as string, agentId);
641
- return wrapWithMessages(client, agentId, result);
817
+ return wrapWithPendingItems(client, agentId, result, context);
642
818
  }
643
819
 
644
820
  // Task completion
@@ -651,25 +827,58 @@ export async function handleToolCall(
651
827
  time_spent: args.time_spent as string | undefined,
652
828
  next_steps: args.next_steps as string | undefined,
653
829
  });
654
- return wrapWithMessages(client, agentId, result);
830
+ return wrapWithPendingItems(client, agentId, result, context);
655
831
  }
656
832
 
657
833
  case "get_pending_tasks": {
658
834
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
659
835
  const result = await client.getPendingTasks(agentId);
660
- return wrapWithMessages(client, agentId, result);
836
+ return wrapWithPendingItems(client, agentId, result, context);
661
837
  }
662
838
 
663
839
  case "accept_task": {
664
840
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
665
841
  const result = await client.acceptTask(agentId, args.task_id as string);
666
- return wrapWithMessages(client, agentId, result);
842
+ return wrapWithPendingItems(client, agentId, result, context);
667
843
  }
668
844
 
669
845
  case "decline_task": {
670
846
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
671
847
  const result = await client.declineTask(agentId, args.task_id as string, args.reason as string);
672
- return wrapWithMessages(client, agentId, result);
848
+ return wrapWithPendingItems(client, agentId, result, context);
849
+ }
850
+
851
+ // Ticket system tools
852
+ case "get_available_tasks": {
853
+ if (!agentId) throw new Error("Not registered. Call agent_register first.");
854
+ const result = await client.getAvailableTasks({
855
+ status: args.status as string | undefined,
856
+ project_id: args.project_id as string | undefined,
857
+ priority: args.priority as string | undefined,
858
+ });
859
+ return wrapWithPendingItems(client, agentId, result, context);
860
+ }
861
+
862
+ case "get_task_context": {
863
+ if (!agentId) throw new Error("Not registered. Call agent_register first.");
864
+ const result = await client.getTicketTask(args.task_id as string);
865
+ return wrapWithPendingItems(client, agentId, result, context);
866
+ }
867
+
868
+ case "claim_ticket_task": {
869
+ if (!agentId) throw new Error("Not registered. Call agent_register first.");
870
+ const result = await client.claimTicketTask(args.task_id as string, agentId);
871
+ return wrapWithPendingItems(client, agentId, result, context);
872
+ }
873
+
874
+ case "reach_checkpoint": {
875
+ if (!agentId) throw new Error("Not registered. Call agent_register first.");
876
+ const result = await client.createCheckpoint(
877
+ args.task_id as string,
878
+ agentId,
879
+ args.description as string
880
+ );
881
+ return wrapWithPendingItems(client, agentId, result, context);
673
882
  }
674
883
 
675
884
  default:
@@ -26,11 +26,15 @@ const mockClient = {
26
26
  listAgents: vi.fn(),
27
27
  sendMessage: vi.fn(),
28
28
  getInbox: vi.fn(),
29
+ getPendingTasks: vi.fn(),
29
30
  markRead: vi.fn(),
30
31
  reply: vi.fn(),
31
32
  listChannels: vi.fn(),
32
33
  joinChannel: vi.fn(),
33
34
  leaveChannel: vi.fn(),
35
+ completeTask: vi.fn(),
36
+ acceptTask: vi.fn(),
37
+ declineTask: vi.fn(),
34
38
  } as unknown as ApiClient;
35
39
 
36
40
  // Mock context
@@ -43,6 +47,7 @@ function createMockContext(): ToolContext {
43
47
  },
44
48
  stopHeartbeat: vi.fn(),
45
49
  getWorkingDir: () => "/tmp/test-workdir",
50
+ getPushedItems: () => ({ tasks: [], messages: [] }),
46
51
  };
47
52
  }
48
53
 
@@ -89,8 +94,9 @@ describe("handleToolCall", () => {
89
94
  beforeEach(() => {
90
95
  vi.clearAllMocks();
91
96
  context = createMockContext();
92
- // Default mock for inbox (used by message wrapping)
97
+ // Default mocks for wrapping (used by wrapWithPendingItems)
93
98
  vi.mocked(mockClient.getInbox).mockResolvedValue({ messages: [], total: 0 });
99
+ vi.mocked(mockClient.getPendingTasks).mockResolvedValue({ tasks: [], total: 0 });
94
100
  });
95
101
 
96
102
  describe("agent_register", () => {