@gethmy/mcp 2.4.3 → 2.4.6
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 +34 -8
- package/dist/index.js +34 -8
- package/dist/lib/api-client.js +21 -0
- package/dist/remote.js +1739 -463
- package/package.json +1 -1
- package/src/__tests__/memory-audit.test.ts +142 -0
- package/src/api-client.ts +42 -1
- package/src/memory-audit.ts +38 -10
- package/src/remote.ts +163 -24
package/package.json
CHANGED
|
@@ -282,6 +282,148 @@ 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 title override");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("legitimate titles starting with boilerplate-prefix words are NOT deleted", async () => {
|
|
319
|
+
// Regression test for the over-broad regex bug. Pre-fix patterns matched
|
|
320
|
+
// any title starting with "Placeholder", "Untitled", "Note", etc. After
|
|
321
|
+
// tightening, only exact boilerplate forms (with optional digit suffix
|
|
322
|
+
// or colon) match — real titles survive.
|
|
323
|
+
const { client, deletedIds } = makeMockClient(
|
|
324
|
+
[
|
|
325
|
+
{
|
|
326
|
+
id: "legit-placeholder",
|
|
327
|
+
type: "pattern",
|
|
328
|
+
title: "Placeholder pattern in React Suspense",
|
|
329
|
+
content:
|
|
330
|
+
"Use React.Suspense with a fallback component as the placeholder pattern for streaming SSR.",
|
|
331
|
+
confidence: 0.9,
|
|
332
|
+
memory_tier: "reference",
|
|
333
|
+
access_count: 12,
|
|
334
|
+
last_accessed_at: daysAgo(1),
|
|
335
|
+
created_at: daysAgo(60),
|
|
336
|
+
tags: ["react", "ssr"],
|
|
337
|
+
embedding: [0.1],
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "legit-untitled",
|
|
341
|
+
type: "context",
|
|
342
|
+
title: "UntitledMaster.fig — design source for the homepage",
|
|
343
|
+
content:
|
|
344
|
+
"Reference Figma file containing master components for landing page assets.",
|
|
345
|
+
confidence: 0.85,
|
|
346
|
+
memory_tier: "reference",
|
|
347
|
+
access_count: 8,
|
|
348
|
+
last_accessed_at: daysAgo(2),
|
|
349
|
+
created_at: daysAgo(45),
|
|
350
|
+
tags: ["design"],
|
|
351
|
+
embedding: [0.1],
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: "legit-note",
|
|
355
|
+
type: "context",
|
|
356
|
+
title: "Note: schema migration order matters",
|
|
357
|
+
content: "Always run 0042 before 0043 because of FK dependency.",
|
|
358
|
+
confidence: 0.8,
|
|
359
|
+
memory_tier: "reference",
|
|
360
|
+
access_count: 5,
|
|
361
|
+
last_accessed_at: daysAgo(3),
|
|
362
|
+
created_at: daysAgo(30),
|
|
363
|
+
tags: ["db"],
|
|
364
|
+
embedding: [0.1],
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
{ "legit-placeholder": 3, "legit-untitled": 2, "legit-note": 1 },
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const report = await runMemoryAudit(client, "ws-1", undefined, {
|
|
371
|
+
dryRun: false,
|
|
372
|
+
});
|
|
373
|
+
expect(deletedIds).toHaveLength(0);
|
|
374
|
+
expect(report.summary.delete).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("empty-content draft with real title is NOT delete-bucketed", async () => {
|
|
378
|
+
// Users sometimes save a draft with title only and fill content later.
|
|
379
|
+
// The override is title-only, so empty content alone must not delete.
|
|
380
|
+
const { client, deletedIds } = makeMockClient([
|
|
381
|
+
{
|
|
382
|
+
id: "draft-empty-body",
|
|
383
|
+
type: "decision",
|
|
384
|
+
title: "Decision: skip Q3 launch",
|
|
385
|
+
content: "",
|
|
386
|
+
confidence: 0.7,
|
|
387
|
+
memory_tier: "draft",
|
|
388
|
+
access_count: 1,
|
|
389
|
+
last_accessed_at: daysAgo(1),
|
|
390
|
+
created_at: daysAgo(2),
|
|
391
|
+
tags: ["q3"],
|
|
392
|
+
embedding: null,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
await runMemoryAudit(client, "ws-1", undefined, { dryRun: false });
|
|
397
|
+
expect(deletedIds).not.toContain("draft-empty-body");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("boilerplate override respects deleteBelow=0 escape hatch", async () => {
|
|
401
|
+
// deleteBelow=0 is a "no deletions, audit-only" knob. Boilerplate must
|
|
402
|
+
// honor it — operators should be able to inspect findings without losing
|
|
403
|
+
// data on the same call.
|
|
404
|
+
const { client, deletedIds } = makeMockClient([
|
|
405
|
+
{
|
|
406
|
+
id: "boilerplate-protected",
|
|
407
|
+
type: "context",
|
|
408
|
+
title: "Task transition: would normally delete",
|
|
409
|
+
content: "noise content",
|
|
410
|
+
confidence: 1.0,
|
|
411
|
+
memory_tier: "reference",
|
|
412
|
+
access_count: 50,
|
|
413
|
+
last_accessed_at: daysAgo(0),
|
|
414
|
+
created_at: daysAgo(20),
|
|
415
|
+
tags: ["x"],
|
|
416
|
+
embedding: [0.1],
|
|
417
|
+
},
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
await runMemoryAudit(client, "ws-1", undefined, {
|
|
421
|
+
dryRun: false,
|
|
422
|
+
deleteBelow: 0,
|
|
423
|
+
});
|
|
424
|
+
expect(deletedIds).toHaveLength(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
285
427
|
test("stale-draft filter flags draft+0access+age>threshold separately from bucket", async () => {
|
|
286
428
|
const { client } = makeMockClient(
|
|
287
429
|
[
|
package/src/api-client.ts
CHANGED
|
@@ -112,19 +112,43 @@ export async function requestWithBearer<T = unknown>(
|
|
|
112
112
|
return result as T;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Sentinel thrown when the API rejects the bearer/api-key with HTTP 401.
|
|
116
|
+
// Lets the MCP transport layer turn it into an HTTP 401 + WWW-Authenticate
|
|
117
|
+
// challenge so OAuth clients can refresh, instead of burying it inside a
|
|
118
|
+
// JSON-RPC tool error envelope.
|
|
119
|
+
export class HarmonyUnauthorizedError extends Error {
|
|
120
|
+
constructor(message = "Invalid or expired credentials") {
|
|
121
|
+
super(message);
|
|
122
|
+
this.name = "HarmonyUnauthorizedError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
export class HarmonyApiClient {
|
|
116
127
|
private apiKey: string;
|
|
117
128
|
private apiUrl: string;
|
|
129
|
+
private onUnauthorized?: () => void;
|
|
118
130
|
|
|
119
|
-
constructor(options?: {
|
|
131
|
+
constructor(options?: {
|
|
132
|
+
apiKey?: string;
|
|
133
|
+
apiUrl?: string;
|
|
134
|
+
onUnauthorized?: () => void;
|
|
135
|
+
}) {
|
|
120
136
|
this.apiKey = options?.apiKey ?? getApiKey();
|
|
121
137
|
this.apiUrl = options?.apiUrl ?? getApiUrl();
|
|
138
|
+
this.onUnauthorized = options?.onUnauthorized;
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
getApiUrl(): string {
|
|
125
142
|
return this.apiUrl;
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
// Lets the MCP session swap in a freshly refreshed OAuth access token
|
|
146
|
+
// without recreating the client. Called from remote.ts when the incoming
|
|
147
|
+
// Bearer header differs from the cached token.
|
|
148
|
+
setApiKey(apiKey: string): void {
|
|
149
|
+
this.apiKey = apiKey;
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
private async request<T>(
|
|
129
153
|
method: string,
|
|
130
154
|
path: string,
|
|
@@ -197,6 +221,13 @@ export class HarmonyApiClient {
|
|
|
197
221
|
? null
|
|
198
222
|
: `API error: ${response.status} (non-JSON response)`) ||
|
|
199
223
|
`API error: ${response.status}`;
|
|
224
|
+
// 401: token rejected by harmony-api. Don't retry — surface a typed
|
|
225
|
+
// error so the MCP transport layer can issue an HTTP 401 challenge
|
|
226
|
+
// and trigger the client's OAuth refresh flow.
|
|
227
|
+
if (response.status === 401) {
|
|
228
|
+
this.onUnauthorized?.();
|
|
229
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
230
|
+
}
|
|
200
231
|
if (!isRetryableError(null, response.status)) {
|
|
201
232
|
throw new Error(errorMsg);
|
|
202
233
|
}
|
|
@@ -259,6 +290,10 @@ export class HarmonyApiClient {
|
|
|
259
290
|
} catch {
|
|
260
291
|
errorMsg = text || `API error: ${response.status}`;
|
|
261
292
|
}
|
|
293
|
+
if (response.status === 401) {
|
|
294
|
+
this.onUnauthorized?.();
|
|
295
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
296
|
+
}
|
|
262
297
|
if (!isRetryableError(null, response.status)) {
|
|
263
298
|
throw new Error(errorMsg);
|
|
264
299
|
}
|
|
@@ -531,6 +566,9 @@ export class HarmonyApiClient {
|
|
|
531
566
|
costCents?: number;
|
|
532
567
|
inputTokens?: number;
|
|
533
568
|
outputTokens?: number;
|
|
569
|
+
cacheCreationInputTokens?: number;
|
|
570
|
+
cacheReadInputTokens?: number;
|
|
571
|
+
modelName?: string;
|
|
534
572
|
recentActions?: { action: string; ts: string }[];
|
|
535
573
|
},
|
|
536
574
|
): Promise<{ session: unknown; created: boolean }> {
|
|
@@ -545,6 +583,9 @@ export class HarmonyApiClient {
|
|
|
545
583
|
costCents?: number;
|
|
546
584
|
inputTokens?: number;
|
|
547
585
|
outputTokens?: number;
|
|
586
|
+
cacheCreationInputTokens?: number;
|
|
587
|
+
cacheReadInputTokens?: number;
|
|
588
|
+
modelName?: string;
|
|
548
589
|
},
|
|
549
590
|
): Promise<{ session: unknown }> {
|
|
550
591
|
return this.request("DELETE", `/cards/${cardId}/agent-context`, data);
|
package/src/memory-audit.ts
CHANGED
|
@@ -116,27 +116,45 @@ export interface AuditReport {
|
|
|
116
116
|
healthReport: string;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Patterns must stay in sync with:
|
|
120
|
+
// supabase/functions/_shared/memory-boilerplate.ts (edge function guard)
|
|
121
|
+
// supabase/migrations/*_harden_memory_cleanup.sql (cron sweeper)
|
|
122
|
+
//
|
|
123
|
+
// End-anchored where possible to avoid matching legitimate titles like
|
|
124
|
+
// "Placeholder pattern in React" or "Untitled.fig reference". The retired
|
|
125
|
+
// mid-session extractor's "Task transition: ..." prefix is the one open-ended
|
|
126
|
+
// pattern — it was never a user-chosen format.
|
|
119
127
|
const BOILERPLATE_PATTERNS = [
|
|
120
128
|
/^todo:?$/i,
|
|
121
|
-
/^placeholder
|
|
129
|
+
/^placeholder(\s+\d+|:)?$/i,
|
|
122
130
|
/^\.\.\.$/,
|
|
123
|
-
/^untitled
|
|
124
|
-
/^(note|memo|draft)\s
|
|
125
|
-
// Auto-captured task-transition snapshots from a retired active-learning rule.
|
|
126
|
-
// No user intent, no access pattern — treat as boilerplate so scoring archives them.
|
|
131
|
+
/^untitled(\s+\d+|:)?$/i,
|
|
132
|
+
/^(note|memo|draft)\s+\d+$/i,
|
|
127
133
|
/^task transition:/i,
|
|
128
134
|
];
|
|
129
135
|
|
|
130
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Title-only check. Used by the audit override — should not delete an entry
|
|
138
|
+
* just because its content is empty (may be a draft the user hasn't finished).
|
|
139
|
+
*/
|
|
140
|
+
function isBoilerplateTitle(title: string): boolean {
|
|
131
141
|
const t = title.trim();
|
|
132
|
-
const c = content.trim();
|
|
133
|
-
if (c.length === 0) return true;
|
|
134
142
|
for (const pat of BOILERPLATE_PATTERNS) {
|
|
135
143
|
if (pat.test(t)) return true;
|
|
136
144
|
}
|
|
137
145
|
return false;
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Stricter check used by the content-quality scoring band. Empty content is
|
|
150
|
+
* "boilerplate" for scoring — an empty memory contributes nothing regardless
|
|
151
|
+
* of title — but does not on its own trigger the delete-bucket override.
|
|
152
|
+
*/
|
|
153
|
+
function isBoilerplate(title: string, content: string): boolean {
|
|
154
|
+
if (content.trim().length === 0) return true;
|
|
155
|
+
return isBoilerplateTitle(title);
|
|
156
|
+
}
|
|
157
|
+
|
|
140
158
|
function scoreEntity(
|
|
141
159
|
entity: AuditEntity,
|
|
142
160
|
relationCount: number,
|
|
@@ -253,9 +271,19 @@ function scoreEntity(
|
|
|
253
271
|
legacyReasons.push("no graph presence");
|
|
254
272
|
}
|
|
255
273
|
|
|
256
|
-
// Bucket
|
|
274
|
+
// Bucket — boilerplate TITLE is a one-way door to delete. High access
|
|
275
|
+
// counts on noise titles signal re-read churn (recall/dedup loops), not
|
|
276
|
+
// genuine reuse; letting confidence + tier + decay drag the composite
|
|
277
|
+
// score back into "keep" leaves promoted-to-reference junk untouched.
|
|
278
|
+
// Override scoring, except when deleteBelow=0 (the "no deletions" escape
|
|
279
|
+
// hatch). Title-only on purpose: an empty-content entry with a real title
|
|
280
|
+
// may be a draft; the user should see it in the audit, not lose it.
|
|
281
|
+
const boilerplateTitle = isBoilerplateTitle(entity.title);
|
|
257
282
|
let bucket: AuditBucket;
|
|
258
|
-
if (
|
|
283
|
+
if (boilerplateTitle && deleteBelow > 0) {
|
|
284
|
+
bucket = "delete";
|
|
285
|
+
reasons.push("boilerplate title override");
|
|
286
|
+
} else if (score < deleteBelow) bucket = "delete";
|
|
259
287
|
else if (score < archiveBelow) bucket = "archive";
|
|
260
288
|
else if (score < 70) bucket = "review";
|
|
261
289
|
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
|
}
|
|
@@ -59,10 +90,16 @@ async function validateApiKey(apiKey: string): Promise<ApiKeyInfo | null> {
|
|
|
59
90
|
interface McpSession {
|
|
60
91
|
transport: WebStandardStreamableHTTPServerTransport;
|
|
61
92
|
server: Server;
|
|
93
|
+
client: HarmonyApiClient;
|
|
62
94
|
apiKey: string;
|
|
63
95
|
activeWorkspaceId: string | null;
|
|
64
96
|
activeProjectId: string | null;
|
|
65
97
|
createdAt: number;
|
|
98
|
+
// Set by HarmonyApiClient.onUnauthorized when the API rejects the cached
|
|
99
|
+
// token mid-session. The HTTP layer reads this after transport.handleRequest
|
|
100
|
+
// returns and converts the response to an HTTP 401 challenge so the OAuth
|
|
101
|
+
// client refreshes instead of caching a JSON-RPC error forever.
|
|
102
|
+
unauthorized: boolean;
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
const sessions = new Map<string, McpSession>();
|
|
@@ -82,7 +119,7 @@ setInterval(
|
|
|
82
119
|
30 * 60 * 1000,
|
|
83
120
|
);
|
|
84
121
|
|
|
85
|
-
function createSession(apiKey: string, keyInfo:
|
|
122
|
+
function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
|
|
86
123
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
87
124
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
88
125
|
enableJsonResponse: true,
|
|
@@ -93,18 +130,38 @@ function createSession(apiKey: string, keyInfo: ApiKeyInfo): McpSession {
|
|
|
93
130
|
{ capabilities: { tools: {}, resources: {} } },
|
|
94
131
|
);
|
|
95
132
|
|
|
133
|
+
// unauthorized flag lives on a mutable holder so the HarmonyApiClient
|
|
134
|
+
// callback can flip it without a circular reference between the client and
|
|
135
|
+
// the session struct it lives on.
|
|
136
|
+
const authState = { unauthorized: false };
|
|
137
|
+
|
|
138
|
+
// Create per-session API client. onUnauthorized fires when harmony-api
|
|
139
|
+
// returns 401 — we mark the session so the HTTP layer can surface a real
|
|
140
|
+
// 401 + WWW-Authenticate challenge to the OAuth client.
|
|
141
|
+
const client = new HarmonyApiClient({
|
|
142
|
+
apiKey,
|
|
143
|
+
apiUrl: HARMONY_API_URL,
|
|
144
|
+
onUnauthorized: () => {
|
|
145
|
+
authState.unauthorized = true;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
96
149
|
const session: McpSession = {
|
|
97
150
|
transport,
|
|
98
151
|
server,
|
|
152
|
+
client,
|
|
99
153
|
apiKey,
|
|
100
154
|
activeWorkspaceId: keyInfo.workspaceId,
|
|
101
155
|
activeProjectId: null,
|
|
102
156
|
createdAt: Date.now(),
|
|
157
|
+
get unauthorized() {
|
|
158
|
+
return authState.unauthorized;
|
|
159
|
+
},
|
|
160
|
+
set unauthorized(v: boolean) {
|
|
161
|
+
authState.unauthorized = v;
|
|
162
|
+
},
|
|
103
163
|
};
|
|
104
164
|
|
|
105
|
-
// Create per-session deps
|
|
106
|
-
const client = new HarmonyApiClient({ apiKey, apiUrl: HARMONY_API_URL });
|
|
107
|
-
|
|
108
165
|
const deps: ToolDeps = {
|
|
109
166
|
getClient: () => client,
|
|
110
167
|
isConfigured: () => true,
|
|
@@ -164,14 +221,57 @@ app.get("/health", (c) =>
|
|
|
164
221
|
}),
|
|
165
222
|
);
|
|
166
223
|
|
|
167
|
-
//
|
|
168
|
-
|
|
224
|
+
// OAuth 2.1 Protected Resource Metadata (RFC 9728 / MCP 2025-11-25).
|
|
225
|
+
// Tells unauthenticated clients which authorization server to use.
|
|
226
|
+
app.get("/.well-known/oauth-protected-resource", (c) =>
|
|
227
|
+
c.json({
|
|
228
|
+
resource: PUBLIC_MCP_URL,
|
|
229
|
+
authorization_servers: [OAUTH_ISSUER],
|
|
230
|
+
bearer_methods_supported: ["header"],
|
|
231
|
+
resource_documentation: `${OAUTH_ISSUER}/docs/mcp`,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Unauthenticated 401 that advertises the OAuth metadata discovery point.
|
|
236
|
+
// Claude's `mcp add --transport http` uses this to kick off the flow.
|
|
237
|
+
function unauthenticatedResponse(): Response {
|
|
238
|
+
return new Response(
|
|
239
|
+
JSON.stringify({
|
|
240
|
+
error: "unauthorized",
|
|
241
|
+
error_description: "Missing or invalid access token",
|
|
242
|
+
}),
|
|
243
|
+
{
|
|
244
|
+
status: 401,
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
"WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource"`,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Evict a session and tear down its transport. Used when an OAuth token
|
|
254
|
+
// rotates or is revoked mid-session — we don't want to keep a zombie session
|
|
255
|
+
// around with a stale cached api key.
|
|
256
|
+
function evictSession(sessionId: string): void {
|
|
257
|
+
const session = sessions.get(sessionId);
|
|
258
|
+
if (!session) return;
|
|
259
|
+
sessions.delete(sessionId);
|
|
260
|
+
session.transport.close().catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close).
|
|
264
|
+
// Mounted on both `/mcp` and `/` so clients that registered the bare host as
|
|
265
|
+
// their server URL still reach the OAuth challenge instead of a 404.
|
|
266
|
+
const handleMcpRequest = async (c: import("hono").Context) => {
|
|
169
267
|
const method = c.req.method;
|
|
170
268
|
|
|
171
|
-
// Extract
|
|
269
|
+
// Extract bearer token. Accept OAuth access tokens (hmy_at_) and legacy
|
|
270
|
+
// integration keys (hmy_). No token → 401 with WWW-Authenticate so Claude
|
|
271
|
+
// can discover our authorization server.
|
|
172
272
|
const authHeader = c.req.header("Authorization");
|
|
173
273
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
174
|
-
return
|
|
274
|
+
return unauthenticatedResponse();
|
|
175
275
|
}
|
|
176
276
|
const apiKey = authHeader.slice(7);
|
|
177
277
|
|
|
@@ -179,17 +279,44 @@ app.all("/mcp", async (c) => {
|
|
|
179
279
|
const sessionId = c.req.header("Mcp-Session-Id");
|
|
180
280
|
|
|
181
281
|
if (sessionId && sessions.has(sessionId)) {
|
|
182
|
-
// Existing session - forward request
|
|
183
282
|
const session = sessions.get(sessionId)!;
|
|
184
|
-
|
|
283
|
+
|
|
284
|
+
// Hot-swap the cached token if the OAuth client just refreshed. Without
|
|
285
|
+
// this the session would keep using the stale access token forever and
|
|
286
|
+
// every tool call after refresh would 401 — the bug that motivated this
|
|
287
|
+
// patch.
|
|
288
|
+
if (session.apiKey !== apiKey) {
|
|
289
|
+
session.apiKey = apiKey;
|
|
290
|
+
session.client.setApiKey(apiKey);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Reset the per-request 401 latch before handing off to the transport.
|
|
294
|
+
session.unauthorized = false;
|
|
295
|
+
|
|
296
|
+
const response = await session.transport.handleRequest(c.req.raw);
|
|
297
|
+
|
|
298
|
+
// If a tool call hit 401 against harmony-api, the api-client tripped the
|
|
299
|
+
// unauthorized flag. Evict the session and return an HTTP 401 +
|
|
300
|
+
// WWW-Authenticate so the client triggers a refresh — instead of burying
|
|
301
|
+
// the auth failure inside a JSON-RPC error envelope the client can't act
|
|
302
|
+
// on.
|
|
303
|
+
if (session.unauthorized) {
|
|
304
|
+
evictSession(sessionId);
|
|
305
|
+
return unauthenticatedResponse();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return response;
|
|
185
309
|
}
|
|
186
310
|
|
|
187
311
|
if (method === "POST") {
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
const keyInfo = await
|
|
312
|
+
// Validate the token. OAuth tokens carry workspaceId; legacy keys don't,
|
|
313
|
+
// so we look up workspaces in a follow-up call for those.
|
|
314
|
+
const keyInfo = await validateToken(apiKey);
|
|
191
315
|
if (!keyInfo) {
|
|
192
|
-
return
|
|
316
|
+
return unauthenticatedResponse();
|
|
317
|
+
}
|
|
318
|
+
if (keyInfo.source === "api_key" && !keyInfo.workspaceId) {
|
|
319
|
+
keyInfo.workspaceId = await resolveWorkspaceForLegacyKey(apiKey);
|
|
193
320
|
}
|
|
194
321
|
|
|
195
322
|
// Create new session
|
|
@@ -205,13 +332,25 @@ app.all("/mcp", async (c) => {
|
|
|
205
332
|
origOnSessionInitialized?.(sid);
|
|
206
333
|
};
|
|
207
334
|
|
|
208
|
-
|
|
209
|
-
|
|
335
|
+
const response = await session.transport.handleRequest(c.req.raw);
|
|
336
|
+
|
|
337
|
+
// Same 401-latch check as the existing-session branch — covers the case
|
|
338
|
+
// where the *initialize* call itself triggers an API request that 401s
|
|
339
|
+
// (e.g., revoked-during-handshake).
|
|
340
|
+
if (session.unauthorized && session.transport.sessionId) {
|
|
341
|
+
evictSession(session.transport.sessionId);
|
|
342
|
+
return unauthenticatedResponse();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return response;
|
|
210
346
|
}
|
|
211
347
|
|
|
212
348
|
// GET or DELETE without a valid session
|
|
213
349
|
return c.json({ error: "Invalid or missing session" }, 404);
|
|
214
|
-
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
app.all("/mcp", handleMcpRequest);
|
|
353
|
+
app.all("/", handleMcpRequest);
|
|
215
354
|
|
|
216
355
|
// ---------------------------------------------------------------------------
|
|
217
356
|
// Start server
|