feat(mcp): read the memory graph from the v5 namespace graph feed (#1871)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Mahesh Sanikommu 2026-11-02 15:05:32 -08:00 • committed by GitHub
parent 72b053a4a4
commit 16ed414e0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 163 additions and 43 deletions

View file

@ -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 () => {

View file

@ -1,6 +1,6 @@
{
"name": "supermemory-mcp",
"version": "1.0.1",
"version": "1.0.2",
"type": "module",
"portless": {
"name": "mcp.dev.supermemory",

View file

@ -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<GraphApiPage["nodes"][number], { kind: "memory" }>
>()
for (const node of page.nodes) {
if (node.kind === "memory") memories.set(node.id, node)
}
const sources = new Map<string, string[]>()
const relations = new Map<
string,
NonNullable<DocumentMemoryEntry["memoryRelations"]>
>()
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<DocumentsApiResponse> {
): Promise<GraphDocumentsPage> {
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)
}

View file

@ -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",
})

View file

@ -83,7 +83,7 @@ export function createSupermemoryServer(
const server = new McpServer(
{
name: "supermemory",
version: "1.0.1",
version: "1.0.2",
},
{ instructions: SERVER_INSTRUCTIONS },
)

View file

@ -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: [

View file

@ -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,

View file

@ -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<typeof documentsApiResponseSchema>
export type GraphDocumentsPage = z.infer<typeof graphDocumentsPageSchema>
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<typeof graphApiPageSchema>
// Shared by the client and the list_memories output schema so the two can't drift.
export const memoryEntryHistorySchema = z.object({