@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/README.md +5 -2
- package/dist/cli.js +63 -4
- package/dist/index.js +63 -4
- package/dist/lib/api-client.js +19 -2
- package/dist/remote.js +1739 -463
- package/package.json +1 -1
- package/src/__tests__/memory-audit.test.ts +60 -0
- package/src/api-client.ts +35 -2
- package/src/memory-audit.ts +10 -2
- package/src/remote.ts +87 -17
- package/src/server.ts +57 -1
package/package.json
CHANGED
|
@@ -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
|
|
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 =
|
|
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);
|
package/src/memory-audit.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
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
|
|
39
|
+
interface TokenInfo {
|
|
40
|
+
userId: string;
|
|
36
41
|
workspaceId: string | null;
|
|
42
|
+
source: "api_key" | "oauth";
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
async function
|
|
45
|
+
async function validateToken(token: string): Promise<TokenInfo | null> {
|
|
40
46
|
try {
|
|
41
|
-
const response = await fetch(`${HARMONY_API_URL}/v1/
|
|
42
|
-
headers: { "X-API-Key":
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
168
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
const keyInfo = await
|
|
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
|
|
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
|
|
2662
|
+
const addLabels = parseLabelList(args.addLabels);
|
|
2607
2663
|
|
|
2608
2664
|
let movedTo: string | null = null;
|
|
2609
2665
|
const labelsAdded: string[] = [];
|