From 3d5579a0067bab0d3a2b6b4be4535b5edca5f83f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 13:21:21 +0000 Subject: [PATCH 1/2] fix(mcp): add 30s fetch timeouts to prevent MCP stalls Add FETCH_TIMEOUT_MS = 30_000 constant and apply AbortSignal.timeout to all outbound fetch calls from the supermemory-mcp Durable Object: - client.ts: SDK constructor timeout + getProjects/getDocuments signals - client.ts: handleError catches AbortError/TimeoutError with friendly msg - auth.ts: validateOAuthToken fetch signal - index.ts: oauth-authorization-server proxy fetch signal Fixes stalls where downstream services (Turbopuffer/Gemini) blocked DO subrequests for minutes with no client-side timeout. Co-authored-by: Dhravya Shah --- apps/mcp/src/auth.ts | 1 + apps/mcp/src/client.ts | 17 ++++++++++++++++- apps/mcp/src/index.ts | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/mcp/src/auth.ts b/apps/mcp/src/auth.ts index 7bd3b9464..7bf6bc1bd 100644 --- a/apps/mcp/src/auth.ts +++ b/apps/mcp/src/auth.ts @@ -103,6 +103,7 @@ export async function validateOAuthToken( headers: { Authorization: `Bearer ${token}`, }, + signal: AbortSignal.timeout(30_000), }) if (!sessionResponse.ok) { diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index ee35fcf1a..2ce8d41a8 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -2,6 +2,7 @@ import Supermemory from "supermemory" const MAX_CHARS = 200000 // ~50k tokens (character-based limit) const DEFAULT_PROJECT_ID = "sm_project_default" +const FETCH_TIMEOUT_MS = 30_000 interface MemoryRichFields { metadata?: Record | null @@ -142,6 +143,7 @@ export class SupermemoryClient { this.client = new Supermemory({ apiKey: bearerToken, baseURL: apiUrl, + timeout: FETCH_TIMEOUT_MS, }) this.containerTag = containerTag || DEFAULT_PROJECT_ID } @@ -328,14 +330,16 @@ export class SupermemoryClient { } // Get projects list - async getProjects(): Promise { + async getProjects(options?: { signal?: AbortSignal }): Promise { try { + const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) const response = await fetch(`${this.apiUrl}/v3/projects`, { method: "GET", headers: { Authorization: `Bearer ${this.bearerToken}`, "Content-Type": "application/json", }, + signal, }) if (!response.ok) { @@ -359,8 +363,10 @@ export class SupermemoryClient { containerTags?: string[], page = 1, limit = 10, + options?: { signal?: AbortSignal }, ): Promise { try { + const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { method: "POST", headers: { @@ -374,6 +380,7 @@ export class SupermemoryClient { order: "desc", containerTags, }), + signal, }) if (!response.ok) { throw Object.assign(new Error("Failed to fetch documents"), { @@ -387,6 +394,14 @@ export class SupermemoryClient { } private handleError(error: unknown): never { + // Handle request timeout (AbortSignal.timeout or explicit abort) + if ( + error instanceof Error && + (error.name === "AbortError" || error.name === "TimeoutError") + ) { + throw new Error("Request to Supermemory API timed out") + } + // Handle network/fetch errors if (error instanceof TypeError) { if ( diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 846064e1f..457319826 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -89,6 +89,7 @@ app.get("/.well-known/oauth-authorization-server", async (c) => { // Fetch the authorization server metadata from the main API const response = await fetch( `${apiUrl}/.well-known/oauth-authorization-server`, + { signal: AbortSignal.timeout(30_000) }, ) if (!response.ok) { From 790f989d2f3bced7627b2e93ac5365396a8285a1 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Sun, 21 Jun 2026 23:04:07 -0700 Subject: [PATCH 2/2] Fix MCP auth timeouts and return retryable 504 on auth stalls. Add shared FETCH_TIMEOUT_MS, timeout both API-key and OAuth auth paths, disable SDK retries for a true 30s bound, and distinguish auth timeouts from invalid credentials so clients get 504 instead of 401. --- apps/mcp/src/auth.ts | 63 ++++++++++++++++++++++++++++----------- apps/mcp/src/client.ts | 12 ++++---- apps/mcp/src/constants.ts | 1 + apps/mcp/src/index.ts | 43 ++++++++++++++++---------- 4 files changed, 82 insertions(+), 37 deletions(-) create mode 100644 apps/mcp/src/constants.ts diff --git a/apps/mcp/src/auth.ts b/apps/mcp/src/auth.ts index 7bf6bc1bd..6e7948537 100644 --- a/apps/mcp/src/auth.ts +++ b/apps/mcp/src/auth.ts @@ -4,6 +4,8 @@ * This validates OAuth tokens and API keys by calling the main Supermemory API, */ +import { FETCH_TIMEOUT_MS } from "./constants" + export interface AuthUser { userId: string apiKey: string @@ -11,6 +13,18 @@ export interface AuthUser { name?: string } +export type AuthValidationResult = + | { status: "success"; user: AuthUser } + | { status: "invalid" } + | { status: "timeout" } + +function isFetchTimeout(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "AbortError" || error.name === "TimeoutError") + ) +} + /** * Check if a token is an API key (starts with "sm_") */ @@ -25,13 +39,14 @@ export function isApiKey(token: string): boolean { export async function validateApiKey( apiKey: string, apiUrl: string, -): Promise { +): Promise { try { const sessionResponse = await fetch(`${apiUrl}/v3/session`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!sessionResponse.ok) { @@ -56,7 +71,7 @@ export async function validateApiKey( } else { console.error("API key validation failed:", status, responseText) } - return null + return { status: "invalid" } } const sessionData = (await sessionResponse.json()) as { @@ -72,20 +87,27 @@ export async function validateApiKey( if (!sessionData?.user?.id) { console.error("Missing user.id in session response:", sessionData) - return null + return { status: "invalid" } } console.log("API key validated for user:", sessionData.user.id) return { - userId: sessionData.user.id, - apiKey: apiKey, - email: sessionData.user.email, - name: sessionData.user.name, + status: "success", + user: { + userId: sessionData.user.id, + apiKey: apiKey, + email: sessionData.user.email, + name: sessionData.user.name, + }, } } catch (error) { + if (isFetchTimeout(error)) { + console.error("API key validation timed out") + return { status: "timeout" } + } console.error("API key validation error:", error) - return null + return { status: "invalid" } } } @@ -96,14 +118,14 @@ export async function validateApiKey( export async function validateOAuthToken( token: string, apiUrl: string, -): Promise { +): Promise { try { const sessionResponse = await fetch(`${apiUrl}/v3/mcp/session-with-key`, { method: "GET", headers: { Authorization: `Bearer ${token}`, }, - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!sessionResponse.ok) { @@ -128,7 +150,7 @@ export async function validateOAuthToken( } else { console.error("Token validation failed:", status, responseText) } - return null + return { status: "invalid" } } const sessionData = (await sessionResponse.json()) as { @@ -144,19 +166,26 @@ export async function validateOAuthToken( "Missing userId or apiKey in session response:", sessionData, ) - return null + return { status: "invalid" } } console.log("OAuth validated, got API key for user:", sessionData.userId) return { - userId: sessionData.userId, - apiKey: sessionData.apiKey, - email: sessionData.email, - name: sessionData.name, + status: "success", + user: { + userId: sessionData.userId, + apiKey: sessionData.apiKey, + email: sessionData.email, + name: sessionData.name, + }, } } catch (error) { + if (isFetchTimeout(error)) { + console.error("Token validation timed out") + return { status: "timeout" } + } console.error("Token validation error:", error) - return null + return { status: "invalid" } } } diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index 2ce8d41a8..ccb08503c 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -1,8 +1,9 @@ -import Supermemory from "supermemory" +import Supermemory, { APIConnectionTimeoutError } from "supermemory" + +import { FETCH_TIMEOUT_MS } from "./constants" const MAX_CHARS = 200000 // ~50k tokens (character-based limit) const DEFAULT_PROJECT_ID = "sm_project_default" -const FETCH_TIMEOUT_MS = 30_000 interface MemoryRichFields { metadata?: Record | null @@ -144,6 +145,7 @@ export class SupermemoryClient { apiKey: bearerToken, baseURL: apiUrl, timeout: FETCH_TIMEOUT_MS, + maxRetries: 0, }) this.containerTag = containerTag || DEFAULT_PROJECT_ID } @@ -394,10 +396,10 @@ export class SupermemoryClient { } private handleError(error: unknown): never { - // Handle request timeout (AbortSignal.timeout or explicit abort) if ( - error instanceof Error && - (error.name === "AbortError" || error.name === "TimeoutError") + error instanceof APIConnectionTimeoutError || + (error instanceof Error && + (error.name === "AbortError" || error.name === "TimeoutError")) ) { throw new Error("Request to Supermemory API timed out") } diff --git a/apps/mcp/src/constants.ts b/apps/mcp/src/constants.ts new file mode 100644 index 000000000..5bd658ef2 --- /dev/null +++ b/apps/mcp/src/constants.ts @@ -0,0 +1 @@ +export const FETCH_TIMEOUT_MS = 30_000 diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 457319826..13c466f72 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -2,6 +2,7 @@ import { cors } from "hono/cors" import { Hono, type Context } from "hono" import { SupermemoryMCP } from "./server" import { isApiKey, validateApiKey, validateOAuthToken } from "./auth" +import { FETCH_TIMEOUT_MS } from "./constants" import { initPosthog } from "./posthog" import type { ContentfulStatusCode } from "hono/utils/http-status" @@ -89,7 +90,7 @@ app.get("/.well-known/oauth-authorization-server", async (c) => { // Fetch the authorization server metadata from the main API const response = await fetch( `${apiUrl}/.well-known/oauth-authorization-server`, - { signal: AbortSignal.timeout(30_000) }, + { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, ) if (!response.ok) { @@ -136,22 +137,32 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => { }) } - let authUser: { - userId: string - apiKey: string - email?: string - name?: string - } | null = null - - if (isApiKey(token)) { - console.log("Authenticating with API key") - authUser = await validateApiKey(token, apiUrl) - } else { - console.log("Authenticating with OAuth token") - authUser = await validateOAuthToken(token, apiUrl) + let authResult = isApiKey(token) + ? await validateApiKey(token, apiUrl) + : await validateOAuthToken(token, apiUrl) + + if (authResult.status === "timeout") { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Authentication service timed out. Please try again.", + }, + id: null, + }), + { + status: 504, + headers: { + "Content-Type": "application/json", + "Access-Control-Expose-Headers": "WWW-Authenticate", + "Access-Control-Allow-Origin": "*", + }, + }, + ) } - if (!authUser) { + if (authResult.status !== "success") { const errorMessage = isApiKey(token) ? "Unauthorized: Invalid or expired API key" : "Unauthorized: Invalid or expired token" @@ -177,6 +188,8 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => { ) } + const authUser = authResult.user + // Create execution context with authenticated user props const ctx = { ...c.executionCtx,