@gethmy/mcp 2.4.2 → 2.4.4

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.4.2",
3
+ "version": "2.4.4",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -282,6 +282,66 @@ describe("runMemoryAudit", () => {
282
282
  ).toBe(0.25);
283
283
  });
284
284
 
285
+ test("boilerplate override forces delete bucket regardless of confidence/access/tier", async () => {
286
+ // The exact failure mode that motivated this override: legacy task-transition
287
+ // entries promoted to reference tier with confidence=1.0 and high access_count
288
+ // were scoring ~80 and surviving in "keep". Verify the override demotes them.
289
+ const { client, deletedIds } = makeMockClient(
290
+ [
291
+ {
292
+ id: "promoted-junk",
293
+ type: "context",
294
+ title: "Task transition: legacy auto-extracted noise",
295
+ content:
296
+ "Agent transitioned tasks. Previous: doing X. Current: doing Y. Progress: 100%.",
297
+ confidence: 1.0,
298
+ memory_tier: "reference",
299
+ access_count: 91,
300
+ last_accessed_at: daysAgo(0),
301
+ created_at: daysAgo(29),
302
+ tags: ["auto-extracted", "task-transition", "mid-session"],
303
+ embedding: [0.1],
304
+ promoted_from_id: "orig-junk",
305
+ },
306
+ ],
307
+ { "promoted-junk": 2 },
308
+ );
309
+
310
+ const report = await runMemoryAudit(client, "ws-1", undefined, {
311
+ dryRun: false,
312
+ });
313
+ expect(report.summary.delete).toBe(1);
314
+ expect(deletedIds).toContain("promoted-junk");
315
+ expect(report.lowest[0].reasons).toContain("boilerplate override");
316
+ });
317
+
318
+ test("boilerplate override respects deleteBelow=0 escape hatch", async () => {
319
+ // deleteBelow=0 is a "no deletions, audit-only" knob. Boilerplate must
320
+ // honor it — operators should be able to inspect findings without losing
321
+ // data on the same call.
322
+ const { client, deletedIds } = makeMockClient([
323
+ {
324
+ id: "boilerplate-protected",
325
+ type: "context",
326
+ title: "Task transition: would normally delete",
327
+ content: "noise content",
328
+ confidence: 1.0,
329
+ memory_tier: "reference",
330
+ access_count: 50,
331
+ last_accessed_at: daysAgo(0),
332
+ created_at: daysAgo(20),
333
+ tags: ["x"],
334
+ embedding: [0.1],
335
+ },
336
+ ]);
337
+
338
+ await runMemoryAudit(client, "ws-1", undefined, {
339
+ dryRun: false,
340
+ deleteBelow: 0,
341
+ });
342
+ expect(deletedIds).toHaveLength(0);
343
+ });
344
+
285
345
  test("stale-draft filter flags draft+0access+age>threshold separately from bucket", async () => {
286
346
  const { client } = makeMockClient(
287
347
  [
package/src/api-client.ts CHANGED
@@ -176,10 +176,27 @@ export class HarmonyApiClient {
176
176
  body: options?.rawBody ?? (body ? JSON.stringify(body) : undefined),
177
177
  });
178
178
 
179
- const data = await response.json();
179
+ const text = await response.text();
180
+ const responseContentType = response.headers.get("content-type") || "";
181
+ const looksLikeJson = responseContentType.includes("application/json");
182
+
183
+ let data: ApiResponse | null = null;
184
+ let parseError: Error | null = null;
185
+ if (text) {
186
+ try {
187
+ data = JSON.parse(text);
188
+ } catch (err) {
189
+ parseError = err instanceof Error ? err : new Error(String(err));
190
+ }
191
+ }
180
192
 
181
193
  if (!response.ok) {
182
- const errorMsg = data.error || `API error: ${response.status}`;
194
+ const errorMsg =
195
+ data?.error ||
196
+ (looksLikeJson
197
+ ? null
198
+ : `API error: ${response.status} (non-JSON response)`) ||
199
+ `API error: ${response.status}`;
183
200
  if (!isRetryableError(null, response.status)) {
184
201
  throw new Error(errorMsg);
185
202
  }
@@ -191,6 +208,12 @@ export class HarmonyApiClient {
191
208
  throw lastError;
192
209
  }
193
210
 
211
+ if (parseError) {
212
+ throw new Error(
213
+ `API returned ${response.status} with invalid JSON body: ${parseError.message}`,
214
+ );
215
+ }
216
+
194
217
  return data as T;
195
218
  } catch (error) {
196
219
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -455,6 +478,10 @@ export class HarmonyApiClient {
455
478
  return this.request("POST", "/labels", { projectId, ...data });
456
479
  }
457
480
 
481
+ async deleteLabel(labelId: string): Promise<{ success: boolean }> {
482
+ return this.request("DELETE", `/labels/${labelId}`);
483
+ }
484
+
458
485
  // ============ SUBTASK OPERATIONS ============
459
486
 
460
487
  async createSubtask(
@@ -504,6 +531,9 @@ export class HarmonyApiClient {
504
531
  costCents?: number;
505
532
  inputTokens?: number;
506
533
  outputTokens?: number;
534
+ cacheCreationInputTokens?: number;
535
+ cacheReadInputTokens?: number;
536
+ modelName?: string;
507
537
  recentActions?: { action: string; ts: string }[];
508
538
  },
509
539
  ): Promise<{ session: unknown; created: boolean }> {
@@ -518,6 +548,9 @@ export class HarmonyApiClient {
518
548
  costCents?: number;
519
549
  inputTokens?: number;
520
550
  outputTokens?: number;
551
+ cacheCreationInputTokens?: number;
552
+ cacheReadInputTokens?: number;
553
+ modelName?: string;
521
554
  },
522
555
  ): Promise<{ session: unknown }> {
523
556
  return this.request("DELETE", `/cards/${cardId}/agent-context`, data);
@@ -253,9 +253,17 @@ function scoreEntity(
253
253
  legacyReasons.push("no graph presence");
254
254
  }
255
255
 
256
- // Bucket
256
+ // Bucket — boilerplate is a one-way door to delete. High access counts on
257
+ // noise titles signal re-read churn (recall/dedup loops), not genuine reuse;
258
+ // letting confidence + tier + decay drag the composite score back into
259
+ // "keep" leaves promoted-to-reference junk untouched. Override scoring,
260
+ // except when deleteBelow=0 (the "no deletions" escape hatch) — in that
261
+ // mode boilerplate falls through to archive.
257
262
  let bucket: AuditBucket;
258
- if (score < deleteBelow) bucket = "delete";
263
+ if (boilerplate && deleteBelow > 0) {
264
+ bucket = "delete";
265
+ reasons.push("boilerplate override");
266
+ } else if (score < deleteBelow) bucket = "delete";
259
267
  else if (score < archiveBelow) bucket = "archive";
260
268
  else if (score < 70) bucket = "review";
261
269
  else bucket = "keep";
package/src/remote.ts CHANGED
@@ -27,27 +27,58 @@ import { registerHandlers, type ToolDeps } from "./server.js";
27
27
  // ---------------------------------------------------------------------------
28
28
  const HARMONY_API_URL =
29
29
  process.env.HARMONY_API_URL || "https://app.gethmy.com/api";
30
+ const OAUTH_ISSUER = process.env.OAUTH_ISSUER || "https://app.gethmy.com";
31
+ const PUBLIC_MCP_URL = process.env.PUBLIC_MCP_URL || "https://mcp.gethmy.com";
30
32
  const PORT = parseInt(process.env.PORT || "3002", 10);
31
33
 
32
34
  // ---------------------------------------------------------------------------
33
- // API key validation via Harmony API
35
+ // Token validation via Harmony API
36
+ // Accepts both legacy hmy_* integration keys and hmy_at_* OAuth access tokens.
37
+ // Uses /v1/auth/context — the server decides which table to look in.
34
38
  // ---------------------------------------------------------------------------
35
- interface ApiKeyInfo {
39
+ interface TokenInfo {
40
+ userId: string;
36
41
  workspaceId: string | null;
42
+ source: "api_key" | "oauth";
37
43
  }
38
44
 
39
- async function validateApiKey(apiKey: string): Promise<ApiKeyInfo | null> {
45
+ async function validateToken(token: string): Promise<TokenInfo | null> {
40
46
  try {
41
- const response = await fetch(`${HARMONY_API_URL}/v1/workspaces`, {
42
- headers: { "X-API-Key": apiKey },
47
+ const response = await fetch(`${HARMONY_API_URL}/v1/auth/context`, {
48
+ headers: { "X-API-Key": token },
43
49
  });
44
50
  if (!response.ok) return null;
45
51
 
52
+ const data = (await response.json()) as {
53
+ userId: string;
54
+ source: "api_key" | "oauth" | "jwt";
55
+ workspaceId: string | null;
56
+ };
57
+ if (data.source === "jwt") return null; // JWT not allowed on MCP endpoint
58
+ return {
59
+ userId: data.userId,
60
+ workspaceId: data.workspaceId,
61
+ source: data.source,
62
+ };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // For legacy api_keys: fall back to workspaces list to pick the first one.
69
+ // OAuth tokens already have workspaceId bound at grant time.
70
+ async function resolveWorkspaceForLegacyKey(
71
+ token: string,
72
+ ): Promise<string | null> {
73
+ try {
74
+ const response = await fetch(`${HARMONY_API_URL}/v1/workspaces`, {
75
+ headers: { "X-API-Key": token },
76
+ });
77
+ if (!response.ok) return null;
46
78
  const data = (await response.json()) as {
47
79
  workspaces?: Array<{ id: string }>;
48
80
  };
49
- const firstWorkspace = data.workspaces?.[0];
50
- return { workspaceId: firstWorkspace?.id ?? null };
81
+ return data.workspaces?.[0]?.id ?? null;
51
82
  } catch {
52
83
  return null;
53
84
  }
@@ -82,7 +113,7 @@ setInterval(
82
113
  30 * 60 * 1000,
83
114
  );
84
115
 
85
- function createSession(apiKey: string, keyInfo: ApiKeyInfo): McpSession {
116
+ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
86
117
  const transport = new WebStandardStreamableHTTPServerTransport({
87
118
  sessionIdGenerator: () => crypto.randomUUID(),
88
119
  enableJsonResponse: true,
@@ -164,14 +195,47 @@ app.get("/health", (c) =>
164
195
  }),
165
196
  );
166
197
 
167
- // MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close)
168
- app.all("/mcp", async (c) => {
198
+ // OAuth 2.1 Protected Resource Metadata (RFC 9728 / MCP 2025-11-25).
199
+ // Tells unauthenticated clients which authorization server to use.
200
+ app.get("/.well-known/oauth-protected-resource", (c) =>
201
+ c.json({
202
+ resource: PUBLIC_MCP_URL,
203
+ authorization_servers: [OAUTH_ISSUER],
204
+ bearer_methods_supported: ["header"],
205
+ resource_documentation: `${OAUTH_ISSUER}/docs/mcp`,
206
+ }),
207
+ );
208
+
209
+ // Unauthenticated 401 that advertises the OAuth metadata discovery point.
210
+ // Claude's `mcp add --transport http` uses this to kick off the flow.
211
+ function unauthenticatedResponse(): Response {
212
+ return new Response(
213
+ JSON.stringify({
214
+ error: "unauthorized",
215
+ error_description: "Missing or invalid access token",
216
+ }),
217
+ {
218
+ status: 401,
219
+ headers: {
220
+ "Content-Type": "application/json",
221
+ "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource"`,
222
+ },
223
+ },
224
+ );
225
+ }
226
+
227
+ // MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close).
228
+ // Mounted on both `/mcp` and `/` so clients that registered the bare host as
229
+ // their server URL still reach the OAuth challenge instead of a 404.
230
+ const handleMcpRequest = async (c: import("hono").Context) => {
169
231
  const method = c.req.method;
170
232
 
171
- // Extract API key from Authorization header
233
+ // Extract bearer token. Accept OAuth access tokens (hmy_at_) and legacy
234
+ // integration keys (hmy_). No token → 401 with WWW-Authenticate so Claude
235
+ // can discover our authorization server.
172
236
  const authHeader = c.req.header("Authorization");
173
237
  if (!authHeader?.startsWith("Bearer ")) {
174
- return c.json({ error: "Missing Authorization: Bearer <api-key>" }, 401);
238
+ return unauthenticatedResponse();
175
239
  }
176
240
  const apiKey = authHeader.slice(7);
177
241
 
@@ -185,11 +249,14 @@ app.all("/mcp", async (c) => {
185
249
  }
186
250
 
187
251
  if (method === "POST") {
188
- // Could be a new session (initialize) or an existing session we don't know about
189
- // Validate API key
190
- const keyInfo = await validateApiKey(apiKey);
252
+ // Validate the token. OAuth tokens carry workspaceId; legacy keys don't,
253
+ // so we look up workspaces in a follow-up call for those.
254
+ const keyInfo = await validateToken(apiKey);
191
255
  if (!keyInfo) {
192
- return c.json({ error: "Invalid API key" }, 401);
256
+ return unauthenticatedResponse();
257
+ }
258
+ if (keyInfo.source === "api_key" && !keyInfo.workspaceId) {
259
+ keyInfo.workspaceId = await resolveWorkspaceForLegacyKey(apiKey);
193
260
  }
194
261
 
195
262
  // Create new session
@@ -211,7 +278,10 @@ app.all("/mcp", async (c) => {
211
278
 
212
279
  // GET or DELETE without a valid session
213
280
  return c.json({ error: "Invalid or missing session" }, 404);
214
- });
281
+ };
282
+
283
+ app.all("/mcp", handleMcpRequest);
284
+ app.all("/", handleMcpRequest);
215
285
 
216
286
  // ---------------------------------------------------------------------------
217
287
  // Start server
package/src/server.ts CHANGED
@@ -100,6 +100,45 @@ interface MemorySessionState {
100
100
 
101
101
  const memorySessions = new Map<string, MemorySessionState>();
102
102
 
103
+ /**
104
+ * Normalize a label-list argument into `string[]`.
105
+ *
106
+ * Some MCP callers pass `addLabels` as a JSON-encoded string (e.g. `'["agent"]'`)
107
+ * instead of a real array. Iterating such a string with `for..of` would yield
108
+ * individual characters and create one bogus single-char label per character.
109
+ * This helper coerces the value into a well-formed array and drops empty entries.
110
+ */
111
+ function parseLabelList(raw: unknown): string[] | undefined {
112
+ if (raw === undefined || raw === null) return undefined;
113
+ if (Array.isArray(raw)) {
114
+ const arr = raw
115
+ .filter((v): v is string => typeof v === "string")
116
+ .map((v) => v.trim())
117
+ .filter((v) => v.length > 0);
118
+ return arr.length ? arr : undefined;
119
+ }
120
+ if (typeof raw === "string") {
121
+ const trimmed = raw.trim();
122
+ if (!trimmed) return undefined;
123
+ if (trimmed.startsWith("[")) {
124
+ try {
125
+ const parsed = JSON.parse(trimmed);
126
+ if (
127
+ Array.isArray(parsed) &&
128
+ parsed.every((x) => typeof x === "string")
129
+ ) {
130
+ const arr = parsed.map((v) => v.trim()).filter((v) => v.length > 0);
131
+ return arr.length ? arr : undefined;
132
+ }
133
+ } catch {
134
+ // fall through to single-label fallback
135
+ }
136
+ }
137
+ return [trimmed];
138
+ }
139
+ return undefined;
140
+ }
141
+
103
142
  function initMemorySession(
104
143
  cardId: string,
105
144
  agentIdentifier: string,
@@ -431,6 +470,17 @@ const TOOLS = {
431
470
  required: ["name", "color"],
432
471
  },
433
472
  },
473
+ harmony_delete_label: {
474
+ description:
475
+ "Delete a label from a project. Also removes it from any cards that reference it.",
476
+ inputSchema: {
477
+ type: "object",
478
+ properties: {
479
+ labelId: { type: "string", description: "Label ID to delete" },
480
+ },
481
+ required: ["labelId"],
482
+ },
483
+ },
434
484
  harmony_add_label_to_card: {
435
485
  description:
436
486
  "Add a label to a card. Provide labelId directly, or labelName to look up (or auto-create) the label by name.",
@@ -2412,6 +2462,12 @@ async function handleToolCall(
2412
2462
  return { success: true, ...result };
2413
2463
  }
2414
2464
 
2465
+ case "harmony_delete_label": {
2466
+ const labelId = z.string().uuid().parse(args.labelId);
2467
+ const result = await client.deleteLabel(labelId);
2468
+ return { success: true, ...result };
2469
+ }
2470
+
2415
2471
  case "harmony_add_label_to_card": {
2416
2472
  const cardId = z.string().uuid().parse(args.cardId);
2417
2473
  let labelId = args.labelId
@@ -2603,7 +2659,7 @@ async function handleToolCall(
2603
2659
  .parse(args.agentIdentifier);
2604
2660
  const agentName = z.string().min(1).max(100).parse(args.agentName);
2605
2661
  const moveToColumn = args.moveToColumn as string | undefined;
2606
- const addLabels = args.addLabels as string[] | undefined;
2662
+ const addLabels = parseLabelList(args.addLabels);
2607
2663
 
2608
2664
  let movedTo: string | null = null;
2609
2665
  const labelsAdded: string[] = [];