@gethmy/mcp 2.4.4 → 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 CHANGED
@@ -27052,16 +27052,28 @@ async function requestWithBearer(apiUrl, bearerToken, method, path, body) {
27052
27052
  return result;
27053
27053
  }
27054
27054
 
27055
+ class HarmonyUnauthorizedError extends Error {
27056
+ constructor(message = "Invalid or expired credentials") {
27057
+ super(message);
27058
+ this.name = "HarmonyUnauthorizedError";
27059
+ }
27060
+ }
27061
+
27055
27062
  class HarmonyApiClient {
27056
27063
  apiKey;
27057
27064
  apiUrl;
27065
+ onUnauthorized;
27058
27066
  constructor(options) {
27059
27067
  this.apiKey = options?.apiKey ?? getApiKey();
27060
27068
  this.apiUrl = options?.apiUrl ?? getApiUrl();
27069
+ this.onUnauthorized = options?.onUnauthorized;
27061
27070
  }
27062
27071
  getApiUrl() {
27063
27072
  return this.apiUrl;
27064
27073
  }
27074
+ setApiKey(apiKey) {
27075
+ this.apiKey = apiKey;
27076
+ }
27065
27077
  async request(method, path, body, options) {
27066
27078
  await requestSemaphore.acquire();
27067
27079
  try {
@@ -27108,6 +27120,10 @@ class HarmonyApiClient {
27108
27120
  }
27109
27121
  if (!response.ok) {
27110
27122
  const errorMsg = data?.error || (looksLikeJson ? null : `API error: ${response.status} (non-JSON response)`) || `API error: ${response.status}`;
27123
+ if (response.status === 401) {
27124
+ this.onUnauthorized?.();
27125
+ throw new HarmonyUnauthorizedError(errorMsg);
27126
+ }
27111
27127
  if (!isRetryableError(null, response.status)) {
27112
27128
  throw new Error(errorMsg);
27113
27129
  }
@@ -27157,6 +27173,10 @@ class HarmonyApiClient {
27157
27173
  } catch {
27158
27174
  errorMsg = text || `API error: ${response.status}`;
27159
27175
  }
27176
+ if (response.status === 401) {
27177
+ this.onUnauthorized?.();
27178
+ throw new HarmonyUnauthorizedError(errorMsg);
27179
+ }
27160
27180
  if (!isRetryableError(null, response.status)) {
27161
27181
  throw new Error(errorMsg);
27162
27182
  }
@@ -28088,23 +28108,25 @@ var BATCH_SIZE = 100;
28088
28108
  var CONCURRENCY_LIMIT = 5;
28089
28109
  var BOILERPLATE_PATTERNS = [
28090
28110
  /^todo:?$/i,
28091
- /^placeholder/i,
28111
+ /^placeholder(\s+\d+|:)?$/i,
28092
28112
  /^\.\.\.$/,
28093
- /^untitled/i,
28094
- /^(note|memo|draft)\s*\d*$/i,
28113
+ /^untitled(\s+\d+|:)?$/i,
28114
+ /^(note|memo|draft)\s+\d+$/i,
28095
28115
  /^task transition:/i
28096
28116
  ];
28097
- function isBoilerplate(title, content) {
28117
+ function isBoilerplateTitle(title) {
28098
28118
  const t = title.trim();
28099
- const c = content.trim();
28100
- if (c.length === 0)
28101
- return true;
28102
28119
  for (const pat of BOILERPLATE_PATTERNS) {
28103
28120
  if (pat.test(t))
28104
28121
  return true;
28105
28122
  }
28106
28123
  return false;
28107
28124
  }
28125
+ function isBoilerplate(title, content) {
28126
+ if (content.trim().length === 0)
28127
+ return true;
28128
+ return isBoilerplateTitle(title);
28129
+ }
28108
28130
  function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraftAgeDays) {
28109
28131
  const now = Date.now();
28110
28132
  const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
@@ -28186,10 +28208,11 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraf
28186
28208
  legacy = true;
28187
28209
  legacyReasons.push("no graph presence");
28188
28210
  }
28211
+ const boilerplateTitle = isBoilerplateTitle(entity.title);
28189
28212
  let bucket;
28190
- if (boilerplate && deleteBelow > 0) {
28213
+ if (boilerplateTitle && deleteBelow > 0) {
28191
28214
  bucket = "delete";
28192
- reasons.push("boilerplate override");
28215
+ reasons.push("boilerplate title override");
28193
28216
  } else if (score < deleteBelow)
28194
28217
  bucket = "delete";
28195
28218
  else if (score < archiveBelow)
package/dist/index.js CHANGED
@@ -24812,16 +24812,28 @@ async function requestWithBearer(apiUrl, bearerToken, method, path, body) {
24812
24812
  return result;
24813
24813
  }
24814
24814
 
24815
+ class HarmonyUnauthorizedError extends Error {
24816
+ constructor(message = "Invalid or expired credentials") {
24817
+ super(message);
24818
+ this.name = "HarmonyUnauthorizedError";
24819
+ }
24820
+ }
24821
+
24815
24822
  class HarmonyApiClient {
24816
24823
  apiKey;
24817
24824
  apiUrl;
24825
+ onUnauthorized;
24818
24826
  constructor(options) {
24819
24827
  this.apiKey = options?.apiKey ?? getApiKey();
24820
24828
  this.apiUrl = options?.apiUrl ?? getApiUrl();
24829
+ this.onUnauthorized = options?.onUnauthorized;
24821
24830
  }
24822
24831
  getApiUrl() {
24823
24832
  return this.apiUrl;
24824
24833
  }
24834
+ setApiKey(apiKey) {
24835
+ this.apiKey = apiKey;
24836
+ }
24825
24837
  async request(method, path, body, options) {
24826
24838
  await requestSemaphore.acquire();
24827
24839
  try {
@@ -24868,6 +24880,10 @@ class HarmonyApiClient {
24868
24880
  }
24869
24881
  if (!response.ok) {
24870
24882
  const errorMsg = data?.error || (looksLikeJson ? null : `API error: ${response.status} (non-JSON response)`) || `API error: ${response.status}`;
24883
+ if (response.status === 401) {
24884
+ this.onUnauthorized?.();
24885
+ throw new HarmonyUnauthorizedError(errorMsg);
24886
+ }
24871
24887
  if (!isRetryableError(null, response.status)) {
24872
24888
  throw new Error(errorMsg);
24873
24889
  }
@@ -24917,6 +24933,10 @@ class HarmonyApiClient {
24917
24933
  } catch {
24918
24934
  errorMsg = text || `API error: ${response.status}`;
24919
24935
  }
24936
+ if (response.status === 401) {
24937
+ this.onUnauthorized?.();
24938
+ throw new HarmonyUnauthorizedError(errorMsg);
24939
+ }
24920
24940
  if (!isRetryableError(null, response.status)) {
24921
24941
  throw new Error(errorMsg);
24922
24942
  }
@@ -25848,23 +25868,25 @@ var BATCH_SIZE = 100;
25848
25868
  var CONCURRENCY_LIMIT = 5;
25849
25869
  var BOILERPLATE_PATTERNS = [
25850
25870
  /^todo:?$/i,
25851
- /^placeholder/i,
25871
+ /^placeholder(\s+\d+|:)?$/i,
25852
25872
  /^\.\.\.$/,
25853
- /^untitled/i,
25854
- /^(note|memo|draft)\s*\d*$/i,
25873
+ /^untitled(\s+\d+|:)?$/i,
25874
+ /^(note|memo|draft)\s+\d+$/i,
25855
25875
  /^task transition:/i
25856
25876
  ];
25857
- function isBoilerplate(title, content) {
25877
+ function isBoilerplateTitle(title) {
25858
25878
  const t = title.trim();
25859
- const c = content.trim();
25860
- if (c.length === 0)
25861
- return true;
25862
25879
  for (const pat of BOILERPLATE_PATTERNS) {
25863
25880
  if (pat.test(t))
25864
25881
  return true;
25865
25882
  }
25866
25883
  return false;
25867
25884
  }
25885
+ function isBoilerplate(title, content) {
25886
+ if (content.trim().length === 0)
25887
+ return true;
25888
+ return isBoilerplateTitle(title);
25889
+ }
25868
25890
  function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraftAgeDays) {
25869
25891
  const now = Date.now();
25870
25892
  const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
@@ -25946,10 +25968,11 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraf
25946
25968
  legacy = true;
25947
25969
  legacyReasons.push("no graph presence");
25948
25970
  }
25971
+ const boilerplateTitle = isBoilerplateTitle(entity.title);
25949
25972
  let bucket;
25950
- if (boilerplate && deleteBelow > 0) {
25973
+ if (boilerplateTitle && deleteBelow > 0) {
25951
25974
  bucket = "delete";
25952
- reasons.push("boilerplate override");
25975
+ reasons.push("boilerplate title override");
25953
25976
  } else if (score < deleteBelow)
25954
25977
  bucket = "delete";
25955
25978
  else if (score < archiveBelow)
@@ -1570,16 +1570,28 @@ async function requestWithBearer(apiUrl, bearerToken, method, path, body) {
1570
1570
  return result;
1571
1571
  }
1572
1572
 
1573
+ class HarmonyUnauthorizedError extends Error {
1574
+ constructor(message = "Invalid or expired credentials") {
1575
+ super(message);
1576
+ this.name = "HarmonyUnauthorizedError";
1577
+ }
1578
+ }
1579
+
1573
1580
  class HarmonyApiClient {
1574
1581
  apiKey;
1575
1582
  apiUrl;
1583
+ onUnauthorized;
1576
1584
  constructor(options) {
1577
1585
  this.apiKey = options?.apiKey ?? getApiKey();
1578
1586
  this.apiUrl = options?.apiUrl ?? getApiUrl();
1587
+ this.onUnauthorized = options?.onUnauthorized;
1579
1588
  }
1580
1589
  getApiUrl() {
1581
1590
  return this.apiUrl;
1582
1591
  }
1592
+ setApiKey(apiKey) {
1593
+ this.apiKey = apiKey;
1594
+ }
1583
1595
  async request(method, path, body, options) {
1584
1596
  await requestSemaphore.acquire();
1585
1597
  try {
@@ -1626,6 +1638,10 @@ class HarmonyApiClient {
1626
1638
  }
1627
1639
  if (!response.ok) {
1628
1640
  const errorMsg = data?.error || (looksLikeJson ? null : `API error: ${response.status} (non-JSON response)`) || `API error: ${response.status}`;
1641
+ if (response.status === 401) {
1642
+ this.onUnauthorized?.();
1643
+ throw new HarmonyUnauthorizedError(errorMsg);
1644
+ }
1629
1645
  if (!isRetryableError(null, response.status)) {
1630
1646
  throw new Error(errorMsg);
1631
1647
  }
@@ -1675,6 +1691,10 @@ class HarmonyApiClient {
1675
1691
  } catch {
1676
1692
  errorMsg = text || `API error: ${response.status}`;
1677
1693
  }
1694
+ if (response.status === 401) {
1695
+ this.onUnauthorized?.();
1696
+ throw new HarmonyUnauthorizedError(errorMsg);
1697
+ }
1678
1698
  if (!isRetryableError(null, response.status)) {
1679
1699
  throw new Error(errorMsg);
1680
1700
  }
@@ -2136,5 +2156,6 @@ export {
2136
2156
  resetClient,
2137
2157
  requestWithBearer,
2138
2158
  getClient,
2159
+ HarmonyUnauthorizedError,
2139
2160
  HarmonyApiClient
2140
2161
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -312,7 +312,89 @@ describe("runMemoryAudit", () => {
312
312
  });
313
313
  expect(report.summary.delete).toBe(1);
314
314
  expect(deletedIds).toContain("promoted-junk");
315
- expect(report.lowest[0].reasons).toContain("boilerplate override");
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");
316
398
  });
317
399
 
318
400
  test("boilerplate override respects deleteBelow=0 escape hatch", async () => {
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?: { apiKey?: string; apiUrl?: string }) {
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
  }
@@ -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/i,
129
+ /^placeholder(\s+\d+|:)?$/i,
122
130
  /^\.\.\.$/,
123
- /^untitled/i,
124
- /^(note|memo|draft)\s*\d*$/i,
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
- function isBoilerplate(title: string, content: string): boolean {
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,16 +271,18 @@ function scoreEntity(
253
271
  legacyReasons.push("no graph presence");
254
272
  }
255
273
 
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.
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);
262
282
  let bucket: AuditBucket;
263
- if (boilerplate && deleteBelow > 0) {
283
+ if (boilerplateTitle && deleteBelow > 0) {
264
284
  bucket = "delete";
265
- reasons.push("boilerplate override");
285
+ reasons.push("boilerplate title override");
266
286
  } else if (score < deleteBelow) bucket = "delete";
267
287
  else if (score < archiveBelow) bucket = "archive";
268
288
  else if (score < 70) bucket = "review";
package/src/remote.ts CHANGED
@@ -90,10 +90,16 @@ async function resolveWorkspaceForLegacyKey(
90
90
  interface McpSession {
91
91
  transport: WebStandardStreamableHTTPServerTransport;
92
92
  server: Server;
93
+ client: HarmonyApiClient;
93
94
  apiKey: string;
94
95
  activeWorkspaceId: string | null;
95
96
  activeProjectId: string | null;
96
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;
97
103
  }
98
104
 
99
105
  const sessions = new Map<string, McpSession>();
@@ -124,18 +130,38 @@ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
124
130
  { capabilities: { tools: {}, resources: {} } },
125
131
  );
126
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
+
127
149
  const session: McpSession = {
128
150
  transport,
129
151
  server,
152
+ client,
130
153
  apiKey,
131
154
  activeWorkspaceId: keyInfo.workspaceId,
132
155
  activeProjectId: null,
133
156
  createdAt: Date.now(),
157
+ get unauthorized() {
158
+ return authState.unauthorized;
159
+ },
160
+ set unauthorized(v: boolean) {
161
+ authState.unauthorized = v;
162
+ },
134
163
  };
135
164
 
136
- // Create per-session deps
137
- const client = new HarmonyApiClient({ apiKey, apiUrl: HARMONY_API_URL });
138
-
139
165
  const deps: ToolDeps = {
140
166
  getClient: () => client,
141
167
  isConfigured: () => true,
@@ -224,6 +250,16 @@ function unauthenticatedResponse(): Response {
224
250
  );
225
251
  }
226
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
+
227
263
  // MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close).
228
264
  // Mounted on both `/mcp` and `/` so clients that registered the bare host as
229
265
  // their server URL still reach the OAuth challenge instead of a 404.
@@ -243,9 +279,33 @@ const handleMcpRequest = async (c: import("hono").Context) => {
243
279
  const sessionId = c.req.header("Mcp-Session-Id");
244
280
 
245
281
  if (sessionId && sessions.has(sessionId)) {
246
- // Existing session - forward request
247
282
  const session = sessions.get(sessionId)!;
248
- return session.transport.handleRequest(c.req.raw);
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;
249
309
  }
250
310
 
251
311
  if (method === "POST") {
@@ -272,8 +332,17 @@ const handleMcpRequest = async (c: import("hono").Context) => {
272
332
  origOnSessionInitialized?.(sid);
273
333
  };
274
334
 
275
- // Handle the initialize request
276
- return session.transport.handleRequest(c.req.raw);
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;
277
346
  }
278
347
 
279
348
  // GET or DELETE without a valid session