@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/dist/cli.js +4 -1
- package/dist/index.js +4 -1
- 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 +6 -0
- package/src/memory-audit.ts +10 -2
- package/src/remote.ts +87 -17
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
|
@@ -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);
|
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
|