@gethmy/mcp 2.4.3 → 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.3",
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
@@ -531,6 +531,9 @@ export class HarmonyApiClient {
531
531
  costCents?: number;
532
532
  inputTokens?: number;
533
533
  outputTokens?: number;
534
+ cacheCreationInputTokens?: number;
535
+ cacheReadInputTokens?: number;
536
+ modelName?: string;
534
537
  recentActions?: { action: string; ts: string }[];
535
538
  },
536
539
  ): Promise<{ session: unknown; created: boolean }> {
@@ -545,6 +548,9 @@ export class HarmonyApiClient {
545
548
  costCents?: number;
546
549
  inputTokens?: number;
547
550
  outputTokens?: number;
551
+ cacheCreationInputTokens?: number;
552
+ cacheReadInputTokens?: number;
553
+ modelName?: string;
548
554
  },
549
555
  ): Promise<{ session: unknown }> {
550
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