diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts index 1ef04d32..7915ec26 100644 --- a/apps/mcp/e2e/graph.test.ts +++ b/apps/mcp/e2e/graph.test.ts @@ -31,18 +31,16 @@ describeWithAuth("MCP — graph, resources & prompts", () => { expect(result.data.documentCount).toBe(result.data.documents.length) }) - it("fetch-graph-data returns paginated documents", async () => { - const res = await callTool(s.client, "fetch-graph-data", { - page: 1, - limit: 5, - }) + it("fetch-graph-data returns cursor-paginated documents", async () => { + const res = await callTool(s.client, "fetch-graph-data", { limit: 5 }) expect(res.isError).toBeFalsy() const sc = res.structuredContent as { documents?: unknown[] - pagination?: { limit?: number } + nextCursor?: string | null } expect(Array.isArray(sc?.documents)).toBe(true) - expect(sc?.pagination?.limit).toBe(5) + expect(sc?.documents?.length).toBeLessThanOrEqual(5) + expect(sc).toHaveProperty("nextCursor") }) it("reads the profile resource", async () => { diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 91574985..6974f26e 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "supermemory-mcp", - "version": "1.0.1", + "version": "1.0.2", "type": "module", "portless": { "name": "mcp.dev.supermemory", diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 664fa93b..f6be58f3 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -7,10 +7,11 @@ import type { SearchResponseResultsItem, } from "supermemory" import { - documentsApiResponseSchema, type DocumentMemoryEntry, - type DocumentsApiResponse, type DocumentWithMemories, + type GraphApiPage, + type GraphDocumentsPage, + graphApiPageSchema, type MemoriesList, type MemoryEntry, type MemoryEntryHistory, @@ -22,6 +23,7 @@ export const DEFAULT_NAMESPACE = "sm_project_default" const FETCH_TIMEOUT_MS = 30_000 const MCP_SOURCE = "supermemory-mcp" const NAMESPACE_PAGE_LIMIT = 100 +export const GRAPH_PAGE_LIMIT = 100 // v4 defaults; v5 lowered the threshold to 0.3. const SEARCH_THRESHOLD = 0.6 // Analytics matches on this text to tag quota errors, keep them in sync. @@ -31,7 +33,7 @@ export const OUT_OF_CREDITS_MESSAGE = export type { DocumentMemoryEntry, DocumentWithMemories, - DocumentsApiResponse, + GraphDocumentsPage, NamespaceInfo, } @@ -171,6 +173,66 @@ export interface NamespaceSettings { updatedAt: string } +// Folds the v5 node/edge graph back into documents with nested memories, mirroring console-v2's use-graph-api. +function toGraphDocuments(page: GraphApiPage): DocumentWithMemories[] { + const memories = new Map< + string, + Extract + >() + for (const node of page.nodes) { + if (node.kind === "memory") memories.set(node.id, node) + } + const sources = new Map() + const relations = new Map< + string, + NonNullable + >() + for (const edge of page.edges) { + if (edge.type === "source") { + sources.set(edge.source, [ + ...(sources.get(edge.source) ?? []), + edge.target, + ]) + continue + } + relations.set(edge.target, { + ...relations.get(edge.target), + [edge.source]: edge.type, + }) + } + return page.nodes.flatMap((node) => { + if (node.kind !== "document") return [] + return { + id: node.id, + title: node.title, + summary: node.summary, + type: node.type, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + memoryEntries: (sources.get(node.id) ?? []).flatMap((memoryId) => { + const mem = memories.get(memoryId) + if (!mem) return [] + return { + id: mem.id, + memory: mem.memory, + spaceId: "", + isStatic: mem.isStatic, + isLatest: mem.isLatest, + isForgotten: mem.isForgotten, + forgetAfter: mem.forgetAfter, + forgetReason: mem.forgetReason, + version: mem.version, + parentMemoryId: mem.parentMemoryId, + rootMemoryId: mem.rootMemoryId, + memoryRelations: relations.get(mem.id) ?? {}, + createdAt: mem.createdAt, + updatedAt: mem.updatedAt, + } + }), + } + }) +} + const isNotFound = (error: unknown): boolean => { const e = error as { statusCode?: number; status?: number } return e?.statusCode === 404 || e?.status === 404 @@ -315,28 +377,27 @@ export class SupermemoryClient { } } - // v5 has no graph route; this stays on the legacy endpoint until one ships. + // The namespace graph feed is a hidden v5 route, so it isn't in the SDK. async getGraphDocuments( - page = 1, - limit = 200, + cursor?: string, + limit = GRAPH_PAGE_LIMIT, options?: { signal?: AbortSignal }, - ): Promise { + ): Promise { try { const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) - const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { + const url = new URL( + `${this.apiUrl}/ns/${encodeURIComponent(this.namespace)}/graph`, + ) + url.searchParams.set("limit", String(limit)) + if (cursor) url.searchParams.set("cursor", cursor) + const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${this.bearerToken}`, "Content-Type": "application/json", "x-sm-source": MCP_SOURCE, }, - body: JSON.stringify({ - page, - limit, - sort: "createdAt", - order: "desc", - containerTags: [this.namespace], - }), + body: "{}", signal, }) if (!response.ok) { @@ -345,7 +406,8 @@ export class SupermemoryClient { status: response.status, }) } - return documentsApiResponseSchema.parse(await response.json()) + const page = graphApiPageSchema.parse(await response.json()) + return { documents: toGraphDocuments(page), nextCursor: page.nextCursor } } catch (error) { this.handleError(error) } diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index b3056903..63ae37a1 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -55,7 +55,7 @@ app.use( app.get("/", (c) => { return c.json({ name: "supermemory-mcp", - version: "1.0.1", + version: "1.0.2", description: "Supermemory MCP - AI memory for teams", docs: "https://supermemory.ai/docs/supermemory-mcp/mcp", }) diff --git a/apps/mcp/src/server/server.ts b/apps/mcp/src/server/server.ts index 5fdd992e..9c55e2a4 100644 --- a/apps/mcp/src/server/server.ts +++ b/apps/mcp/src/server/server.ts @@ -83,7 +83,7 @@ export function createSupermemoryServer( const server = new McpServer( { name: "supermemory", - version: "1.0.1", + version: "1.0.2", }, { instructions: SERVER_INSTRUCTIONS }, ) diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index cfdc7da6..69e5a3de 100644 --- a/apps/mcp/src/server/tools/fetch-graph-data.ts +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -1,5 +1,6 @@ import { z } from "zod" -import { documentsApiResponseSchema } from "../../shared/types" +import { graphDocumentsPageSchema } from "../../shared/types" +import { GRAPH_PAGE_LIMIT } from "../client" import { appToolMeta } from "../app-metadata" import { optionalNamespaceSchema } from "../namespace" import { legacyNamespaceInput, namespaceArg } from "./compat" @@ -14,10 +15,16 @@ export function register(deps: ToolDeps) { inputSchema: z.object({ namespace: optionalNamespaceSchema, ...legacyNamespaceInput, - page: z.number().int().min(1).max(10_000).optional().default(1), - limit: z.number().int().min(1).max(1_000).optional().default(200), + cursor: z.string().min(1).max(2048).optional(), + limit: z + .number() + .int() + .min(1) + .max(GRAPH_PAGE_LIMIT) + .optional() + .default(GRAPH_PAGE_LIMIT), }), - outputSchema: documentsApiResponseSchema, + outputSchema: graphDocumentsPageSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, _meta: appToolMeta(["app"]), }, @@ -25,7 +32,7 @@ export function register(deps: ToolDeps) { try { const namespace = await deps.resolveNamespace(namespaceArg(args)) const client = deps.getClient(namespace) - const data = await client.getGraphDocuments(args.page, args.limit) + const data = await client.getGraphDocuments(args.cursor, args.limit) return { content: [ diff --git a/apps/mcp/src/server/tools/memory-graph.ts b/apps/mcp/src/server/tools/memory-graph.ts index 73cc2b2d..31ccf469 100644 --- a/apps/mcp/src/server/tools/memory-graph.ts +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -1,11 +1,17 @@ import { z } from "zod" -import { graphViewSchema, type ViewMessage } from "../../shared/types" +import { + type DocumentWithMemories, + graphViewSchema, + type ViewMessage, +} from "../../shared/types" import { appResultMeta, appToolMeta } from "../app-metadata" import { optionalNamespaceSchema } from "../namespace" import { legacyNamespaceInput, namespaceArg, withLegacyView } from "./compat" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" import { textContent, type ToolDeps } from "./types" +const GRAPH_DOCUMENT_LIMIT = 200 + export function register(deps: ToolDeps) { const inputSchema = z.object({ namespace: optionalNamespaceSchema, @@ -28,8 +34,14 @@ export function register(deps: ToolDeps) { const viewId = crypto.randomUUID() const namespace = await deps.resolveNamespace(namespaceArg(args)) const client = deps.getClient(namespace) - const result = await client.getGraphDocuments(1, 200) - const memoryCount = result.documents.reduce( + const documents: DocumentWithMemories[] = [] + let cursor: string | undefined + do { + const page = await client.getGraphDocuments(cursor) + documents.push(...page.documents) + cursor = page.nextCursor ?? undefined + } while (cursor && documents.length < GRAPH_DOCUMENT_LIMIT) + const memoryCount = documents.reduce( (sum, document) => sum + document.memoryEntries.length, 0, ) @@ -37,18 +49,18 @@ export function register(deps: ToolDeps) { view: "graph", viewId, namespace, - documents: result.documents, - totalCount: result.pagination.totalItems, - documentCount: result.documents.length, + documents, + totalCount: documents.length, + documentCount: documents.length, memoryCount, - totalDocumentCount: result.pagination.totalItems, - truncated: result.documents.length < result.pagination.totalItems, + totalDocumentCount: documents.length, + truncated: Boolean(cursor), rendered: true, }) return { content: [ textContent( - `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories. Space: ${namespace}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`, + `Rendered the interactive Memory Graph MCP App: ${documents.length} documents, ${memoryCount} memories. Space: ${namespace}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`, ), ], structuredContent: sc, diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index a1834f72..80a453cd 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -122,12 +122,53 @@ export const paginationSchema = z.object({ totalPages: z.number().int().nonnegative(), }) -export const documentsApiResponseSchema = z.object({ +export const graphDocumentsPageSchema = z.object({ documents: z.array(documentWithMemoriesSchema), - pagination: paginationSchema, + nextCursor: z.string().nullable(), }) -export type DocumentsApiResponse = z.infer +export type GraphDocumentsPage = z.infer + +const graphApiNodeSchema = z.discriminatedUnion("kind", [ + z.looseObject({ + kind: z.literal("document"), + id: z.string(), + title: z.string().nullable(), + summary: z.string().nullable(), + type: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + }), + z.looseObject({ + kind: z.literal("memory"), + id: z.string(), + memory: z.string(), + isStatic: z.boolean(), + isLatest: z.boolean(), + isForgotten: z.boolean(), + forgetAfter: z.string().nullable(), + forgetReason: z.string().nullable(), + version: z.number(), + parentMemoryId: z.string().nullable(), + rootMemoryId: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }), +]) + +export const graphApiPageSchema = z.object({ + nodes: z.array(graphApiNodeSchema), + edges: z.array( + z.looseObject({ + type: z.union([z.literal("source"), memoryRelationSchema]), + source: z.string(), + target: z.string(), + }), + ), + nextCursor: z.string().nullable(), +}) + +export type GraphApiPage = z.infer // Shared by the client and the list_memories output schema so the two can't drift. export const memoryEntryHistorySchema = z.object({