Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 46 additions & 16 deletions apps/mcp/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@
* 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
email?: string
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_")
*/
Expand All @@ -25,13 +39,14 @@ export function isApiKey(token: string): boolean {
export async function validateApiKey(
apiKey: string,
apiUrl: string,
): Promise<AuthUser | null> {
): Promise<AuthValidationResult> {
try {
const sessionResponse = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})

if (!sessionResponse.ok) {
Expand All @@ -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 {
Expand All @@ -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" }
}
}

Expand All @@ -96,13 +118,14 @@ export async function validateApiKey(
export async function validateOAuthToken(
token: string,
apiUrl: string,
): Promise<AuthUser | null> {
): Promise<AuthValidationResult> {
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) {
Expand All @@ -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 {
Expand All @@ -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" }
}
}
21 changes: 19 additions & 2 deletions apps/mcp/src/client.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -328,14 +332,16 @@ export class SupermemoryClient {
}

// Get projects list
async getProjects(): Promise<string[]> {
async getProjects(options?: { signal?: AbortSignal }): Promise<string[]> {
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) {
Expand All @@ -359,8 +365,10 @@ export class SupermemoryClient {
containerTags?: string[],
page = 1,
limit = 10,
options?: { signal?: AbortSignal },
): Promise<DocumentsApiResponse> {
try {
const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS)
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
method: "POST",
headers: {
Expand All @@ -374,6 +382,7 @@ export class SupermemoryClient {
order: "desc",
containerTags,
}),
signal,
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch documents"), {
Expand All @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions apps/mcp/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const FETCH_TIMEOUT_MS = 30_000
42 changes: 28 additions & 14 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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"

Expand Down Expand Up @@ -89,6 +90,7 @@
// 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) {
Expand Down Expand Up @@ -135,22 +137,32 @@
})
}

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)

Check warning on line 140 in apps/mcp/src/index.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

lint/style/useConst

This let declares a variable that is only assigned once.
? 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"
Expand All @@ -176,6 +188,8 @@
)
}

const authUser = authResult.user

// Create execution context with authenticated user props
const ctx = {
...c.executionCtx,
Expand Down
Loading