From f051af098ecef15f792e776c9c67da30d3b8c9ed Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:36:04 +0000 Subject: [PATCH] fix(mcp): bound tool inputs and scope get_document to the active space (#1593) Cherry-picks #1582, #1583, #1584 and #1585 from @Sravanjangam (security audit #1578) onto one branch. - MCP: `get_document` scopes to the active space like its sibling read tools, `fetch-graph-data` bounds page/limit, `guided-save` caps prefill at 200k, and `whoAmI` no longer returns the transport session id. - ai-sdk: search limit clamped to 1-50 with a 30s client timeout. - validation: caps on `DocumentsWithMemoriesQuerySchema.limit` and `BulkDeleteMemoriesSchema.containerTags`. - Raycast: `metadata.url` is parsed and only http(s) is offered to the OS opener. Dropped his `add_memory` permission gate: it checked the target against the list of existing spaces, so writes to a new space failed and the no-active-space path surfaced `No write access to space "undefined"`. Write permission stays enforced in the API via `containerTagGate`. Hardening and consistency rather than a security fix, since the API already enforces every permission boundary here. Co-Authored-By: Sravanjangam <163002695+Sravanjangam@users.noreply.github.com> --- apps/mcp/src/server/tools/fetch-graph-data.ts | 4 ++-- apps/mcp/src/server/tools/get-document.ts | 10 ++++++++ apps/mcp/src/server/tools/guided-save.ts | 6 ++++- apps/mcp/src/server/tools/output-schemas.ts | 1 - apps/mcp/src/server/tools/who-am-i.ts | 2 -- .../raycast-extension/src/search-memories.tsx | 11 ++++++++- packages/ai-sdk/src/tools.ts | 18 ++++++++++++--- packages/validation/api.test.ts | 23 +++++++++++++++++++ packages/validation/api.ts | 9 ++++---- 9 files changed, 70 insertions(+), 14 deletions(-) diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index 9c1345a8..d86fe12e 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 01535c73..ab5f8565 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 aac1e7ea..c3f3c042 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 6cb0bf66..2ea8bb3b 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 629d1a09..83d33040 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 dbc47ceb..db6c1f27 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/packages/ai-sdk/src/tools.ts b/packages/ai-sdk/src/tools.ts index 7d0b837c..82ea39b1 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/validation/api.test.ts b/packages/validation/api.test.ts index e186af88..fa52ed89 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 f066bfcd..ae6ac319 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"], }), })