mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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>
This commit is contained in:
parent
6cae175852
commit
f051af098e
9 changed files with 70 additions and 14 deletions
|
|
@ -12,8 +12,8 @@ export function register(deps: ToolDeps) {
|
||||||
description: "Fetch documents with memories for graph display",
|
description: "Fetch documents with memories for graph display",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
containerTag: optionalContainerTagSchema,
|
containerTag: optionalContainerTagSchema,
|
||||||
page: z.number().optional().default(1),
|
page: z.number().int().min(1).max(10_000).optional().default(1),
|
||||||
limit: z.number().optional().default(200),
|
limit: z.number().int().min(1).max(1_000).optional().default(200),
|
||||||
}),
|
}),
|
||||||
outputSchema: documentsApiResponseSchema,
|
outputSchema: documentsApiResponseSchema,
|
||||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "./output-schemas"
|
} from "./output-schemas"
|
||||||
import { textContent, type ToolDeps } from "./types"
|
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) {
|
export function register(deps: ToolDeps) {
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
documentId: z
|
documentId: z
|
||||||
|
|
@ -28,8 +29,17 @@ export function register(deps: ToolDeps) {
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
try {
|
try {
|
||||||
|
const effectiveTag = await deps.resolveContainerTag()
|
||||||
const client = deps.getClient()
|
const client = deps.getClient()
|
||||||
const document = await client.getDocument(args.documentId)
|
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 { content, truncated } = getDocumentContent(document)
|
||||||
const structuredContent: GetDocumentOutput = {
|
const structuredContent: GetDocumentOutput = {
|
||||||
document: {
|
document: {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ export function register(deps: ToolDeps) {
|
||||||
description:
|
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.",
|
"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({
|
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,
|
outputSchema: saveViewSchema,
|
||||||
_meta: appToolMeta(),
|
_meta: appToolMeta(),
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,6 @@ export const whoAmIOutputSchema = z.object({
|
||||||
version: z.string().optional(),
|
version: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
sessionId: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type WhoAmIOutput = z.infer<typeof whoAmIOutputSchema>
|
export type WhoAmIOutput = z.infer<typeof whoAmIOutputSchema>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export function register(deps: ToolDeps) {
|
||||||
deps.getActiveContainerTag(),
|
deps.getActiveContainerTag(),
|
||||||
])
|
])
|
||||||
const client = deps.getClientInfo(context)
|
const client = deps.getClientInfo(context)
|
||||||
const sessionId = context.sessionId
|
|
||||||
const structuredContent: WhoAmIOutput = {
|
const structuredContent: WhoAmIOutput = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
...(session.user.email ? { email: session.user.email } : {}),
|
...(session.user.email ? { email: session.user.email } : {}),
|
||||||
|
|
@ -34,7 +33,6 @@ export function register(deps: ToolDeps) {
|
||||||
: null,
|
: null,
|
||||||
...(session.scope ? { scope: session.scope } : {}),
|
...(session.scope ? { scope: session.scope } : {}),
|
||||||
...(client ? { client } : {}),
|
...(client ? { client } : {}),
|
||||||
...(sessionId ? { sessionId } : {}),
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
content: [textContent(JSON.stringify(structuredContent))],
|
content: [textContent(JSON.stringify(structuredContent))],
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,18 @@ const extractContent = (memory: SearchResult) => {
|
||||||
return "No content available"
|
return "No content available"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadata.url comes from ingested content, so only http(s) reaches the OS opener.
|
||||||
const extractUrl = (memory: SearchResult) => {
|
const extractUrl = (memory: SearchResult) => {
|
||||||
if (memory.metadata?.url && typeof memory.metadata.url === "string") {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ type AddMemoryInput = {
|
||||||
memory: string
|
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
|
* Create Supermemory tools for AI SDK
|
||||||
*/
|
*/
|
||||||
|
|
@ -30,6 +37,8 @@ export function supermemoryTools(
|
||||||
) {
|
) {
|
||||||
const client = new Supermemory({
|
const client = new Supermemory({
|
||||||
apiKey,
|
apiKey,
|
||||||
|
timeout: 30_000,
|
||||||
|
maxRetries: 2,
|
||||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -54,8 +63,10 @@ export function supermemoryTools(
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
limit: {
|
limit: {
|
||||||
type: "number",
|
type: "integer",
|
||||||
description: "Maximum number of results to return",
|
minimum: 1,
|
||||||
|
maximum: 50,
|
||||||
|
description: "Maximum number of results to return (1-50)",
|
||||||
default: 10,
|
default: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -67,10 +78,11 @@ export function supermemoryTools(
|
||||||
limit = 10,
|
limit = 10,
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
|
const safeLimit = clampSearchLimit(limit)
|
||||||
const response = await client.search.execute({
|
const response = await client.search.execute({
|
||||||
q: informationToGet,
|
q: informationToGet,
|
||||||
containerTags,
|
containerTags,
|
||||||
limit,
|
limit: safeLimit,
|
||||||
chunkThreshold: 0.6,
|
chunkThreshold: 0.6,
|
||||||
includeFullDocs,
|
includeFullDocs,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from "bun:test"
|
import { describe, expect, it } from "bun:test"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
import {
|
import {
|
||||||
|
BulkDeleteMemoriesSchema,
|
||||||
DocumentsWithMemoriesQuerySchema,
|
DocumentsWithMemoriesQuerySchema,
|
||||||
ListMemoriesQuerySchema,
|
ListMemoriesQuerySchema,
|
||||||
SearchRequestSchema,
|
SearchRequestSchema,
|
||||||
|
|
@ -151,4 +152,26 @@ describe("pagination query schemas", () => {
|
||||||
expect(parsed.page).toBe(2)
|
expect(parsed.page).toBe(2)
|
||||||
expect(parsed.limit).toBe(50)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1102,8 +1102,8 @@ export const DocumentsWithMemoriesQuerySchema = z
|
||||||
description: "Page number to fetch",
|
description: "Page number to fetch",
|
||||||
example: 1,
|
example: 1,
|
||||||
}),
|
}),
|
||||||
limit: z.number().int().min(1).default(10).openapi({
|
limit: z.number().int().min(1).max(1000).default(10).openapi({
|
||||||
description: "Number of items per page",
|
description: "Number of items per page (max 1000)",
|
||||||
example: 10,
|
example: 10,
|
||||||
}),
|
}),
|
||||||
sort: z.enum(["createdAt", "updatedAt"]).default("createdAt").openapi({
|
sort: z.enum(["createdAt", "updatedAt"]).default("createdAt").openapi({
|
||||||
|
|
@ -1409,12 +1409,13 @@ export const BulkDeleteMemoriesSchema = z
|
||||||
example: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
|
example: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
|
||||||
}),
|
}),
|
||||||
containerTags: z
|
containerTags: z
|
||||||
.array(z.string())
|
.array(z.string().max(256))
|
||||||
.min(1)
|
.min(1)
|
||||||
|
.max(100)
|
||||||
.optional()
|
.optional()
|
||||||
.openapi({
|
.openapi({
|
||||||
description:
|
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"],
|
example: ["user_123", "project_123"],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue