@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.4.3",
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"
@@ -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?: { 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
  }
@@ -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);
@@ -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,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 (score < deleteBelow) bucket = "delete";
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
- // 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
  }
@@ -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: ApiKeyInfo): McpSession {
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
- // MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close)
168
- app.all("/mcp", async (c) => {
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 API key from Authorization header
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 c.json({ error: "Missing Authorization: Bearer <api-key>" }, 401);
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
- 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;
185
309
  }
186
310
 
187
311
  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);
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 c.json({ error: "Invalid API key" }, 401);
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
- // Handle the initialize request
209
- 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;
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