fix(mcp): clarify memory graph pagination

This commit is contained in:
cat0825 2026-06-01 22:20:01 +08:00
parent 2684990688
commit c3fe0a27ad
6 changed files with 277 additions and 35 deletions

View file

@ -16,6 +16,8 @@
<div id="stats"></div>
<button type="button" id="load-more-btn" hidden>Load more</button>
<div id="popup">
<span id="popup-type"></span>
<div id="popup-title"></div>

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest"
import {
GRAPH_PAGE_LIMIT,
createMemoryGraphStructuredContent,
summarizeMemoryGraphResult,
} from "./memory-graph-data"
import type { DocumentsApiResponse } from "./client"
const baseDocument = {
id: "doc-1",
title: "Doc",
summary: null,
type: "text",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
memoryEntries: [
{
id: "mem-1",
memory: "first memory",
isStatic: true,
spaceId: "space-1",
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
rootMemoryId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
}
function documentsResponse(
overrides: Partial<DocumentsApiResponse["pagination"]>,
): DocumentsApiResponse {
return {
documents: [baseDocument],
pagination: {
currentPage: 1,
limit: GRAPH_PAGE_LIMIT,
totalItems: 23,
totalPages: 3,
...overrides,
},
}
}
describe("memory graph data", () => {
it("preserves pagination metadata in structured content", () => {
const response = documentsResponse({})
expect(createMemoryGraphStructuredContent(response, "project-a")).toEqual({
containerTag: "project-a",
documents: response.documents,
totalCount: 23,
loadedCount: 1,
pagination: response.pagination,
})
})
it("summarizes loaded versus total document counts", () => {
expect(summarizeMemoryGraphResult(documentsResponse({}), "project-a")).toBe(
"Memory Graph: showing 1 of 23 documents, 1 loaded memories. More documents available (3 pages total). Project: project-a",
)
expect(
summarizeMemoryGraphResult(
documentsResponse({ totalItems: 1, totalPages: 1 }),
),
).toBe("Memory Graph: showing 1 of 1 documents, 1 loaded memories")
})
})

View file

@ -0,0 +1,48 @@
import type { DocumentWithMemories, DocumentsApiResponse } from "./client"
export const GRAPH_PAGE_LIMIT = 10
export interface MemoryGraphStructuredContent {
containerTag?: string
documents: DocumentWithMemories[]
totalCount: number
loadedCount: number
pagination: DocumentsApiResponse["pagination"]
}
export function createMemoryGraphStructuredContent(
result: DocumentsApiResponse,
containerTag?: string,
): MemoryGraphStructuredContent {
return {
containerTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
loadedCount: result.documents.length,
pagination: result.pagination,
}
}
export function summarizeMemoryGraphResult(
result: DocumentsApiResponse,
containerTag?: string,
): string {
let memoryCount = 0
for (const document of result.documents) {
memoryCount += document.memoryEntries.length
}
const textParts = [
`Memory Graph: showing ${result.documents.length} of ${result.pagination.totalItems} documents, ${memoryCount} loaded memories`,
]
if (result.pagination.totalPages > result.pagination.currentPage) {
textParts.push(
`More documents available (${result.pagination.totalPages} pages total)`,
)
}
if (containerTag) {
textParts.push(`Project: ${containerTag}`)
}
return textParts.join(". ")
}

View file

@ -6,6 +6,11 @@ import {
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
import { SupermemoryClient, getMemoryText } from "./client"
import {
GRAPH_PAGE_LIMIT,
createMemoryGraphStructuredContent,
summarizeMemoryGraphResult,
} from "./memory-graph-data"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
@ -318,26 +323,23 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
? [effectiveContainerTag]
: undefined
const result = await client.getDocuments(containerTags, 1, 10)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
const result = await client.getDocuments(
containerTags,
1,
GRAPH_PAGE_LIMIT,
)
const textParts = [
`Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`,
]
if (effectiveContainerTag) {
textParts.push(`Project: ${effectiveContainerTag}`)
}
return {
content: [{ type: "text" as const, text: textParts.join(". ") }],
structuredContent: {
containerTag: effectiveContainerTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
},
content: [
{
type: "text" as const,
text: summarizeMemoryGraphResult(result, effectiveContainerTag),
},
],
structuredContent: createMemoryGraphStructuredContent(
result,
effectiveContainerTag,
),
}
} catch (error) {
const message =
@ -366,7 +368,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
inputSchema: z.object({
containerTag: z.string().optional(),
page: z.number().optional().default(1),
limit: z.number().optional().default(10),
limit: z.number().optional().default(GRAPH_PAGE_LIMIT),
}),
_meta: {
ui: {
@ -711,7 +713,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: containerTag || this.props?.containerTag,
containerTag: effectiveContainerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))

View file

@ -57,6 +57,32 @@
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
#load-more-btn {
position: fixed;
top: 12px;
right: 12px;
z-index: 10;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
#load-more-btn:hover:not(:disabled) {
opacity: 0.85;
}
#load-more-btn:disabled {
cursor: wait;
opacity: 0.7;
}
/* Popup */
#popup {
display: none;

View file

@ -55,6 +55,13 @@ interface ToolResultData {
containerTag?: string
documents: GraphApiDocument[]
totalCount: number
loadedCount?: number
pagination?: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
interface MemoryNode extends NodeObject {
@ -129,6 +136,8 @@ const CLUSTER_SPREAD = 120
let isDark = true
let selectedNode: GraphNode | null = null
let hoveredNode: GraphNode | null = null
let activeToolData: ToolResultData | null = null
let isLoadingMore = false
// =============================================================================
// DOM References (elements are guaranteed to exist in mcp-app.html)
@ -155,6 +164,9 @@ const zoomInBtn = document.getElementById("zoom-in")!
const zoomOutBtn = document.getElementById("zoom-out")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const fitBtn = document.getElementById("fit-btn")!
const loadMoreBtn = document.getElementById(
"load-more-btn",
) as HTMLButtonElement
// =============================================================================
// Helpers
@ -857,6 +869,99 @@ prefersDark.addEventListener("change", (e) =>
applyTheme(e.matches ? "dark" : "light"),
)
function updateLoadMore(data: ToolResultData) {
const pagination = data.pagination
const loadedCount = data.loadedCount ?? data.documents.length
const hasMore = pagination
? pagination.currentPage < pagination.totalPages
: loadedCount < data.totalCount
loadMoreBtn.hidden = !hasMore
loadMoreBtn.disabled = isLoadingMore
if (hasMore) {
loadMoreBtn.textContent = isLoadingMore
? "Loading..."
: `Load more (${loadedCount}/${data.totalCount})`
}
}
function renderGraphData(data: ToolResultData) {
const { nodes, links } = transformData(data)
const memCount = nodes.filter((n) => n.nodeType === "memory").length
const docCount = nodes.filter((n) => n.nodeType === "document").length
const loadedCount = data.loadedCount ?? data.documents.length
const totalCount = data.totalCount
const countLabel =
totalCount > loadedCount
? `${docCount}/${totalCount} docs`
: `${docCount} docs`
statsEl.textContent = `${countLabel} · ${memCount} memories · ${links.length} connections`
// Update legend counts
const docCountEl = document.getElementById("legend-doc-count")
const memCountEl = document.getElementById("legend-mem-count")
if (docCountEl) docCountEl.textContent = String(docCount)
if (memCountEl) memCountEl.textContent = String(memCount)
graph.graphData({ nodes, links })
updateLoadMore(data)
// Fit to view after layout stabilizes
setTimeout(() => graph.zoomToFit(400, 40), 600)
}
async function loadNextPage() {
const data = activeToolData
const pagination = data?.pagination
if (!data || !pagination || pagination.currentPage >= pagination.totalPages) {
return
}
isLoadingMore = true
updateLoadMore(data)
try {
const result = await app.callServerTool({
name: "fetch-graph-data",
arguments: {
containerTag: data.containerTag,
page: pagination.currentPage + 1,
limit: pagination.limit,
},
})
if (result.isError) {
statsEl.textContent = "Error loading more graph data"
return
}
const nextData = result.structuredContent as unknown as {
documents?: GraphApiDocument[]
pagination?: ToolResultData["pagination"]
}
if (!nextData?.documents || !nextData.pagination) {
statsEl.textContent = "No additional graph data available"
return
}
activeToolData = {
...data,
documents: [...data.documents, ...nextData.documents],
loadedCount: data.documents.length + nextData.documents.length,
pagination: nextData.pagination,
totalCount: nextData.pagination.totalItems,
}
renderGraphData(activeToolData)
} catch (error) {
statsEl.textContent = "Error loading more graph data"
console.error(error)
} finally {
isLoadingMore = false
if (activeToolData) updateLoadMore(activeToolData)
}
}
loadMoreBtn.addEventListener("click", () => {
void loadNextPage()
})
// =============================================================================
// MCP App SDK
// =============================================================================
@ -881,22 +986,8 @@ app.ontoolresult = (result: CallToolResult) => {
return
}
const { nodes, links } = transformData(data)
const memCount = nodes.filter((n) => n.nodeType === "memory").length
const docCount = nodes.filter((n) => n.nodeType === "document").length
statsEl.textContent = `${docCount} docs \u00b7 ${memCount} memories \u00b7 ${links.length} connections`
// Update legend counts
const docCountEl = document.getElementById("legend-doc-count")
const memCountEl = document.getElementById("legend-mem-count")
if (docCountEl) docCountEl.textContent = String(docCount)
if (memCountEl) memCountEl.textContent = String(memCount)
graph.graphData({ nodes, links })
// Fit to view after layout stabilizes
setTimeout(() => graph.zoomToFit(400, 40), 600)
activeToolData = data
renderGraphData(data)
}
app.ontoolcancelled = () => {