agenthub-multiagent-mcp 1.1.4 → 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
 
@@ -468,18 +610,23 @@ export async function handleToolCall(
468
610
  } else {
469
611
  // Try to reconnect
470
612
  try {
613
+ // Use provided name, or fall back to stored name
614
+ const reconnectName = (args.name as string) || existingState.name;
615
+
471
616
  const reconnectResult = await client.reconnectAgent(
472
617
  existingState.agent_id,
473
618
  existingState.token,
474
619
  owner,
475
- args.model as string // Pass the requested model to update it
620
+ args.model as string,
621
+ reconnectName
476
622
  );
477
623
 
478
624
  context.setCurrentAgentId(existingState.agent_id);
479
625
 
480
- // Update state with any new info
626
+ // Update state with returned name from server
481
627
  state.saveState(workingDir, {
482
628
  ...existingState,
629
+ name: reconnectResult.name,
483
630
  last_task: undefined, // Cleared on reconnect
484
631
  });
485
632
 
@@ -487,8 +634,8 @@ export async function handleToolCall(
487
634
  ...reconnectResult,
488
635
  mode: "reconnected",
489
636
  message: reconnectResult.was_offline
490
- ? `Reconnected after timeout. You have ${reconnectResult.pending_tasks_count} pending tasks and ${reconnectResult.unread_messages_count} unread messages.`
491
- : `Reconnected. You have ${reconnectResult.pending_tasks_count} pending tasks and ${reconnectResult.unread_messages_count} unread messages.`,
637
+ ? `Reconnected as ${reconnectResult.name || existingState.agent_id}. You have ${reconnectResult.pending_tasks_count} pending tasks and ${reconnectResult.unread_messages_count} unread messages.`
638
+ : `Reconnected as ${reconnectResult.name || existingState.agent_id}. You have ${reconnectResult.pending_tasks_count} pending tasks and ${reconnectResult.unread_messages_count} unread messages.`,
492
639
  };
493
640
  } catch (error) {
494
641
  // Reconnection failed (invalid token, agent not found, etc.)
@@ -498,34 +645,68 @@ export async function handleToolCall(
498
645
  }
499
646
  }
500
647
 
501
- // Fresh registration
502
- // Auto-detect model from environment or use provided value
503
- const model = args.model as string ||
504
- process.env.CLAUDE_MODEL ||
505
- "claude-opus-4-5-20251101";
506
-
507
- const result = await client.registerAgent(
648
+ // Fresh registration - use browser-based flow
649
+ // Step 1: Initialize registration session
650
+ const initResult = await client.initRegistration(
508
651
  requestedId,
509
- args.name as string | undefined,
510
- owner,
511
- workingDir,
512
- model
652
+ args.name as string | undefined
513
653
  );
514
654
 
515
- 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);
516
694
 
517
695
  // Save state for future reconnection
518
696
  state.saveState(workingDir, {
519
- agent_id: requestedId,
697
+ agent_id: agentId,
698
+ name: registrationResult.agent_name || agentId,
520
699
  owner,
521
- token: result.token,
522
- registered_at: result.registered_at,
700
+ token: registrationResult.connect_token!,
701
+ registered_at: new Date().toISOString(),
523
702
  });
524
703
 
525
704
  return {
526
- ...result,
527
- mode: "registered",
528
- message: `Registered as ${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.`,
529
710
  };
530
711
  }
531
712
 
@@ -534,15 +715,16 @@ export async function handleToolCall(
534
715
  const result = await client.startWork(
535
716
  agentId,
536
717
  args.task as string,
537
- args.project as string | undefined
718
+ args.project as string | undefined,
719
+ args.task_id as string | undefined
538
720
  );
539
- return wrapWithMessages(client, agentId, result);
721
+ return wrapWithPendingItems(client, agentId, result, context);
540
722
  }
541
723
 
542
724
  case "agent_set_status": {
543
725
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
544
726
  const result = await client.heartbeat(agentId, args.status as "online" | "busy");
545
- return wrapWithMessages(client, agentId, result);
727
+ return wrapWithPendingItems(client, agentId, result, context);
546
728
  }
547
729
 
548
730
  case "agent_disconnect": {
@@ -563,7 +745,7 @@ export async function handleToolCall(
563
745
  body: args.body as string,
564
746
  priority: args.priority as string | undefined,
565
747
  });
566
- return wrapWithMessages(client, agentId, result);
748
+ return wrapWithPendingItems(client, agentId, result, context);
567
749
  }
568
750
 
569
751
  case "send_to_channel": {
@@ -575,7 +757,7 @@ export async function handleToolCall(
575
757
  subject: args.subject as string,
576
758
  body: args.body as string,
577
759
  });
578
- return wrapWithMessages(client, agentId, result);
760
+ return wrapWithPendingItems(client, agentId, result, context);
579
761
  }
580
762
 
581
763
  case "broadcast": {
@@ -587,7 +769,7 @@ export async function handleToolCall(
587
769
  subject: args.subject as string,
588
770
  body: args.body as string,
589
771
  });
590
- return wrapWithMessages(client, agentId, result);
772
+ return wrapWithPendingItems(client, agentId, result, context);
591
773
  }
592
774
 
593
775
  case "check_inbox": {
@@ -598,41 +780,41 @@ export async function handleToolCall(
598
780
 
599
781
  case "mark_read": {
600
782
  const result = await client.markRead(args.message_id as string);
601
- return wrapWithMessages(client, agentId, result);
783
+ return wrapWithPendingItems(client, agentId, result, context);
602
784
  }
603
785
 
604
786
  case "reply": {
605
787
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
606
788
  const result = await client.reply(args.message_id as string, agentId, args.body as string);
607
- return wrapWithMessages(client, agentId, result);
789
+ return wrapWithPendingItems(client, agentId, result, context);
608
790
  }
609
791
 
610
792
  // Discovery
611
793
  case "list_agents": {
612
794
  const result = await client.listAgents(args.status as string | undefined);
613
- return wrapWithMessages(client, agentId, result);
795
+ return wrapWithPendingItems(client, agentId, result, context);
614
796
  }
615
797
 
616
798
  case "get_agent": {
617
799
  const result = await client.getAgent(args.id as string);
618
- return wrapWithMessages(client, agentId, result);
800
+ return wrapWithPendingItems(client, agentId, result, context);
619
801
  }
620
802
 
621
803
  case "list_channels": {
622
804
  const result = await client.listChannels();
623
- return wrapWithMessages(client, agentId, result);
805
+ return wrapWithPendingItems(client, agentId, result, context);
624
806
  }
625
807
 
626
808
  case "join_channel": {
627
809
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
628
810
  const result = await client.joinChannel(args.channel as string, agentId);
629
- return wrapWithMessages(client, agentId, result);
811
+ return wrapWithPendingItems(client, agentId, result, context);
630
812
  }
631
813
 
632
814
  case "leave_channel": {
633
815
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
634
816
  const result = await client.leaveChannel(args.channel as string, agentId);
635
- return wrapWithMessages(client, agentId, result);
817
+ return wrapWithPendingItems(client, agentId, result, context);
636
818
  }
637
819
 
638
820
  // Task completion
@@ -645,25 +827,58 @@ export async function handleToolCall(
645
827
  time_spent: args.time_spent as string | undefined,
646
828
  next_steps: args.next_steps as string | undefined,
647
829
  });
648
- return wrapWithMessages(client, agentId, result);
830
+ return wrapWithPendingItems(client, agentId, result, context);
649
831
  }
650
832
 
651
833
  case "get_pending_tasks": {
652
834
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
653
835
  const result = await client.getPendingTasks(agentId);
654
- return wrapWithMessages(client, agentId, result);
836
+ return wrapWithPendingItems(client, agentId, result, context);
655
837
  }
656
838
 
657
839
  case "accept_task": {
658
840
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
659
841
  const result = await client.acceptTask(agentId, args.task_id as string);
660
- return wrapWithMessages(client, agentId, result);
842
+ return wrapWithPendingItems(client, agentId, result, context);
661
843
  }
662
844
 
663
845
  case "decline_task": {
664
846
  if (!agentId) throw new Error("Not registered. Call agent_register first.");
665
847
  const result = await client.declineTask(agentId, args.task_id as string, args.reason as string);
666
- 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);
667
882
  }
668
883
 
669
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,14 +94,16 @@ 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", () => {
97
103
  it("should register agent and set current agent ID", async () => {
98
104
  const mockResult = {
99
105
  agent_id: "test-agent",
106
+ name: "Test Agent",
100
107
  token: "test-token-uuid",
101
108
  model: "claude-opus-4.5",
102
109
  model_provider: "anthropic",