supermemory/apps/mcp/src/client.ts
MaheshtheDev 4aa044fe55 fix(mcp): respect readable scope for unscoped recall (#1357)
Unscoped recall forced sm_project_default even when the caller could only read another organization space, causing a misleading 403 while list and graph operations succeeded. Let the search API choose the caller's readable scope, skip profile enrichment when no concrete scope is selected, and retain upstream error details.

Validated with Biome, Vite production build, and Wrangler deploy dry-run.
2026-07-24 19:01:51 +00:00

494 lines
12 KiB
TypeScript

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<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
}
export type Memory =
| ({
id: string
memory: string
similarity: number
title?: string
content?: string
} & MemoryRichFields)
| ({
id: string
chunk: string
similarity: number
title?: string
content?: string
} & MemoryRichFields)
export interface SearchResult {
results: Memory[]
total: number
timing: number
}
export interface SearchOptions {
searchMode?: "memories" | "hybrid" | "documents"
rerank?: boolean
rewriteQuery?: boolean
include?: {
documents?: boolean
relatedMemories?: boolean
summaries?: boolean
chunks?: boolean
forgottenMemories?: boolean
}
}
export interface Profile {
static: string[]
dynamic: string[]
}
export interface ProfileResponse {
profile: Profile
searchResults?: SearchResult
}
export interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental: boolean
documentCount?: number
}
// Documents API types
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
}
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export function getMemoryText(m: Memory): string {
return "memory" in m ? m.memory : m.chunk
}
function limitByChars(text: string, maxChars = MAX_CHARS): string {
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
// Type for SDK search result item
interface SDKResult {
id: string
memory?: string
chunk?: string
content?: string
similarity: number
title?: string
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
}
export class SupermemoryClient {
private client: Supermemory
private containerTag: string
private hasExplicitContainerTag: boolean
private bearerToken: string
private apiUrl: string
constructor(
bearerToken: string,
containerTag?: string,
apiUrl = "https://api.supermemory.ai",
) {
this.bearerToken = bearerToken
this.apiUrl = apiUrl
this.client = new Supermemory({
apiKey: bearerToken,
baseURL: apiUrl,
timeout: FETCH_TIMEOUT_MS,
})
this.hasExplicitContainerTag = Boolean(containerTag)
this.containerTag = containerTag || DEFAULT_PROJECT_ID
}
// Create memory using SDK
async createMemory(
content: string,
): Promise<{ id: string; status: string; containerTag: string }> {
try {
const result = await this.client.add({
content,
containerTag: this.containerTag,
metadata: {
sm_source: "mcp",
},
})
return {
id: result.id,
status: "queued",
containerTag: this.containerTag,
}
} catch (error) {
this.handleOperationError("Create memory request", error)
}
}
// Delete/forget memory - try exact match first, then semantic search
async forgetMemory(
content: string,
): Promise<{ success: boolean; message: string; containerTag: string }> {
try {
// Try exact content matching first
try {
const result = await this.client.memories.forget({
content: content,
containerTag: this.containerTag,
})
return {
success: true,
message: `Successfully forgot memory (exact match) with ID: ${result.id}`,
containerTag: this.containerTag,
}
} catch (error: unknown) {
// If not 404, it's a real error - re-throw it
const status =
error && typeof error === "object" && "status" in error
? (error as Record<string, unknown>).status
: undefined
if (status !== 404) {
throw error
}
// Otherwise continue to semantic search fallback
}
// Fallback to semantic search if exact match fails
const SIMILARITY_THRESHOLD = 0.85 // High threshold - only very similar memories
const searchResult = await this.search(
content,
5,
SIMILARITY_THRESHOLD,
undefined,
this.containerTag,
)
if (searchResult.results.length === 0) {
return {
success: false,
message: `No matching memory found to forget. Tried exact match and semantic search with similarity threshold ${SIMILARITY_THRESHOLD}.`,
containerTag: this.containerTag,
}
}
// Only actual memories (not chunks) can be forgotten
const memoryToDelete = searchResult.results.find((r) => "memory" in r)
if (!memoryToDelete) {
return {
success: false,
message:
"No matching memory found to forget (only document chunks matched in semantic search).",
containerTag: this.containerTag,
}
}
// Delete using the ID from semantic search
await this.client.memories.forget({
id: memoryToDelete.id,
containerTag: this.containerTag,
})
const memoryText =
getMemoryText(memoryToDelete) || memoryToDelete.content || ""
return {
success: true,
message: `Forgot similar memory (semantic match, similarity: ${memoryToDelete.similarity.toFixed(2)}): "${limitByChars(memoryText, 100)}"`,
containerTag: this.containerTag,
}
} catch (error) {
this.handleOperationError("Forget memory request", error)
}
}
// Search memories using SDK
async search(
query: string,
limit = 10,
threshold?: number,
options?: SearchOptions,
containerTagOverride?: string,
): Promise<SearchResult> {
try {
const containerTag =
containerTagOverride ??
(this.hasExplicitContainerTag ? this.containerTag : undefined)
const result = await this.client.search.memories({
q: query,
limit,
...(containerTag ? { containerTag } : {}),
searchMode: options?.searchMode ?? "hybrid",
threshold, // Optional threshold parameter
rerank: options?.rerank,
rewriteQuery: options?.rewriteQuery,
include: options?.include,
})
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
metadata: r.metadata,
updatedAt: r.updatedAt,
context: r.context,
documents: r.documents,
isAggregated: r.isAggregated,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
return { ...base, memory: text }
})
return {
results,
total: result.total,
timing: result.timing,
}
} catch (error) {
this.handleOperationError("Search request", error)
}
}
// Get user profile using SDK
async getProfile(query?: string): Promise<ProfileResponse> {
if (!this.hasExplicitContainerTag) {
return {
profile: {
static: [],
dynamic: [],
},
}
}
try {
const result = await this.client.profile({
containerTag: this.containerTag,
q: query,
})
const response: ProfileResponse = {
profile: {
static: result.profile?.static || [],
dynamic: result.profile?.dynamic || [],
},
}
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
return { ...base, memory: text }
}),
total: result.searchResults.total,
timing: result.searchResults.timing,
}
}
return response
} catch (error) {
this.handleOperationError("Profile request", error)
}
}
// Get projects list
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) {
if (response.status === 401) {
throw new Error("Authentication failed. Please re-authenticate.")
}
throw new Error(`Failed to fetch projects: ${response.statusText}`)
}
const data = (await response.json()) as {
projects: Project[]
}
return data.projects?.map((p) => p.containerTag) || []
} catch (error) {
this.handleError(error)
}
}
// Fetch documents with their memory entries
async getDocuments(
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: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
page,
limit,
sort: "createdAt",
order: "desc",
containerTags,
}),
signal,
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch documents"), {
status: response.status,
})
}
return (await response.json()) as DocumentsApiResponse
} catch (error) {
this.handleError(error)
}
}
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 (
error.message.includes("fetch") ||
error.message.includes("network")
) {
throw new Error(
"Network error. Please check your connection and try again.",
)
}
}
// Handle HTTP status errors from SDK/fetch
if (error && typeof error === "object" && "status" in error) {
const status = (error as { status: number }).status
const message =
"message" in error ? (error as { message: string }).message : undefined
switch (status) {
case 400:
case 422:
throw new Error(
message || "Invalid request parameters. Please check your input.",
)
case 401:
throw new Error("Authentication failed. Please re-authenticate.")
case 402:
throw new Error("Memory limit reached. Upgrade at supermemory.ai")
case 403:
throw new Error(
message ||
"Access forbidden. Your account may be restricted or blocked.",
)
case 404:
throw new Error("Memory not found. It may have been deleted.")
case 429:
throw new Error(
"Rate limit exceeded. Please wait a moment and try again.",
)
default:
if (status >= 500) {
throw new Error(
"Server error. The service may be temporarily unavailable. Please try again later.",
)
}
}
}
// Re-throw Error instances as-is
if (error instanceof Error) {
throw error
}
// Wrap unknown errors
throw new Error(`An unexpected error occurred: ${String(error)}`)
}
private handleOperationError(operation: string, error: unknown): never {
try {
this.handleError(error)
} catch (handledError) {
const message =
handledError instanceof Error
? handledError.message
: String(handledError)
throw new Error(`${operation} failed: ${message}`)
}
}
}