@gethmy/mcp 2.13.4 → 2.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.13.4",
3
+ "version": "2.15.0",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -16,6 +16,10 @@
16
16
  "./src/config.js": {
17
17
  "types": "./src/config.ts",
18
18
  "default": "./dist/lib/config.js"
19
+ },
20
+ "./src/oauth-refresh.js": {
21
+ "types": "./src/oauth-refresh.ts",
22
+ "default": "./dist/lib/oauth-refresh.js"
19
23
  }
20
24
  },
21
25
  "bin": {
@@ -55,12 +59,12 @@
55
59
  "bun": ">=1.0.0"
56
60
  },
57
61
  "scripts": {
58
- "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
62
+ "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts src/oauth-refresh.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
59
63
  "build:bun": "bun build src/index.ts src/http.ts src/remote.ts src/cli.ts --outdir dist --target bun",
60
64
  "serve:remote": "bun src/remote.ts",
61
65
  "dev": "bun --watch src/index.ts",
62
66
  "test": "bun run test:unit && bun run test:integration",
63
- "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/hmy-config.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
67
+ "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/hmy-config.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts src/__tests__/auto-session.test.ts",
64
68
  "test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
65
69
  "typecheck": "tsc --noEmit",
66
70
  "prepublishOnly": "bun run typecheck && bun run build"
package/src/api-client.ts CHANGED
@@ -527,6 +527,70 @@ export class HarmonyApiClient {
527
527
  return this.request("GET", `/board/${projectId}${query}`);
528
528
  }
529
529
 
530
+ /**
531
+ * Fetch the FULL board — every non-archived card — by paginating past the
532
+ * server's default 50-card window (`getBoard` caps at `limit=50` to keep MCP
533
+ * responses under the token budget).
534
+ *
535
+ * Daemon board scans must see the whole board: with the 50-card cap the
536
+ * entire `Review` column fell outside the first-50-by-position window once a
537
+ * project crossed 50 cards, silently starving the review pipeline (#628 — a
538
+ * 272-card project had 0 reviews for ~2 days). This has no LLM-context/token
539
+ * concern daemon-side, so it walks `offset` until `pagination.hasMore` is
540
+ * false and concatenates every page's cards.
541
+ *
542
+ * Summary mode carries no `cards` array, so callers that only need per-column
543
+ * counts should keep using `getBoard({ summary: true })`. This is the
544
+ * card-list path.
545
+ */
546
+ async getFullBoard(
547
+ projectId: string,
548
+ options?: {
549
+ columnId?: string;
550
+ includeArchived?: boolean;
551
+ labelName?: string;
552
+ /** Page size for the offset walk. Defaults to 200. */
553
+ pageSize?: number;
554
+ },
555
+ ): Promise<{
556
+ project: unknown;
557
+ columns: unknown[];
558
+ cards: unknown[];
559
+ labels: unknown[];
560
+ totalCards: number;
561
+ }> {
562
+ const { pageSize = 200, ...boardOpts } = options ?? {};
563
+ const cards: unknown[] = [];
564
+ let offset = 0;
565
+ let last: Awaited<ReturnType<HarmonyApiClient["getBoard"]>> | null = null;
566
+
567
+ // Walk pages until the server reports no more. Guard on `hasMore` (not
568
+ // `cards.length`) so a label-filtered final page shorter than pageSize
569
+ // terminates cleanly; the empty-page break is a belt-and-braces backstop
570
+ // against a server that omits `pagination`.
571
+ for (;;) {
572
+ const page = await this.getBoard(projectId, {
573
+ ...boardOpts,
574
+ limit: pageSize,
575
+ offset,
576
+ });
577
+ last = page;
578
+ const pageCards = page.cards ?? [];
579
+ cards.push(...pageCards);
580
+ const hasMore = page.pagination?.hasMore ?? false;
581
+ if (!hasMore || pageCards.length === 0) break;
582
+ offset += pageSize;
583
+ }
584
+
585
+ return {
586
+ project: last?.project,
587
+ columns: last?.columns ?? [],
588
+ cards,
589
+ labels: last?.labels ?? [],
590
+ totalCards: last?.pagination?.totalCards ?? cards.length,
591
+ };
592
+ }
593
+
530
594
  // ============ CARD OPERATIONS ============
531
595
 
532
596
  async createCard(
@@ -719,6 +783,17 @@ export class HarmonyApiClient {
719
783
  return this.request("GET", `/cards/${cardId}/external-links`);
720
784
  }
721
785
 
786
+ async addExternalLink(
787
+ cardId: string,
788
+ url: string,
789
+ title?: string,
790
+ ): Promise<{ link: CardExternalLinkRow }> {
791
+ return this.request("POST", `/cards/${cardId}/external-links`, {
792
+ url,
793
+ title,
794
+ });
795
+ }
796
+
722
797
  // ============ ARTIFACTS (hosted HTML documents) ============
723
798
 
724
799
  async uploadArtifact(data: {
@@ -842,6 +917,7 @@ export class HarmonyApiClient {
842
917
  commentType?: string;
843
918
  supersedesId?: string;
844
919
  confirmsId?: string;
920
+ replyToId?: string;
845
921
  agentSessionId?: string;
846
922
  },
847
923
  ): Promise<{ comment: unknown }> {
@@ -851,6 +927,7 @@ export class HarmonyApiClient {
851
927
  commentType: opts?.commentType,
852
928
  supersedesId: opts?.supersedesId,
853
929
  confirmsId: opts?.confirmsId,
930
+ replyToId: opts?.replyToId,
854
931
  agentSessionId: opts?.agentSessionId,
855
932
  });
856
933
  }
@@ -1871,7 +1948,6 @@ export class HarmonyApiClient {
1871
1948
  name: string;
1872
1949
  description?: string;
1873
1950
  steps?: unknown;
1874
- stepsVersion?: number;
1875
1951
  triggerType?: string;
1876
1952
  }): Promise<{ playbook: unknown }> {
1877
1953
  return this.request("POST", "/playbooks", data);
@@ -1889,17 +1965,6 @@ export class HarmonyApiClient {
1889
1965
  ): Promise<{ playbook: unknown }> {
1890
1966
  return this.request("PATCH", `/playbooks/${playbookId}`, updates);
1891
1967
  }
1892
-
1893
- async runPlaybook(playbookId: string): Promise<{ run: unknown }> {
1894
- return this.request("POST", `/playbooks/${playbookId}/run`);
1895
- }
1896
-
1897
- async savePlaybookFromCard(data: {
1898
- cardId: string;
1899
- name?: string;
1900
- }): Promise<{ playbook: unknown }> {
1901
- return this.request("POST", "/playbooks/from-card", data);
1902
- }
1903
1968
  }
1904
1969
 
1905
1970
  // Shared types for generateCardPrompt to avoid inline assertions
@@ -25,6 +25,16 @@
25
25
 
26
26
  import type { HarmonyApiClient } from "./api-client.js";
27
27
 
28
+ /**
29
+ * Status reported for a tracked session. Drives the heartbeat decision: only
30
+ * `working` sessions are heartbeated by the sweep. `paused`/`blocked`/`waiting`
31
+ * are intentional non-heartbeat states — the agent has deliberately stopped
32
+ * making progress (parked for a human, awaiting input, …), so bumping their
33
+ * `updated_at` would both keep a paused row looking active and mask a genuinely
34
+ * stalled run.
35
+ */
36
+ export type SessionStatus = "working" | "blocked" | "waiting" | "paused";
37
+
28
38
  export interface TrackedSession {
29
39
  cardId: string;
30
40
  startedAt: number;
@@ -33,6 +43,11 @@ export interface TrackedSession {
33
43
  isExplicit: boolean;
34
44
  agentIdentifier: string;
35
45
  agentName: string;
46
+ /**
47
+ * Last status reported for the session (default `working`). Only `working`
48
+ * sessions are heartbeated by the sweep; see `heartbeatActiveSessions`.
49
+ */
50
+ status?: SessionStatus;
36
51
  }
37
52
 
38
53
  export type EndSessionCallback = (
@@ -173,7 +188,7 @@ export function initAutoSession(
173
188
  // the remote transport, connections arrive faster than the 60s interval and a
174
189
  // restart-each-time would keep deferring the sweep indefinitely (starvation).
175
190
  if (!inactivityTimer) {
176
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
191
+ inactivityTimer = setInterval(sweepTick, CHECK_INTERVAL_MS);
177
192
  }
178
193
  }
179
194
 
@@ -256,6 +271,7 @@ export async function trackActivity(
256
271
  isExplicit: false,
257
272
  agentIdentifier,
258
273
  agentName,
274
+ status: "working",
259
275
  });
260
276
  }
261
277
 
@@ -283,6 +299,7 @@ export function markExplicit(
283
299
  isExplicit: true,
284
300
  agentIdentifier: options?.agentIdentifier ?? "explicit",
285
301
  agentName: options?.agentName ?? "Explicit Agent",
302
+ status: "working",
286
303
  });
287
304
  }
288
305
  }
@@ -376,6 +393,79 @@ export function checkInactivity(): void {
376
393
  }
377
394
  }
378
395
 
396
+ /**
397
+ * Record the latest status reported for a tracked session so the sweep knows
398
+ * whether to keep heartbeating it. Wired into `harmony_update_agent_progress`
399
+ * (server.ts): when an agent reports `paused`/`blocked`/`waiting` the heartbeat
400
+ * stops for that session (those are intentional non-heartbeat states); a later
401
+ * `working` report resumes it. No-op when the card isn't tracked in the scope.
402
+ */
403
+ export function noteSessionStatus(
404
+ cardId: string,
405
+ status: SessionStatus,
406
+ scopeId: string = DEFAULT_SCOPE,
407
+ ): void {
408
+ const session = scopes.get(scopeId)?.sessions.get(cardId);
409
+ if (session) session.status = status;
410
+ }
411
+
412
+ /**
413
+ * Heartbeat every tracked `working` session by writing a status-only
414
+ * `updateAgentProgress`. The backend's `card_agent_context` update trigger
415
+ * bumps `updated_at` — the field both the kanban card ribbon
416
+ * (`isAgentContextLive`) and the header "Connected Agents" panel key off to
417
+ * decide a session is still live.
418
+ *
419
+ * WHY (card #608): an interactive MCP client (Claude Code, Cursor, /hmy) opens a
420
+ * `card_agent_context` row but, unlike the agent daemon's progress-tracker (60s
421
+ * heartbeat), only writes progress at milestones — often many minutes apart. On
422
+ * a long run `updated_at` goes stale and the card drops its live indicator
423
+ * mid-flight. This sweep gives interactive sessions the same ~60s heartbeat.
424
+ *
425
+ * Only `working` sessions are heartbeated; `paused`/`blocked`/`waiting` are
426
+ * intentional non-heartbeat states (see `SessionStatus`). The payload is
427
+ * status-only (no currentTask/progressPercent) so the heartbeat never clobbers
428
+ * the real progress the agent last reported — the trigger still bumps
429
+ * `updated_at`. Covers both auto-started and explicit sessions (both live in
430
+ * `scope.sessions`).
431
+ *
432
+ * No double-heartbeat for the daemon: `packages/harmony-agent` has its own
433
+ * progress-tracker heartbeat and never routes through mcp-server auto-sessions.
434
+ */
435
+ export function heartbeatActiveSessions(): void {
436
+ for (const scope of scopes.values()) {
437
+ const client = scope.clientGetter?.();
438
+ if (!client) continue;
439
+ for (const session of scope.sessions.values()) {
440
+ // Default-undefined status is treated as `working` (sessions created
441
+ // before a status was reported).
442
+ if ((session.status ?? "working") !== "working") continue;
443
+ client
444
+ .updateAgentProgress(session.cardId, {
445
+ agentIdentifier: session.agentIdentifier,
446
+ agentName: session.agentName,
447
+ status: "working",
448
+ })
449
+ .catch(() => {
450
+ // Best-effort: a transient failure just defers the bump to the next
451
+ // sweep. updateAgentProgress is fire-and-forget here.
452
+ });
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * One tick of the shared 60s sweep: prune idle auto-sessions
459
+ * (`checkInactivity`) first, then heartbeat the surviving `working` sessions
460
+ * (`heartbeatActiveSessions`). Order matters — prune before heartbeat so we
461
+ * never bump a session we're about to end. Exported for tests; in production
462
+ * the single process-wide timer calls it every CHECK_INTERVAL_MS.
463
+ */
464
+ export function sweepTick(): void {
465
+ checkInactivity();
466
+ heartbeatActiveSessions();
467
+ }
468
+
379
469
  // --- Internal ---
380
470
 
381
471
  async function autoEndSession(