diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index 9c1345a8f..d86fe12e2 100644 --- a/apps/mcp/src/server/tools/fetch-graph-data.ts +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -12,8 +12,8 @@ export function register(deps: ToolDeps) { description: "Fetch documents with memories for graph display", inputSchema: z.object({ containerTag: optionalContainerTagSchema, - page: z.number().optional().default(1), - limit: z.number().optional().default(200), + page: z.number().int().min(1).max(10_000).optional().default(1), + limit: z.number().int().min(1).max(1_000).optional().default(200), }), outputSchema: documentsApiResponseSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, diff --git a/apps/mcp/src/server/tools/get-document.ts b/apps/mcp/src/server/tools/get-document.ts index 01535c732..ab5f85652 100644 --- a/apps/mcp/src/server/tools/get-document.ts +++ b/apps/mcp/src/server/tools/get-document.ts @@ -7,6 +7,7 @@ import { } from "./output-schemas" import { textContent, type ToolDeps } from "./types" +// An out-of-space document reports "not found" on purpose, so the id is not an existence oracle. export function register(deps: ToolDeps) { const inputSchema = z.object({ documentId: z @@ -28,8 +29,17 @@ export function register(deps: ToolDeps) { }, async (args) => { try { + const effectiveTag = await deps.resolveContainerTag() const client = deps.getClient() const document = await client.getDocument(args.documentId) + const docTags = document.containerTags + if ( + Array.isArray(docTags) && + docTags.length > 0 && + !docTags.includes(effectiveTag) + ) { + throw new Error("Document not found") + } const { content, truncated } = getDocumentContent(document) const structuredContent: GetDocumentOutput = { document: { diff --git a/apps/mcp/src/server/tools/guided-save.ts b/apps/mcp/src/server/tools/guided-save.ts index aac1e7ea0..c3f3c0425 100644 --- a/apps/mcp/src/server/tools/guided-save.ts +++ b/apps/mcp/src/server/tools/guided-save.ts @@ -13,7 +13,11 @@ export function register(deps: ToolDeps) { description: "Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.", inputSchema: z.object({ - prefill: z.string().optional().describe("Optional content to prefill"), + prefill: z + .string() + .max(200000, "Prefill exceeds maximum length") + .optional() + .describe("Optional content to prefill"), }), outputSchema: saveViewSchema, _meta: appToolMeta(), diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 6cb0bf66d..2ea8bb3b9 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -122,7 +122,6 @@ export const whoAmIOutputSchema = z.object({ version: z.string().optional(), }) .optional(), - sessionId: z.string().optional(), }) export type WhoAmIOutput = z.infer diff --git a/apps/mcp/src/server/tools/who-am-i.ts b/apps/mcp/src/server/tools/who-am-i.ts index 629d1a092..83d33040b 100644 --- a/apps/mcp/src/server/tools/who-am-i.ts +++ b/apps/mcp/src/server/tools/who-am-i.ts @@ -20,7 +20,6 @@ export function register(deps: ToolDeps) { deps.getActiveContainerTag(), ]) const client = deps.getClientInfo(context) - const sessionId = context.sessionId const structuredContent: WhoAmIOutput = { userId: session.user.id, ...(session.user.email ? { email: session.user.email } : {}), @@ -34,7 +33,6 @@ export function register(deps: ToolDeps) { : null, ...(session.scope ? { scope: session.scope } : {}), ...(client ? { client } : {}), - ...(sessionId ? { sessionId } : {}), } return { content: [textContent(JSON.stringify(structuredContent))], diff --git a/apps/raycast-extension/src/search-memories.tsx b/apps/raycast-extension/src/search-memories.tsx index dbc47ceb7..db6c1f279 100644 --- a/apps/raycast-extension/src/search-memories.tsx +++ b/apps/raycast-extension/src/search-memories.tsx @@ -40,9 +40,18 @@ const extractContent = (memory: SearchResult) => { return "No content available" } +// metadata.url comes from ingested content, so only http(s) reaches the OS opener. const extractUrl = (memory: SearchResult) => { if (memory.metadata?.url && typeof memory.metadata.url === "string") { - return memory.metadata.url + const url = memory.metadata.url + try { + const parsed = new URL(url) + if (parsed.protocol === "https:" || parsed.protocol === "http:") { + return url + } + } catch { + return null + } } return null } diff --git a/bun.lock b/bun.lock index d55c8237e..f7c898691 100644 --- a/bun.lock +++ b/bun.lock @@ -336,7 +336,7 @@ }, "packages/tools": { "name": "@supermemory/tools", - "version": "2.1.1", + "version": "2.2.0", "dependencies": { "@ai-sdk/anthropic": "^2.0.25", "@ai-sdk/openai": "^2.0.23", diff --git a/packages/ai-sdk/src/tools.ts b/packages/ai-sdk/src/tools.ts index 7d0b837cb..82ea39b17 100644 --- a/packages/ai-sdk/src/tools.ts +++ b/packages/ai-sdk/src/tools.ts @@ -21,6 +21,13 @@ type AddMemoryInput = { memory: string } +// The schema constrains well-behaved models; a prompt-injected one can still send anything. +function clampSearchLimit(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 10 + return Math.min(50, Math.max(1, Math.floor(parsed))) +} + /** * Create Supermemory tools for AI SDK */ @@ -30,6 +37,8 @@ export function supermemoryTools( ) { const client = new Supermemory({ apiKey, + timeout: 30_000, + maxRetries: 2, ...(config?.baseUrl ? { baseURL: config.baseUrl } : {}), }) @@ -54,8 +63,10 @@ export function supermemoryTools( default: true, }, limit: { - type: "number", - description: "Maximum number of results to return", + type: "integer", + minimum: 1, + maximum: 50, + description: "Maximum number of results to return (1-50)", default: 10, }, }, @@ -67,10 +78,11 @@ export function supermemoryTools( limit = 10, }) => { try { + const safeLimit = clampSearchLimit(limit) const response = await client.search.execute({ q: informationToGet, containerTags, - limit, + limit: safeLimit, chunkThreshold: 0.6, includeFullDocs, }) diff --git a/packages/tools/package.json b/packages/tools/package.json index 59289f3d9..6df7f9a93 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.1.1", + "version": "2.2.0", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index f8d88154f..40290baf2 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -372,4 +372,10 @@ export function supermemoryTools( } } -export { withSupermemory } from "./vercel" +// `./vercel` is not a published subpath, so this is the only way consumers reach the middleware types. +export { + withSupermemory, + type WithSupermemoryOptions, + type PromptTemplate, + type MemoryPromptData, +} from "./vercel" diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 8923b652c..b436e078d 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -1,4 +1,5 @@ import type OpenAI from "openai" +import { validateApiKey } from "../shared" import { createOpenAIMiddleware, type OpenAIMiddlewareOptions, @@ -21,6 +22,7 @@ import { * @param options.verbose - Optional flag to enable detailed logging of memory search and injection process (default: false) * @param options.mode - Optional mode for memory search: "profile" (default), "query", or "full" * @param options.addMemory - Optional mode for memory addition: "always" (default), "never" + * @param options.apiKey - Optional Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable * * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs * @@ -56,16 +58,14 @@ import { * }) * ``` * - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set + * @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set * @throws {Error} When supermemory API request fails */ export function withSupermemory( openaiClient: OpenAI, options: OpenAIMiddlewareOptions, ) { - if (!process.env.SUPERMEMORY_API_KEY) { - throw new Error("SUPERMEMORY_API_KEY is not set") - } + validateApiKey(options.apiKey) if (!options.containerTag) { throw new Error( diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index c9b8b4b88..5ac504029 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,6 +1,7 @@ import type OpenAI from "openai" import Supermemory from "supermemory" import { addConversation } from "../conversations-client" +import { validateApiKey } from "../shared" import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" @@ -20,6 +21,7 @@ export interface OpenAIMiddlewareOptions { mode?: "profile" | "query" | "full" addMemory?: "always" | "never" baseUrl?: string + apiKey?: string } interface SupermemoryProfileSearch { @@ -75,22 +77,25 @@ const getLastUserMessage = ( * * @param containerTag - The container tag/identifier for memory search (e.g., user ID, project ID) * @param queryText - Optional query text to search for specific memories. If empty, returns all profile memories + * @param baseUrl - The Supermemory API base URL + * @param apiKey - The Supermemory API key used to authenticate the request * @returns Promise that resolves to the SuperMemory profile search response * @throws {Error} When the API request fails or returns an error status * * @example * ```typescript * // Search with query - * const results = await supermemoryProfileSearch("user-123", "favorite programming language") + * const results = await supermemoryProfileSearch("user-123", "favorite programming language", baseUrl, apiKey) * * // Get all profile memories - * const profile = await supermemoryProfileSearch("user-123", "") + * const profile = await supermemoryProfileSearch("user-123", "", baseUrl, apiKey) * ``` */ const supermemoryProfileSearch = async ( containerTag: string, queryText: string, baseUrl: string, + apiKey: string, ): Promise => { const payload = queryText ? JSON.stringify({ @@ -106,7 +111,7 @@ const supermemoryProfileSearch = async ( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + Authorization: `Bearer ${apiKey}`, }, body: payload, }) @@ -138,6 +143,8 @@ const supermemoryProfileSearch = async ( * @param containerTag - The container tag/identifier for memory search * @param logger - Logger instance for debugging and info output * @param mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) + * @param baseUrl - The Supermemory API base URL + * @param apiKey - The Supermemory API key used to authenticate the request * @returns Promise that resolves to enhanced messages with memory-injected system prompt * * @example @@ -150,7 +157,9 @@ const supermemoryProfileSearch = async ( * messages, * "user-123", * logger, - * "full" + * "full", + * baseUrl, + * apiKey * ) * // Returns messages with system prompt containing relevant memories * ``` @@ -161,6 +170,7 @@ const addSystemPrompt = async ( logger: Logger, mode: "profile" | "query" | "full", baseUrl: string, + apiKey: string, ) => { const systemPromptExists = messages.some((msg) => msg.role === "system") @@ -170,6 +180,7 @@ const addSystemPrompt = async ( containerTag, queryText, baseUrl, + apiKey, ) const memoryCountStatic = memoriesResponse.profile.static?.length || 0 @@ -400,8 +411,9 @@ const addMemoryTool = async ( * @param options.verbose - Enable detailed logging of memory operations (default: false) * @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile") * @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always") + * @param options.apiKey - Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable * @returns Object with `wrapClient` and `createClient` methods - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set + * @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set * * @example * ```typescript @@ -421,8 +433,9 @@ export function createOpenAIMiddleware( ) { const logger = createLogger(options?.verbose ?? false) const baseUrl = normalizeBaseUrl(options?.baseUrl) + const apiKey = validateApiKey(options?.apiKey) const client = new Supermemory({ - apiKey: process.env.SUPERMEMORY_API_KEY, + apiKey, ...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}), }) @@ -457,6 +470,7 @@ export function createOpenAIMiddleware( containerTag, queryText, baseUrl, + apiKey, ) const memoryCountStatic = memoriesResponse.profile.static?.length || 0 @@ -615,7 +629,7 @@ export function createOpenAIMiddleware( memoryCustomId, logger, messages, - process.env.SUPERMEMORY_API_KEY, + apiKey, baseUrl, ), ) @@ -623,7 +637,7 @@ export function createOpenAIMiddleware( } operations.push( - addSystemPrompt(messages, containerTag, logger, mode, baseUrl), + addSystemPrompt(messages, containerTag, logger, mode, baseUrl, apiKey), ) const results = await Promise.all(operations) diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 4695c9205..1ce23cb63 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -552,6 +552,14 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool ] } +function parseToolArguments(argumentsJson: string) { + try { + return { success: true as const, value: JSON.parse(argumentsJson) } + } catch { + return { success: false as const } + } +} + /** * Execute a tool call based on the function name and arguments */ @@ -565,7 +573,14 @@ export function createToolCallExecutor( toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall, ): Promise { const functionName = toolCall.function.name - const args = JSON.parse(toolCall.function.arguments) + const parsed = parseToolArguments(toolCall.function.arguments) + if (!parsed.success) { + return JSON.stringify({ + success: false, + error: `Invalid JSON arguments for ${functionName}`, + }) + } + const args = parsed.value switch (functionName) { case "searchMemories": diff --git a/packages/tools/src/voltagent/hooks.ts b/packages/tools/src/voltagent/hooks.ts index 87c788315..027ca0021 100644 --- a/packages/tools/src/voltagent/hooks.ts +++ b/packages/tools/src/voltagent/hooks.ts @@ -129,11 +129,7 @@ export function createSupermemoryHooks( return } - saveConversation(messages, ctx).catch((error) => { - ctx.logger.error("Background conversation save failed", { - error: error instanceof Error ? error.message : "Unknown error", - }) - }) + await saveConversation(messages, ctx) } catch (error) { ctx.logger.error("Error in onEnd", { error: error instanceof Error ? error.message : "Unknown error", diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index bf7717265..85b903648 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -448,7 +448,7 @@ const convertToConversationMessages = ( } /** - * Saves conversation to Supermemory (fire-and-forget). + * Saves conversation to Supermemory. */ export const saveConversation = async ( messages: VoltAgentMessage[], diff --git a/packages/validation/api.test.ts b/packages/validation/api.test.ts index e186af88f..fa52ed890 100644 --- a/packages/validation/api.test.ts +++ b/packages/validation/api.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test" import { readFileSync } from "node:fs" import { + BulkDeleteMemoriesSchema, DocumentsWithMemoriesQuerySchema, ListMemoriesQuerySchema, SearchRequestSchema, @@ -151,4 +152,26 @@ describe("pagination query schemas", () => { expect(parsed.page).toBe(2) expect(parsed.limit).toBe(50) }) + + it("DocumentsWithMemoriesQuerySchema caps limit at 1000", () => { + expect( + DocumentsWithMemoriesQuerySchema.safeParse({ limit: 1001 }).success, + ).toBe(false) + expect( + DocumentsWithMemoriesQuerySchema.safeParse({ limit: 200 }).success, + ).toBe(true) + }) + + it("BulkDeleteMemoriesSchema caps containerTags at 100 entries of bounded length", () => { + const tooMany = { + containerTags: Array.from({ length: 101 }, (_, i) => `tag_${i}`), + } + expect(BulkDeleteMemoriesSchema.safeParse(tooMany).success).toBe(false) + + const tagTooLong = { containerTags: ["x".repeat(257)] } + expect(BulkDeleteMemoriesSchema.safeParse(tagTooLong).success).toBe(false) + + const ok = { containerTags: ["tag_a", "tag_b"] } + expect(BulkDeleteMemoriesSchema.safeParse(ok).success).toBe(true) + }) }) diff --git a/packages/validation/api.ts b/packages/validation/api.ts index f066bfcd4..ae6ac3191 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -1102,8 +1102,8 @@ export const DocumentsWithMemoriesQuerySchema = z description: "Page number to fetch", example: 1, }), - limit: z.number().int().min(1).default(10).openapi({ - description: "Number of items per page", + limit: z.number().int().min(1).max(1000).default(10).openapi({ + description: "Number of items per page (max 1000)", example: 10, }), sort: z.enum(["createdAt", "updatedAt"]).default("createdAt").openapi({ @@ -1409,12 +1409,13 @@ export const BulkDeleteMemoriesSchema = z example: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"], }), containerTags: z - .array(z.string()) + .array(z.string().max(256)) .min(1) + .max(100) .optional() .openapi({ description: - "Array of container tags - all memories in these containers will be deleted", + "Array of container tags - all memories in these containers will be deleted (max 100 at once)", example: ["user_123", "project_123"], }), })