diff --git a/apps/mcp/src/auth.ts b/apps/mcp/src/auth.ts index 7bd3b9464..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,13 +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(FETCH_TIMEOUT_MS), }) if (!sessionResponse.ok) { @@ -127,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 { @@ -143,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 ee35fcf1a..ccb08503c 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -1,4 +1,6 @@ -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" @@ -142,6 +144,8 @@ export class SupermemoryClient { this.client = new Supermemory({ apiKey: bearerToken, baseURL: apiUrl, + timeout: FETCH_TIMEOUT_MS, + maxRetries: 0, }) this.containerTag = containerTag || DEFAULT_PROJECT_ID } @@ -328,14 +332,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 +365,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 +382,7 @@ export class SupermemoryClient { order: "desc", containerTags, }), + signal, }) if (!response.ok) { throw Object.assign(new Error("Failed to fetch documents"), { @@ -387,6 +396,14 @@ export class SupermemoryClient { } private handleError(error: unknown): never { + if ( + error instanceof APIConnectionTimeoutError || + (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/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 846064e1f..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,6 +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(FETCH_TIMEOUT_MS) }, ) if (!response.ok) { @@ -135,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" @@ -176,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,