@gethmy/mcp 2.17.0 → 2.17.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.
package/dist/cli.js CHANGED
@@ -1499,6 +1499,21 @@ var TIMINGS = {
1499
1499
  QUERY_STALE_TIME: 1000 * 60 * 5,
1500
1500
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
1501
1501
  };
1502
+ // ../harmony-shared/dist/playbookStage.js
1503
+ var STAGE_DAEMON_OWNED_TOOLS = [
1504
+ "mcp__harmony__harmony_end_agent_session",
1505
+ "mcp__harmony__harmony_start_agent_session",
1506
+ "mcp__harmony__harmony_move_card"
1507
+ ];
1508
+ // ../harmony-shared/dist/reviewTools.js
1509
+ var REVIEW_DISALLOWED_TOOLS = [
1510
+ ...STAGE_DAEMON_OWNED_TOOLS,
1511
+ "mcp__harmony__harmony_update_card",
1512
+ "mcp__harmony__harmony_create_subtask",
1513
+ "mcp__harmony__harmony_update_subtask",
1514
+ "mcp__harmony__harmony_delete_subtask",
1515
+ "mcp__harmony__harmony_toggle_subtask"
1516
+ ];
1502
1517
  // ../harmony-shared/dist/stageHandoff.js
1503
1518
  var HANDOFF_MARKER = "harmony:stage-handoff";
1504
1519
  var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
@@ -1761,6 +1776,9 @@ class HarmonyApiClient {
1761
1776
  async getWorkspaceMembers(workspaceId) {
1762
1777
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1763
1778
  }
1779
+ async getAuthContext() {
1780
+ return this.request("GET", "/auth/context");
1781
+ }
1764
1782
  async listWorkspaceAgents(workspaceId) {
1765
1783
  return this.request("GET", `/workspaces/${workspaceId}/agents`);
1766
1784
  }
@@ -2481,6 +2499,7 @@ var AUTO_START_TRIGGERS = new Set([
2481
2499
  ]);
2482
2500
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2483
2501
  var CHECK_INTERVAL_MS = 60 * 1000;
2502
+ var HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000;
2484
2503
  var DEFAULT_SCOPE = "__default__";
2485
2504
  var scopes = new Map;
2486
2505
  var inactivityTimer = null;
@@ -2618,17 +2637,25 @@ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2618
2637
  session.status = status;
2619
2638
  }
2620
2639
  function heartbeatActiveSessions() {
2640
+ const now = Date.now();
2621
2641
  for (const scope of scopes.values()) {
2622
2642
  const client3 = scope.clientGetter?.();
2623
2643
  if (!client3)
2624
2644
  continue;
2625
- for (const session of scope.sessions.values()) {
2645
+ for (const [cardId, session] of [...scope.sessions.entries()]) {
2626
2646
  if ((session.status ?? "working") !== "working")
2627
2647
  continue;
2648
+ if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS)
2649
+ continue;
2628
2650
  client3.updateAgentProgress(session.cardId, {
2629
2651
  agentIdentifier: session.agentIdentifier,
2630
2652
  agentName: session.agentName,
2631
- status: "working"
2653
+ status: "working",
2654
+ noCreate: true
2655
+ }).then((res) => {
2656
+ if (res?.session === null && scope.sessions.get(cardId) === session) {
2657
+ scope.sessions.delete(cardId);
2658
+ }
2632
2659
  }).catch(() => {});
2633
2660
  }
2634
2661
  }
@@ -6148,6 +6175,23 @@ async function handleToolCall(name, args, deps) {
6148
6175
  }
6149
6176
  case "harmony_set_workspace_context": {
6150
6177
  const workspaceId = z.string().uuid().parse(args.workspaceId);
6178
+ const { workspaces } = await client3.listWorkspaces();
6179
+ const available = workspaces ?? [];
6180
+ const match = available.find((w) => w?.id === workspaceId);
6181
+ if (!match) {
6182
+ const options = available.filter((w) => w?.id).map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`).join(`
6183
+ `);
6184
+ throw new Error(`Workspace ${workspaceId} is not available to this connection.
6185
+
6186
+ ` + `This MCP connection is authorized for a specific workspace, chosen ` + `during OAuth consent — it cannot read or write any other workspace, ` + `even ones you're a member of.
6187
+
6188
+ ` + (options ? `Available here:
6189
+ ${options}
6190
+
6191
+ ` : `No workspaces are available to this connection.
6192
+
6193
+ `) + `To work in a different workspace, run /mcp to reconnect and select ` + `it on the consent screen.`);
6194
+ }
6151
6195
  deps.setActiveWorkspace(workspaceId);
6152
6196
  return { success: true, activeWorkspaceId: workspaceId };
6153
6197
  }
package/dist/index.js CHANGED
@@ -1494,6 +1494,21 @@ var TIMINGS = {
1494
1494
  QUERY_STALE_TIME: 1000 * 60 * 5,
1495
1495
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
1496
1496
  };
1497
+ // ../harmony-shared/dist/playbookStage.js
1498
+ var STAGE_DAEMON_OWNED_TOOLS = [
1499
+ "mcp__harmony__harmony_end_agent_session",
1500
+ "mcp__harmony__harmony_start_agent_session",
1501
+ "mcp__harmony__harmony_move_card"
1502
+ ];
1503
+ // ../harmony-shared/dist/reviewTools.js
1504
+ var REVIEW_DISALLOWED_TOOLS = [
1505
+ ...STAGE_DAEMON_OWNED_TOOLS,
1506
+ "mcp__harmony__harmony_update_card",
1507
+ "mcp__harmony__harmony_create_subtask",
1508
+ "mcp__harmony__harmony_update_subtask",
1509
+ "mcp__harmony__harmony_delete_subtask",
1510
+ "mcp__harmony__harmony_toggle_subtask"
1511
+ ];
1497
1512
  // ../harmony-shared/dist/stageHandoff.js
1498
1513
  var HANDOFF_MARKER = "harmony:stage-handoff";
1499
1514
  var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
@@ -1756,6 +1771,9 @@ class HarmonyApiClient {
1756
1771
  async getWorkspaceMembers(workspaceId) {
1757
1772
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1758
1773
  }
1774
+ async getAuthContext() {
1775
+ return this.request("GET", "/auth/context");
1776
+ }
1759
1777
  async listWorkspaceAgents(workspaceId) {
1760
1778
  return this.request("GET", `/workspaces/${workspaceId}/agents`);
1761
1779
  }
@@ -2476,6 +2494,7 @@ var AUTO_START_TRIGGERS = new Set([
2476
2494
  ]);
2477
2495
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2478
2496
  var CHECK_INTERVAL_MS = 60 * 1000;
2497
+ var HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000;
2479
2498
  var DEFAULT_SCOPE = "__default__";
2480
2499
  var scopes = new Map;
2481
2500
  var inactivityTimer = null;
@@ -2613,17 +2632,25 @@ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2613
2632
  session.status = status;
2614
2633
  }
2615
2634
  function heartbeatActiveSessions() {
2635
+ const now = Date.now();
2616
2636
  for (const scope of scopes.values()) {
2617
2637
  const client3 = scope.clientGetter?.();
2618
2638
  if (!client3)
2619
2639
  continue;
2620
- for (const session of scope.sessions.values()) {
2640
+ for (const [cardId, session] of [...scope.sessions.entries()]) {
2621
2641
  if ((session.status ?? "working") !== "working")
2622
2642
  continue;
2643
+ if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS)
2644
+ continue;
2623
2645
  client3.updateAgentProgress(session.cardId, {
2624
2646
  agentIdentifier: session.agentIdentifier,
2625
2647
  agentName: session.agentName,
2626
- status: "working"
2648
+ status: "working",
2649
+ noCreate: true
2650
+ }).then((res) => {
2651
+ if (res?.session === null && scope.sessions.get(cardId) === session) {
2652
+ scope.sessions.delete(cardId);
2653
+ }
2627
2654
  }).catch(() => {});
2628
2655
  }
2629
2656
  }
@@ -6143,6 +6170,23 @@ async function handleToolCall(name, args, deps) {
6143
6170
  }
6144
6171
  case "harmony_set_workspace_context": {
6145
6172
  const workspaceId = z.string().uuid().parse(args.workspaceId);
6173
+ const { workspaces } = await client3.listWorkspaces();
6174
+ const available = workspaces ?? [];
6175
+ const match = available.find((w) => w?.id === workspaceId);
6176
+ if (!match) {
6177
+ const options = available.filter((w) => w?.id).map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`).join(`
6178
+ `);
6179
+ throw new Error(`Workspace ${workspaceId} is not available to this connection.
6180
+
6181
+ ` + `This MCP connection is authorized for a specific workspace, chosen ` + `during OAuth consent — it cannot read or write any other workspace, ` + `even ones you're a member of.
6182
+
6183
+ ` + (options ? `Available here:
6184
+ ${options}
6185
+
6186
+ ` : `No workspaces are available to this connection.
6187
+
6188
+ `) + `To work in a different workspace, run /mcp to reconnect and select ` + `it on the consent screen.`);
6189
+ }
6146
6190
  deps.setActiveWorkspace(workspaceId);
6147
6191
  return { success: true, activeWorkspaceId: workspaceId };
6148
6192
  }
@@ -946,6 +946,21 @@ var TIMINGS = {
946
946
  QUERY_STALE_TIME: 1000 * 60 * 5,
947
947
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
948
948
  };
949
+ // ../harmony-shared/dist/playbookStage.js
950
+ var STAGE_DAEMON_OWNED_TOOLS = [
951
+ "mcp__harmony__harmony_end_agent_session",
952
+ "mcp__harmony__harmony_start_agent_session",
953
+ "mcp__harmony__harmony_move_card"
954
+ ];
955
+ // ../harmony-shared/dist/reviewTools.js
956
+ var REVIEW_DISALLOWED_TOOLS = [
957
+ ...STAGE_DAEMON_OWNED_TOOLS,
958
+ "mcp__harmony__harmony_update_card",
959
+ "mcp__harmony__harmony_create_subtask",
960
+ "mcp__harmony__harmony_update_subtask",
961
+ "mcp__harmony__harmony_delete_subtask",
962
+ "mcp__harmony__harmony_toggle_subtask"
963
+ ];
949
964
  // ../harmony-shared/dist/stageHandoff.js
950
965
  var HANDOFF_MARKER = "harmony:stage-handoff";
951
966
  var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
@@ -1208,6 +1223,9 @@ class HarmonyApiClient {
1208
1223
  async getWorkspaceMembers(workspaceId) {
1209
1224
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1210
1225
  }
1226
+ async getAuthContext() {
1227
+ return this.request("GET", "/auth/context");
1228
+ }
1211
1229
  async listWorkspaceAgents(workspaceId) {
1212
1230
  return this.request("GET", `/workspaces/${workspaceId}/agents`);
1213
1231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/api-client.ts CHANGED
@@ -461,6 +461,22 @@ export class HarmonyApiClient {
461
461
  return this.request("GET", `/workspaces/${workspaceId}/members`);
462
462
  }
463
463
 
464
+ /**
465
+ * The auth user THIS client's credential resolves to server-side — the id
466
+ * harmony-api stamps onto sessions it creates for this caller. The agent
467
+ * daemon uses it to assert its configured `userEmail` and its API key are the
468
+ * same user; a divergence would make it fail closed on its own comment
469
+ * artifacts (card #693). `GET /v1/auth/context` is long-deployed (the MCP
470
+ * transport authenticates every request through it).
471
+ */
472
+ async getAuthContext(): Promise<{
473
+ userId: string;
474
+ source: "api_key" | "oauth" | "jwt";
475
+ workspaceId: string | null;
476
+ }> {
477
+ return this.request("GET", "/auth/context");
478
+ }
479
+
464
480
  async listWorkspaceAgents(
465
481
  workspaceId: string,
466
482
  ): Promise<{ agents: WorkspaceAgent[] }> {
@@ -1067,6 +1083,12 @@ export class HarmonyApiClient {
1067
1083
  recoveryBranch?: string;
1068
1084
  attemptNumber?: number;
1069
1085
  maxAttempts?: number;
1086
+ /**
1087
+ * Update-only: never create a session if none is live, answer
1088
+ * `{session: null}` instead. Set by the auto-session heartbeat, which must
1089
+ * report on a session but must never bring one back from the dead (#696).
1090
+ */
1091
+ noCreate?: boolean;
1070
1092
  },
1071
1093
  ): Promise<{ session: unknown; created: boolean }> {
1072
1094
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
@@ -1075,7 +1097,13 @@ export class HarmonyApiClient {
1075
1097
  async endAgentSession(
1076
1098
  cardId: string,
1077
1099
  data?: {
1078
- status?: "completed" | "paused" | "failed";
1100
+ /**
1101
+ * `cancelled` marks a HUMAN stop specifically — it is what arms the
1102
+ * server's human-stop cooldown (card #663). Never use it for a graceful
1103
+ * shutdown or an internal abort, which must stay `paused`/`failed` so the
1104
+ * card can be re-picked immediately (card #696).
1105
+ */
1106
+ status?: "completed" | "paused" | "failed" | "cancelled";
1079
1107
  progressPercent?: number;
1080
1108
  costCents?: number;
1081
1109
  inputTokens?: number;
@@ -116,6 +116,27 @@ export const AUTO_START_TRIGGERS = new Set([
116
116
  export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
117
117
  const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
118
118
 
119
+ /**
120
+ * How long after the agent's last harmony tool call we keep heartbeating a
121
+ * session (card #696).
122
+ *
123
+ * WHY: the heartbeat asserts "this agent is still working". An *explicit*
124
+ * session (`/hmy`) is exempt from `checkInactivity`, so an unbounded heartbeat
125
+ * asserted that forever — bumping `updated_at` every 60s and starving the
126
+ * 30-minute `cleanup_stale_agent_sessions` cron, which is the only backstop for
127
+ * a session whose agent finished (card moved to Review) but never called
128
+ * `harmony_end_agent_session`. The session then showed "working" forever.
129
+ *
130
+ * Bounding the heartbeat by real activity restores that backstop: once the
131
+ * agent stops calling harmony tools we stop beating, `updated_at` goes stale,
132
+ * and the cron closes the row as `failed`/`stale`. Sits deliberately BELOW the
133
+ * cron's 30-minute threshold so a stalled session can actually reach it, and
134
+ * well above the milestone-gap the heartbeat exists to bridge (#608). Activity
135
+ * revives it — `trackActivity` refreshes `lastActivityAt` on every card-scoped
136
+ * tool call.
137
+ */
138
+ export const HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000; // 25 minutes
139
+
119
140
  /**
120
141
  * Scope used when no `scopeId` is supplied. stdio is single-user, so it shares
121
142
  * one scope; the unit tests also exercise this default scope exclusively.
@@ -429,22 +450,54 @@ export function noteSessionStatus(
429
450
  * `updated_at`. Covers both auto-started and explicit sessions (both live in
430
451
  * `scope.sessions`).
431
452
  *
453
+ * Two bounds keep a heartbeat from outliving the work it reports on (card #696):
454
+ *
455
+ * 1. `noCreate` — a heartbeat may only ever UPDATE a live session, never create
456
+ * one. The endpoint shares a single upsert with `start_agent_session`, so
457
+ * without this a heartbeat for a session ended out-of-band (the UI "Stop",
458
+ * which cannot reach this process's map) re-created the row and re-added the
459
+ * `agent` label. The #663 human-stop cooldown only deferred that by 10
460
+ * minutes, so a stopped card reliably respawned exactly one cooldown later.
461
+ * `session: null` back means the session is gone → untrack, stop beating.
462
+ * 2. `HEARTBEAT_ACTIVITY_WINDOW_MS` — only beat while the agent is still calling
463
+ * harmony tools, so an abandoned session goes stale and the cron can close it.
464
+ *
432
465
  * No double-heartbeat for the daemon: `packages/harmony-agent` has its own
433
466
  * progress-tracker heartbeat and never routes through mcp-server auto-sessions.
434
467
  */
435
468
  export function heartbeatActiveSessions(): void {
469
+ const now = Date.now();
436
470
  for (const scope of scopes.values()) {
437
471
  const client = scope.clientGetter?.();
438
472
  if (!client) continue;
439
- for (const session of scope.sessions.values()) {
473
+ // Snapshot: the untrack-on-ended path below mutates the map.
474
+ for (const [cardId, session] of [...scope.sessions.entries()]) {
440
475
  // Default-undefined status is treated as `working` (sessions created
441
476
  // before a status was reported).
442
477
  if ((session.status ?? "working") !== "working") continue;
478
+ // Silent agent → stop asserting it's working; hand the row to the cron.
479
+ if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) continue;
443
480
  client
444
481
  .updateAgentProgress(session.cardId, {
445
482
  agentIdentifier: session.agentIdentifier,
446
483
  agentName: session.agentName,
447
484
  status: "working",
485
+ noCreate: true,
486
+ })
487
+ .then((res) => {
488
+ // Ended out-of-band (UI "Stop", another client, the stale cron). Drop
489
+ // it so we never beat — nor respawn — this session again. Only an
490
+ // explicit null counts: an unknown/absent field must not untrack a
491
+ // live session.
492
+ //
493
+ // Identity-checked: this reply describes the session we beat, which
494
+ // may no longer be the one tracked for this card. An end + restart
495
+ // landing inside the request's round-trip replaces the map entry, and
496
+ // deleting by cardId alone would drop that NEW live session — leaving
497
+ // it unbeaten until the stale cron closed it early.
498
+ if (res?.session === null && scope.sessions.get(cardId) === session) {
499
+ scope.sessions.delete(cardId);
500
+ }
448
501
  })
449
502
  .catch(() => {
450
503
  // Best-effort: a transient failure just defers the bump to the next
package/src/server.ts CHANGED
@@ -3489,6 +3489,42 @@ async function handleToolCall(
3489
3489
 
3490
3490
  case "harmony_set_workspace_context": {
3491
3491
  const workspaceId = z.string().uuid().parse(args.workspaceId);
3492
+
3493
+ // Fail fast on a workspace this connection can't actually reach (#695).
3494
+ // An OAuth token is bound at consent time to the single workspace the user
3495
+ // picked ("It will be able to read and modify cards, plans, and memory in
3496
+ // the workspace you select below"), and harmony-api enforces that on every
3497
+ // call. Setting the context is a pure local assignment, so without this
3498
+ // check it always "succeeded" and the caller only discovered the binding
3499
+ // when some later, unrelated tool call 403'd mid-task — the exact confusion
3500
+ // this card reports. `listWorkspaces` is server-side scoped to the bound
3501
+ // workspace, so presence in it is the authoritative reachability answer for
3502
+ // OAuth and legacy-key connections alike.
3503
+ const { workspaces } = await client.listWorkspaces();
3504
+ const available = (workspaces ?? []) as Array<{
3505
+ id?: string;
3506
+ name?: string;
3507
+ }>;
3508
+ const match = available.find((w) => w?.id === workspaceId);
3509
+
3510
+ if (!match) {
3511
+ const options = available
3512
+ .filter((w) => w?.id)
3513
+ .map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`)
3514
+ .join("\n");
3515
+ throw new Error(
3516
+ `Workspace ${workspaceId} is not available to this connection.\n\n` +
3517
+ `This MCP connection is authorized for a specific workspace, chosen ` +
3518
+ `during OAuth consent — it cannot read or write any other workspace, ` +
3519
+ `even ones you're a member of.\n\n` +
3520
+ (options
3521
+ ? `Available here:\n${options}\n\n`
3522
+ : `No workspaces are available to this connection.\n\n`) +
3523
+ `To work in a different workspace, run /mcp to reconnect and select ` +
3524
+ `it on the consent screen.`,
3525
+ );
3526
+ }
3527
+
3492
3528
  deps.setActiveWorkspace(workspaceId);
3493
3529
  return { success: true, activeWorkspaceId: workspaceId };
3494
3530
  }