@gethmy/mcp 2.2.0 → 2.2.1

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.
@@ -51,16 +51,16 @@ describe("auto-start", () => {
51
51
 
52
52
  expect(client.startAgentSession).toHaveBeenCalledTimes(1);
53
53
  expect(client.startAgentSession).toHaveBeenCalledWith(CARD_A, {
54
- agentIdentifier: "auto",
55
- agentName: "Auto-detected Agent",
54
+ agentIdentifier: "unknown",
55
+ agentName: "Unknown Agent",
56
56
  status: "working",
57
57
  });
58
58
  expect(getActiveSessions().size).toBe(1);
59
59
  const session = getActiveSessions().get(CARD_A);
60
60
  expect(session).toBeDefined();
61
61
  expect(session!.isExplicit).toBe(false);
62
- expect(session!.agentIdentifier).toBe("auto");
63
- expect(session!.agentName).toBe("Auto-detected Agent");
62
+ expect(session!.agentIdentifier).toBe("unknown");
63
+ expect(session!.agentName).toBe("Unknown Agent");
64
64
  });
65
65
 
66
66
  test("does NOT trigger on autoStart=false", async () => {
@@ -109,7 +109,7 @@ describe("auto-start", () => {
109
109
 
110
110
  // Should still be tracked despite API error
111
111
  expect(getActiveSessions().size).toBe(1);
112
- expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("auto");
112
+ expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("unknown");
113
113
  });
114
114
 
115
115
  test("uses clientGetter when no client in options", async () => {
@@ -328,8 +328,8 @@ describe("inactivity timeout", () => {
328
328
  startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 1000,
329
329
  lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 1000,
330
330
  isExplicit: false,
331
- agentIdentifier: "auto",
332
- agentName: "Auto-detected Agent",
331
+ agentIdentifier: "unknown",
332
+ agentName: "Unknown Agent",
333
333
  });
334
334
 
335
335
  checkInactivity();
@@ -355,8 +355,8 @@ describe("inactivity timeout", () => {
355
355
  startedAt: Date.now(),
356
356
  lastActivityAt: Date.now(), // just now — well within timeout
357
357
  isExplicit: false,
358
- agentIdentifier: "auto",
359
- agentName: "Auto-detected Agent",
358
+ agentIdentifier: "unknown",
359
+ agentName: "Unknown Agent",
360
360
  });
361
361
 
362
362
  checkInactivity();
@@ -398,8 +398,8 @@ describe("inactivity timeout", () => {
398
398
  startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
399
399
  lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
400
400
  isExplicit: false,
401
- agentIdentifier: "auto",
402
- agentName: "Auto-detected Agent",
401
+ agentIdentifier: "unknown",
402
+ agentName: "Unknown Agent",
403
403
  });
404
404
 
405
405
  // CARD_B — still active
@@ -408,8 +408,8 @@ describe("inactivity timeout", () => {
408
408
  startedAt: Date.now() - 1000,
409
409
  lastActivityAt: Date.now() - 1000,
410
410
  isExplicit: false,
411
- agentIdentifier: "auto",
412
- agentName: "Auto-detected Agent",
411
+ agentIdentifier: "unknown",
412
+ agentName: "Unknown Agent",
413
413
  });
414
414
 
415
415
  // CARD_C — timed out but explicit
@@ -442,8 +442,8 @@ describe("inactivity timeout", () => {
442
442
  startedAt: 0,
443
443
  lastActivityAt: 0,
444
444
  isExplicit: false,
445
- agentIdentifier: "auto",
446
- agentName: "Auto-detected Agent",
445
+ agentIdentifier: "unknown",
446
+ agentName: "Unknown Agent",
447
447
  });
448
448
 
449
449
  // Should not throw
@@ -464,8 +464,8 @@ describe("inactivity timeout", () => {
464
464
  startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
465
465
  lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
466
466
  isExplicit: false,
467
- agentIdentifier: "auto",
468
- agentName: "Auto-detected Agent",
467
+ agentIdentifier: "unknown",
468
+ agentName: "Unknown Agent",
469
469
  });
470
470
 
471
471
  // Refresh activity
@@ -495,8 +495,8 @@ describe("markExplicit", () => {
495
495
 
496
496
  markExplicit(CARD_A);
497
497
  expect(getActiveSessions().get(CARD_A)!.isExplicit).toBe(true);
498
- // agentIdentifier should remain "auto" since it was auto-started
499
- expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("auto");
498
+ // agentIdentifier should remain "unknown" since no clientInfo was provided
499
+ expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("unknown");
500
500
  });
501
501
 
502
502
  test("creates new tracking entry for unknown card", () => {
@@ -583,8 +583,8 @@ describe("shutdown", () => {
583
583
  startedAt: Date.now(),
584
584
  lastActivityAt: Date.now(),
585
585
  isExplicit: false,
586
- agentIdentifier: "auto",
587
- agentName: "Auto-detected Agent",
586
+ agentIdentifier: "unknown",
587
+ agentName: "Unknown Agent",
588
588
  });
589
589
 
590
590
  await shutdownAllSessions();
@@ -606,8 +606,8 @@ describe("shutdown", () => {
606
606
  startedAt: Date.now(),
607
607
  lastActivityAt: Date.now(),
608
608
  isExplicit: false,
609
- agentIdentifier: "auto",
610
- agentName: "Auto-detected Agent",
609
+ agentIdentifier: "unknown",
610
+ agentName: "Unknown Agent",
611
611
  });
612
612
  getActiveSessions().set(CARD_B, {
613
613
  cardId: CARD_B,
@@ -633,8 +633,8 @@ describe("shutdown", () => {
633
633
  startedAt: Date.now(),
634
634
  lastActivityAt: Date.now(),
635
635
  isExplicit: false,
636
- agentIdentifier: "auto",
637
- agentName: "Auto-detected Agent",
636
+ agentIdentifier: "unknown",
637
+ agentName: "Unknown Agent",
638
638
  });
639
639
 
640
640
  // Should not throw
@@ -660,16 +660,16 @@ describe("shutdown", () => {
660
660
  startedAt: Date.now(),
661
661
  lastActivityAt: Date.now(),
662
662
  isExplicit: false,
663
- agentIdentifier: "auto",
664
- agentName: "Auto-detected Agent",
663
+ agentIdentifier: "unknown",
664
+ agentName: "Unknown Agent",
665
665
  });
666
666
  getActiveSessions().set(CARD_B, {
667
667
  cardId: CARD_B,
668
668
  startedAt: Date.now(),
669
669
  lastActivityAt: Date.now(),
670
670
  isExplicit: false,
671
- agentIdentifier: "auto",
672
- agentName: "Auto-detected Agent",
671
+ agentIdentifier: "unknown",
672
+ agentName: "Unknown Agent",
673
673
  });
674
674
 
675
675
  await shutdownAllSessions();
@@ -826,8 +826,8 @@ describe("edge cases", () => {
826
826
  startedAt: 0,
827
827
  lastActivityAt: 0,
828
828
  isExplicit: false,
829
- agentIdentifier: "auto",
830
- agentName: "Auto-detected Agent",
829
+ agentIdentifier: "unknown",
830
+ agentName: "Unknown Agent",
831
831
  });
832
832
 
833
833
  checkInactivity();
@@ -850,8 +850,8 @@ describe("edge cases", () => {
850
850
  startedAt: 0,
851
851
  lastActivityAt: 0,
852
852
  isExplicit: false,
853
- agentIdentifier: "auto",
854
- agentName: "Auto-detected Agent",
853
+ agentIdentifier: "unknown",
854
+ agentName: "Unknown Agent",
855
855
  });
856
856
 
857
857
  checkInactivity();
package/src/api-client.ts CHANGED
@@ -952,6 +952,211 @@ export class HarmonyApiClient {
952
952
  }> {
953
953
  return this.request("POST", "/api-keys", { name });
954
954
  }
955
+
956
+ // ============ PROMPT GENERATION ============
957
+
958
+ /**
959
+ * Generate a prompt for a card with full memory context assembly.
960
+ *
961
+ * This is the shared entry point for prompt generation — used by the MCP
962
+ * server tool handler and the agent daemon. It fetches the card, assembles
963
+ * relevant memories, and produces a role-framed prompt.
964
+ */
965
+ async generateCardPrompt(options: {
966
+ cardId: string;
967
+ workspaceId: string;
968
+ projectId?: string;
969
+ variant?: "analysis" | "draft" | "execute";
970
+ customConstraints?: string;
971
+ contextOptions?: Partial<{
972
+ includeSubtasks: boolean;
973
+ includeLinks: boolean;
974
+ includeDescription: boolean;
975
+ }>;
976
+ }): Promise<{
977
+ prompt: string;
978
+ variant: string;
979
+ category: string;
980
+ role: string;
981
+ contextSummary: {
982
+ hasDescription: boolean;
983
+ labelCount: number;
984
+ subtaskCount: number;
985
+ completedSubtasks: number;
986
+ linkedCardCount: number;
987
+ memoryCount: number;
988
+ };
989
+ tokenEstimate: number;
990
+ assemblyId?: string;
991
+ cardId: string;
992
+ shortId: number;
993
+ title: string;
994
+ }> {
995
+ const { assembleContext, cacheManifest, generatePrompt } =
996
+ await loadPromptModules();
997
+
998
+ // Fetch card data
999
+ const cardResult = await this.getCard(options.cardId);
1000
+ const cardData = cardResult.card as CardPromptData;
1001
+
1002
+ // Try to get column info
1003
+ let columnData: { name: string } | null = null;
1004
+ const projectIdForBoard = options.projectId || cardData.project_id;
1005
+ if (projectIdForBoard) {
1006
+ try {
1007
+ const board = await this.getBoard(projectIdForBoard, { summary: true });
1008
+ const column = (
1009
+ board.columns as Array<{ id: string; name: string }>
1010
+ ).find((col) => col.id === cardData.column_id);
1011
+ if (column) {
1012
+ columnData = { name: column.name };
1013
+ }
1014
+ } catch {
1015
+ // Column info not available, continue without it
1016
+ }
1017
+ }
1018
+
1019
+ const variant = options.variant || "execute";
1020
+
1021
+ // Assemble memory context
1022
+ let assembledContextStr: string | undefined;
1023
+ let assemblyId: string | undefined;
1024
+ let memories: MemoryItem[] | undefined;
1025
+
1026
+ try {
1027
+ if (options.workspaceId && cardData.title) {
1028
+ const cardLabels = (cardData.labels || []).map((l) => l.name);
1029
+ const taskContext = [cardData.title, cardData.description || ""]
1030
+ .filter(Boolean)
1031
+ .join(" ");
1032
+
1033
+ const assembled = await assembleContext({
1034
+ workspaceId: options.workspaceId,
1035
+ projectId: options.projectId,
1036
+ taskContext,
1037
+ cardLabels,
1038
+ cardId: cardData.id,
1039
+ client: this,
1040
+ });
1041
+
1042
+ if (assembled.context) {
1043
+ assembledContextStr = assembled.context;
1044
+ assemblyId = assembled.manifest.assemblyId;
1045
+ cacheManifest(assembled.manifest);
1046
+ }
1047
+ }
1048
+ } catch (err) {
1049
+ // Context assembly failed, try legacy fallback
1050
+ const msg = err instanceof Error ? err.message : String(err);
1051
+ console.debug(`[generateCardPrompt] Context assembly failed: ${msg}`);
1052
+ try {
1053
+ if (options.workspaceId && cardData.title) {
1054
+ const memoryResult = await this.searchMemoryEntities(
1055
+ options.workspaceId,
1056
+ cardData.title,
1057
+ {
1058
+ project_id: options.projectId,
1059
+ limit: 5,
1060
+ },
1061
+ );
1062
+ if (memoryResult.entities?.length > 0) {
1063
+ memories = (memoryResult.entities as MemoryItem[]).map((e) => ({
1064
+ id: e.id,
1065
+ type: e.type,
1066
+ title: e.title,
1067
+ content: e.content,
1068
+ confidence: e.confidence,
1069
+ tags: e.tags || [],
1070
+ }));
1071
+ }
1072
+ }
1073
+ } catch (fallbackErr) {
1074
+ const fallbackMsg =
1075
+ fallbackErr instanceof Error
1076
+ ? fallbackErr.message
1077
+ : String(fallbackErr);
1078
+ console.debug(
1079
+ `[generateCardPrompt] Memory fallback also failed: ${fallbackMsg}`,
1080
+ );
1081
+ }
1082
+ }
1083
+
1084
+ const result = generatePrompt({
1085
+ card: cardData,
1086
+ column: columnData,
1087
+ variant,
1088
+ contextOptions: options.contextOptions,
1089
+ customConstraints: options.customConstraints,
1090
+ memories,
1091
+ assembledContext: assembledContextStr,
1092
+ assemblyId,
1093
+ });
1094
+
1095
+ return {
1096
+ ...result,
1097
+ cardId: cardData.id,
1098
+ shortId: cardData.short_id,
1099
+ title: cardData.title,
1100
+ };
1101
+ }
1102
+ }
1103
+
1104
+ // Shared types for generateCardPrompt to avoid inline assertions
1105
+ interface CardPromptData {
1106
+ id: string;
1107
+ short_id: number;
1108
+ title: string;
1109
+ description?: string | null;
1110
+ priority: string;
1111
+ due_date?: string | null;
1112
+ done: boolean;
1113
+ labels?: Array<{ name: string; color: string }>;
1114
+ subtasks?: Array<{ title: string; completed: boolean }>;
1115
+ links?: Array<{
1116
+ target_card: { short_id: number; title: string };
1117
+ display_type: string;
1118
+ direction: "outgoing" | "incoming";
1119
+ }>;
1120
+ assignee?: { full_name?: string; email: string } | null;
1121
+ column_id?: string;
1122
+ project_id?: string;
1123
+ }
1124
+
1125
+ interface MemoryItem {
1126
+ id: string;
1127
+ type: string;
1128
+ title: string;
1129
+ content: string;
1130
+ confidence: number;
1131
+ tags: string[];
1132
+ }
1133
+
1134
+ // Cached dynamic imports for context-assembly and prompt-builder
1135
+ let _promptModules: {
1136
+ assembleContext: Awaited<
1137
+ typeof import("./context-assembly.js")
1138
+ >["assembleContext"];
1139
+ cacheManifest: Awaited<
1140
+ typeof import("./context-assembly.js")
1141
+ >["cacheManifest"];
1142
+ generatePrompt: Awaited<
1143
+ typeof import("./prompt-builder.js")
1144
+ >["generatePrompt"];
1145
+ } | null = null;
1146
+
1147
+ async function loadPromptModules() {
1148
+ if (!_promptModules) {
1149
+ const [ca, pb] = await Promise.all([
1150
+ import("./context-assembly.js"),
1151
+ import("./prompt-builder.js"),
1152
+ ]);
1153
+ _promptModules = {
1154
+ assembleContext: ca.assembleContext,
1155
+ cacheManifest: ca.cacheManifest,
1156
+ generatePrompt: pb.generatePrompt,
1157
+ };
1158
+ }
1159
+ return _promptModules;
955
1160
  }
956
1161
 
957
1162
  // Singleton instance
@@ -4,6 +4,10 @@
4
4
  * Automatically detects agent session boundaries by monitoring tool calls.
5
5
  * Sessions auto-start when card-mutating tools are called, and auto-end
6
6
  * after 10 minutes of inactivity or when a different card is worked on.
7
+ *
8
+ * Agent identity is resolved from the MCP client's `initialize` handshake
9
+ * (clientInfo.name), so "Claude Code", "Cursor", "Windsurf", etc. are
10
+ * detected automatically — no hardcoded fallback needed.
7
11
  */
8
12
 
9
13
  import type { HarmonyApiClient } from "./api-client.js";
@@ -24,6 +28,43 @@ export type EndSessionCallback = (
24
28
  status: "completed" | "paused",
25
29
  ) => Promise<void>;
26
30
 
31
+ /** MCP client identity from the `initialize` handshake */
32
+ export interface ClientInfo {
33
+ name: string;
34
+ version?: string;
35
+ }
36
+
37
+ /** Well-known MCP client names → human-friendly display names */
38
+ const CLIENT_DISPLAY_NAMES: Record<string, string> = {
39
+ "claude-code": "Claude Code",
40
+ "claude-desktop": "Claude Desktop",
41
+ cursor: "Cursor",
42
+ windsurf: "Windsurf",
43
+ cline: "Cline",
44
+ continue: "Continue",
45
+ "codex-cli": "OpenAI Codex",
46
+ zed: "Zed",
47
+ "gemini-cli": "Gemini CLI",
48
+ };
49
+
50
+ /** Derive a slug-style identifier from a client name */
51
+ function toIdentifier(name: string): string {
52
+ return name.toLowerCase().replace(/\s+/g, "-");
53
+ }
54
+
55
+ /** Resolve agent identity from MCP client info */
56
+ export function resolveAgentIdentity(info: ClientInfo | null): {
57
+ agentIdentifier: string;
58
+ agentName: string;
59
+ } {
60
+ if (!info?.name) {
61
+ return { agentIdentifier: "unknown", agentName: "Unknown Agent" };
62
+ }
63
+ const key = toIdentifier(info.name);
64
+ const displayName = CLIENT_DISPLAY_NAMES[key] ?? info.name; // use raw name if not in map
65
+ return { agentIdentifier: key, agentName: displayName };
66
+ }
67
+
27
68
  /** Tools that trigger auto-start of a session */
28
69
  export const AUTO_START_TRIGGERS = new Set([
29
70
  "harmony_generate_prompt",
@@ -42,18 +83,22 @@ const activeSessions = new Map<string, TrackedSession>();
42
83
  let inactivityTimer: ReturnType<typeof setInterval> | null = null;
43
84
  let endCallback: EndSessionCallback | null = null;
44
85
  let clientGetter: (() => HarmonyApiClient) | null = null;
86
+ let clientInfoGetter: (() => ClientInfo | null) | null = null;
45
87
 
46
88
  /**
47
89
  * Initialize auto-session tracking.
48
90
  * @param callback Called when an auto-session ends (runs the learning pipeline)
49
91
  * @param getClient Function to get the current API client
92
+ * @param getClientInfo Function to get MCP client identity from the initialize handshake
50
93
  */
51
94
  export function initAutoSession(
52
95
  callback: EndSessionCallback,
53
96
  getClient: () => HarmonyApiClient,
97
+ getClientInfo?: () => ClientInfo | null,
54
98
  ): void {
55
99
  endCallback = callback;
56
100
  clientGetter = getClient;
101
+ clientInfoGetter = getClientInfo ?? null;
57
102
 
58
103
  if (inactivityTimer) clearInterval(inactivityTimer);
59
104
  inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
@@ -95,11 +140,15 @@ export async function trackActivity(
95
140
  await autoEndSession(client, otherCardId, "completed");
96
141
  }
97
142
 
143
+ // Resolve agent identity from MCP client info
144
+ const info = clientInfoGetter?.() ?? null;
145
+ const { agentIdentifier, agentName } = resolveAgentIdentity(info);
146
+
98
147
  // Start a new auto-session
99
148
  try {
100
149
  await client.startAgentSession(cardId, {
101
- agentIdentifier: "auto",
102
- agentName: "Auto-detected Agent",
150
+ agentIdentifier,
151
+ agentName,
103
152
  status: "working",
104
153
  });
105
154
  } catch {
@@ -111,8 +160,8 @@ export async function trackActivity(
111
160
  startedAt: now,
112
161
  lastActivityAt: now,
113
162
  isExplicit: false,
114
- agentIdentifier: "auto",
115
- agentName: "Auto-detected Agent",
163
+ agentIdentifier,
164
+ agentName,
116
165
  });
117
166
  }
118
167
 
@@ -176,6 +225,7 @@ export function destroyAutoSession(): void {
176
225
  activeSessions.clear();
177
226
  endCallback = null;
178
227
  clientGetter = null;
228
+ clientInfoGetter = null;
179
229
  }
180
230
 
181
231
  /**
package/src/remote.ts CHANGED
@@ -25,7 +25,8 @@ import { registerHandlers, type ToolDeps } from "./server.js";
25
25
  // ---------------------------------------------------------------------------
26
26
  // Config from env
27
27
  // ---------------------------------------------------------------------------
28
- const HARMONY_API_URL = process.env.HARMONY_API_URL || "https://app.gethmy.com/api";
28
+ const HARMONY_API_URL =
29
+ process.env.HARMONY_API_URL || "https://app.gethmy.com/api";
29
30
  const PORT = parseInt(process.env.PORT || "3002", 10);
30
31
 
31
32
  // ---------------------------------------------------------------------------