mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-11-12 08:41:45 +00:00
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:
parent
72b053a4a4
commit
16ed414e0e
8 changed files with 163 additions and 43 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "supermemory-mcp",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"portless": {
|
||||
"name": "mcp.dev.supermemory",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function createSupermemoryServer(
|
|||
const server = new McpServer(
|
||||
{
|
||||
name: "supermemory",
|
||||
version: "1.0.1",
|
||||
version: "1.0.2",
|
||||
},
|
||||
{ instructions: SERVER_INSTRUCTIONS },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue