跳到主要内容

MCP 服务器:把记忆装进任意 AI 助手

30 秒导读: 这是 Supermemory 三条分发路径里最主流的一条——把「远端记忆」包装成一个标准 MCP(Model Context Protocol)服务器,任何支持 MCP 的助手(Claude Desktop、Cursor、Windsurf……)填一个 URL 就能用上 memory/recall 等工具。本章讲 apps/mcp 这个 Cloudflare Worker:它怎么部署、怎么鉴权、注册了哪些工具、每个会话的状态放在哪、以及几处防坑设计。数据形态(文档/记忆/画像/项目到底是什么)见 01-data-model;「一行 withSupermemory 挂中间件」那条另一条分发路径见 03-framework-middleware


1. 这是什么(零基础也能懂)

一句话定义: apps/mcp 是一个独立部署的 MCP 服务器,它把 Supermemory 的记忆能力,变成 AI 助手能直接调用的一组「工具」。

先说清 MCP 是什么。MCP(模型上下文协议)是 AI 助手和外部能力之间的标准插座——助手是插头,任何遵守 MCP 的服务是插座。一旦某个能力包成 MCP 服务器,Claude Desktop、Cursor、Windsurf 这些客户端不改代码就能接上它。

那这个服务器给助手插上了什么?长期记忆。 大模型天生没有跨对话的记忆,聊完就忘。接上这个服务器后,助手多出几个动作:

助手能做的事对应工具白话
记住用户说的话memory(save)"记一下:我用 TypeScript,偏好深色模式"
忘掉过时信息memory(forget)"把我住在北京这条删了"
回忆相关记忆recall"关于我的编程偏好,你记得什么?"
注入用户画像context(prompt)开场就把用户的稳定偏好塞进系统提示
看记忆关系图memory-graph弹出一张力导向图

用起来什么样。 用户根本不写代码,只在 MCP 客户端配置里填一个 URL:

{
"mcpServers": {
"supermemory": { "url": "https://mcp.supermemory.ai/mcp" }
}
}

之后助手在对话里自动调 memory/recall。首次连接会走 OAuth 让用户登录授权;想跳过 OAuth 就直接塞一个 sm_ 开头的 API key 到 Authorization 头(README apps/mcp/README.md:27-56)。

一句话直觉。 把这个服务器想成记忆能力的「转接头」:一头是标准 MCP 插口(什么助手都能插),另一头接的是 Supermemory 的云端 API。转接头本身几乎不存数据,它的活是鉴权 + 把 MCP 的工具调用翻译成对 Supermemory API 的调用


2. 顶层全景(它大概怎么转)

这一节讲:一次请求从客户端进来,到底路过了哪些部件。

先看整体拓扑。这个服务器夹在「MCP 客户端」和「Supermemory 主 API」中间,自己不碰数据库:

┌──────────────┐ MCP over HTTP ┌──────────────────────────────┐
│ MCP 客户端 │ (JSON-RPC/SSE) │ MCP Worker (mcp.supermemory │
│ Claude/Cursor │ ─────────────────► │ .ai) — Cloudflare Workers │
└──────────────┘ │ │
▲ │ ┌────────────────────────┐ │
│ 401 + WWW-Authenticate │ │ Hono 路由 (index.ts) │ │
│ (发现鉴权服务器) │ │ · / · /.well-known/* │ │
│ │ │ · /mcp (鉴权后转交) │ │
│ │ └───────────┬────────────┘ │
│ │ ▼ │
│ │ ┌────────────────────────┐ │
│ │ │ Durable Object 每会话一 │ │
│ │ │ 个 (SupermemoryMCP) │ │
│ │ │ · 注册工具/资源/prompt │ │
│ │ │ · 缓存 clientInfo/tags │ │
│ │ └───────────┬────────────┘ │
└────────────────────────────┼─────────────┼───────────────┘
│ │ SupermemoryClient
令牌自省(换 apiKey) ▼ ▼ (SDK + fetch)
┌──────────────────────────────────┐
│ Supermemory 主 API │
│ /v3/session · /v3/mcp/session- │
│ with-key · /v3/search · … │
└──────────────────────────────────┘

怎么读这张图: 请求从左上进,先被 Hono 路由拦住做鉴权(向主 API「自省」令牌),换到真实 apiKey 后才转交给 Durable Object;DO 里注册好的工具再通过 SupermemoryClient 回调主 API 完成实际读写。

各部件一句话职责:

部件干什么在哪个文件
Hono appHTTP 入口、CORS、.well-known 鉴权发现、把 /mcp 请求做完鉴权再转交apps/mcp/src/index.ts
isApiKey/validateApiKey/validateOAuthToken双模「令牌自省」:判断令牌类型、向主 API 换回真实用户与 apiKeyapps/mcp/src/auth.ts
SupermemoryMCP(DO)每会话一个实例;注册所有工具/资源/prompt;缓存会话态apps/mcp/src/server.ts
SupermemoryClient薄封装:调 Supermemory SDK 与几个裸 fetch,统一错误映射apps/mcp/src/client.ts
formatMemories把搜索结果排版成给模型读的紧凑文本apps/mcp/src/format.ts
posthog埋点(memory_added / memory_search / memory_forgot)apps/mcp/src/posthog.ts

主线走一遍(高层): 客户端发 tools/call memory → Hono /mcp 处理器取 Authorization 头 → 按前缀分流到 API-key 或 OAuth 自省 → 拿到 {userId, apiKey} 塞进执行上下文的 props → 转交给该会话的 DO → DO 里的 handleMemoryprops.apiKey 构造 SupermemoryClient → 调主 API 落库 → 返回一句 Saved memory (id: …)


3. 部署形态:Workers + 每会话一个 Durable Object

这一节讲:这个服务器「活在哪」,以及为什么每个连接要单独一个对象实例。

运行时是 Cloudflare Workers,状态载体是 Durable Objects(DO)。 wrangler.jsonc 把主入口指向 src/index.ts,并把类 SupermemoryMCP 声明成一个带 SQLite 存储的 Durable Object 绑定:

// apps/mcp/wrangler.jsonc:23-37 —— DO 绑定 + SQLite 迁移
"durable_objects": {
"bindings": [{ "name": "MCP_SERVER", "class_name": "SupermemoryMCP" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SupermemoryMCP"] }]

为什么用 DO 而不是普通无状态 Worker? 因为 MCP 是有会话的协议:一次连接里,客户端先握手(initialize)、再列工具、再多次调用,中间要保持同一份会话状态(比如客户端叫什么、是什么版本)。普通 Worker 每个请求都是全新实例,存不住这个。DO 的特性正好补上:同一个会话 id 永远路由到同一个对象实例,于是可以在实例里挂缓存、往它自带的存储里写东西。

SupermemoryMCP 继承自 McpAgent(来自 agents/mcp,即 Cloudflare 的 Agents SDK),它把「DO 生命周期」和「MCP 协议」缝在了一起:

// apps/mcp/src/server.ts:32-40 —— 一个会话 = 一个 McpAgent 子类实例
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
private containerTagsLastFetchedAt: number | null = null

server = new McpServer({ name: "supermemory", version: "4.0.0" })

这三个私有字段就是每会话缓存:客户端信息、该用户的项目标签列表、标签上次拉取时间。它们随 DO 实例存活,不必每次请求都重算。

握手时抓取并持久化 clientInfo MCP 协议在 initialize 阶段会带上客户端的名字/版本。init() 里挂了个回调,在协议初始化完成时把它取出来,既存进内存字段、也落进 DO 的持久存储:

// apps/mcp/src/server.ts:54-63 —— 握手完成即缓存客户端身份(内存 + DO 存储双写)
this.server.server.oninitialized = async () => {
const clientVersion = this.server.server.getClientVersion()
if (clientVersion) {
this.clientInfo = { name: clientVersion.name, version: clientVersion.version }
await this.ctx.storage.put("clientInfo", this.clientInfo)
}
}

双写的意义:内存字段服务当前请求;DO 存储(ctx.storage,即那块 SQLite)保证下一次冷启动该 DO 时,init() 开头能把 clientInfo 读回来(server.ts:42-49)。这份身份信息后面全程用于埋点(区分是哪个客户端在调用)和 whoAmI

会话 id 从哪来?就是 DO 实例的名字:

// apps/mcp/src/server.ts:747-749 —— 会话 id = DO 实例名
private getMcpSessionId(): string {
return this.ctx.id.name || "unknown"
}

4. 请求入口与鉴权发现(Hono 路由)

这一节讲:HTTP 层怎么组织,以及 MCP 客户端如何「发现」该去哪登录。

入口是一个 Hono app(index.ts:23)。它先开全放的 CORS,并特意暴露 Mcp-Session-IdWWW-Authenticate 两个响应头(index.ts:36-52)——后者是 OAuth 发现的关键,浏览器默认不让 JS 读它,必须显式 expose。

路由分三类:

路由作用
GET /返回服务器名/版本/文档链接的自我介绍 JSON(index.ts:59-66)
GET /.well-known/oauth-protected-resource[/mcp]告诉客户端「我是受保护资源,去哪个授权服务器登录」
GET /.well-known/oauth-authorization-server代理:把主 API 的授权服务器元数据透传回来
ALL /mcp/mcp/*真正的 MCP 端点,鉴权后转交 DO

鉴权发现遵循 OAuth「受保护资源」规范。 当客户端不带令牌来敲 /mcp,服务器不是简单拒绝,而是回一个 401 + WWW-Authenticate,里面用 resource_metadata 指向自己的 .well-known 地址:

// apps/mcp/src/index.ts:127-136 —— 无令牌:401 质询,告诉客户端去哪发现鉴权
if (!token) {
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
})
}

客户端顺着这个地址拿到 oauth-protected-resource 文档,里面写明真正的授权服务器是主 API(authorization_servers: [apiUrl])、支持哪些 scope(index.ts:69-80)。

为什么还要代理 oauth-authorization-server? 因为规范落地参差:有些客户端不去读 authorization_servers 数组,而是直接在 MCP 服务器域名上oauth-authorization-server。为了兼容这些「不完全守规矩」的客户端,服务器把这个路径也接住,转手去主 API 拉真实元数据再透传回去:

// apps/mcp/src/index.ts:82-107 —— 兼容不守规范的客户端:代理授权服务器元数据
app.get("/.well-known/oauth-authorization-server", async (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const response = await fetch(`${apiUrl}/.well-known/oauth-authorization-server`)
// ……失败透传状态码,成功则原样 c.json(metadata)
})

MCP 端点如何转交。 mcpHandler = SupermemoryMCP.serve("/mcp", …)(index.ts:109-117)是 Agents SDK 生成的 DO 转发器。但它前面套了一层 handleMcpRequest:先取 Authorization 头(剥掉 Bearer )和 x-sm-project 头,做完鉴权,再把结果塞进执行上下文的 props 后调 mcpHandler.fetch:

// apps/mcp/src/index.ts:180-191 —— 鉴权结果注入 props,再交给 DO
const ctx = {
...c.executionCtx,
props: {
userId: authUser.userId,
apiKey: authUser.apiKey, // ← 真实 apiKey,DO 全程用它
containerTag, // ← 来自 x-sm-project 头(可选)
email: authUser.email,
name: authUser.name,
} satisfies Props,
} as ExecutionContext & { props: Props }
return mcpHandler.fetch(c.req.raw, c.env, ctx)

这层「先鉴权、把 apiKey 放进 props」是整个设计的枢纽:DO 里所有工具从不自己碰令牌,只从 this.props.apiKey 拿早已换好的真实 key。


5. 双模鉴权:「令牌自省」换回真实 apiKey

这一节讲全章最巧的部分:一个 Authorization 头,两种令牌,统一换成对主 API 有效的 apiKey

核心难题。 MCP 客户端塞进 Authorization 的东西有两种可能:要么用户直接给了 Supermemory 的 API key,要么走 OAuth 拿到的是一个 OAuth 访问令牌。后者不能直接拿去调 Supermemory 的数据 API——它得先被主 API 兑换成真实 apiKey。服务器必须先分辨、再分别处理。

分辨靠前缀。 API key 一律以 sm_ 开头,一个字符串判断就够:

// apps/mcp/src/auth.ts:17-19 —— sm_ 前缀 = API key,否则按 OAuth 令牌处理
export function isApiKey(token: string): boolean {
return token.startsWith("sm_")
}

分流逻辑在入口(index.ts:145-151):isApiKey(token) 为真走 validateApiKey,否则走 validateOAuthToken。两条路都是向主 API 发一次「自省」请求,拿回统一的 AuthUser

两条路的差别在于打哪个端点、拿回什么:

API key 路OAuth 令牌路
触发条件sm_ 前缀其它任意令牌
自省端点GET /v3/sessionGET /v3/mcp/session-with-key
主 API 干什么校验 key,返回 user用 better-auth 验令牌,并顺带返回真实 apiKey
拿回的 apiKey就是传入的 key 本身响应里的 sessionData.apiKey
符号validateApiKey(auth.ts:25)validateOAuthToken(auth.ts:96)

API key 路:key 本身就是有效的,自省只为拿到用户身份,返回时 apiKey 原样带回:

// apps/mcp/src/auth.ts:62-85 —— /v3/session 校验并回填用户身份
const sessionData = await sessionResponse.json() as { user?: { id?: string; email?: string; name?: string } }
if (!sessionData?.user?.id) { /* 缺 id 视为失败 */ return null }
return {
userId: sessionData.user.id,
apiKey: apiKey, // ← 原样带回
email: sessionData.user.email,
name: sessionData.user.name,
}

OAuth 路:传入的是访问令牌,换回来的 apiKey 才是后面真正能调数据 API 的钥匙——这就是端点名 session-with-key 的含义:

// apps/mcp/src/auth.ts:133-156 —— OAuth 令牌换回真实 apiKey
const sessionData = await sessionResponse.json() as {
userId?: string; apiKey?: string; email?: string; name?: string
}
if (!sessionData?.userId || !sessionData?.apiKey) { /* 两者缺一即失败 */ return null }
return {
userId: sessionData.userId,
apiKey: sessionData.apiKey, // ← 主 API 兑换出来的真实 key
email: sessionData.email,
name: sessionData.name,
}

HTTP 状态码分支处理很细。 两个函数都对自省响应的非 2xx 做了分级日志:401=令牌无效/过期,403=用户被封,429=限流,≥500=上游故障,其它兜底(auth.ts:41-58auth.ts:112-129)。但对客户端只回两种结果:成功返回 AuthUser,任何失败一律返回 null——把「为什么失败」留在服务器日志里,不外泄。

失败后的对外响应。 入口拿到 null 后,按令牌类型给一句人类可读的错误,并再次带上 WWW-Authenticate(这次带 error="invalid_token"),让客户端知道该重新走 OAuth:

// apps/mcp/src/index.ts:153-176 —— 自省失败:401 + invalid_token,提示重新鉴权
if (!authUser) {
const errorMessage = isApiKey(token)
? "Unauthorized: Invalid or expired API key"
: "Unauthorized: Invalid or expired token"
return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: errorMessage }, id: null }), {
status: 401,
headers: { "WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`, /* … */ },
})
}

一句话总结这个模式: 服务器自己不验证令牌、不签发令牌,把信任完全委托给主 API(better-auth),自己只做「翻译官」——把两种令牌都自省成一个 {userId, apiKey},交给下游。这让 MCP 服务器保持无状态、无密钥,鉴权真相只有一个来源。


6. 工具与资源注册:模型能调用的动作面板

这一节讲:DO 的 init() 里到底给模型挂了哪些能力,以及每个能力背后调了什么。

所有注册都发生在 init()(server.ts:42-518)。总览:

名称类型作用背后调用
memorytoolsave/forget 一条记忆handleMemoryclient.createMemory / forgetMemory
recalltool混合搜索 + 画像拼装handleRecallclient.search + getProfile
listProjectstool列出可用项目(容器标签)缓存 / client.getProjects
whoAmItool当前登录用户信息props + clientInfo
memory-graphtool(带 UI)弹出力导向记忆图client.getDocuments
fetch-graph-datatool(仅 App 可见)图 UI 翻页取数client.getDocuments
supermemory://profileresource用户画像纯文本client.getProfile
supermemory://projectsresource项目列表 JSON缓存标签
contextprompt注入系统上下文client.getProfile
Memory Graph UIapp resource图的 HTML 载体打包进来的 mcp-app.html

6.1 memory:save / forget

handleMemory(server.ts:539-620)先算出有效容器标签——参数里的 containerTag 优先,否则回退到连接级的 props.containerTag——再据此构造 client。

save 分支直接 client.createMemory(content)(SDK add,固定打上 metadata.sm_source = "mcp",见 client.ts:151-170)。一个巧思:save 完若结果里的容器标签是缓存里没见过的新项目,就顺手刷新一次标签缓存,让后续的 listProjects 立刻能看到它:

// apps/mcp/src/server.ts:577-581 —— 新项目出现即刷新缓存
const result = await client.createMemory(content)
if (!this.cachedContainerTags.includes(result.containerTag)) {
await this.refreshContainerTags()
}

forget 分支的容错在 client 层(client.ts:173-240):先按内容精确匹配删;若返回 404,退化为语义搜索(相似度阈值 0.85)找最接近的一条,且只删真正的 memory、不删文档 chunk,再按其 id 删除。找不到就诚实回一句「没找到可删的记忆」。

6.2 recall:混合搜索 + 画像拼装

handleRecall(server.ts:622-727)是给模型「回忆」用的。它固定用 hybrid 搜索模式,并精细控制返回什么:

// apps/mcp/src/server.ts:634-643 —— hybrid 搜索,只要文档与相关记忆
const searchResult = await client.search(query, 10, undefined, {
searchMode: "hybrid",
include: {
documents: true, relatedMemories: true,
summaries: false, chunks: false, forgottenMemories: false,
},
})

然后按需在前面拼上用户画像(includeProfile 默认 true):稳定事实(static)+ 近期上下文(dynamic),再接 formatMemories 排版出的记忆列表(server.ts:645-681)。

输出有硬上限。 拼完的整段文本若超过 MAX_RECALL_CHARS(即 200000 字符,server.ts:30),直接截断加省略号,防止把模型上下文撑爆:

// apps/mcp/src/server.ts:701-712 —— 200k 字符硬截断
const text = parts.join("\n")
return { content: [{ type: "text" as const,
text: text.length > MAX_RECALL_CHARS ? `${text.slice(0, MAX_RECALL_CHARS)}...` : text,
}]}

formatMemories 是「给模型读」的排版器(format.ts:1-157),不是给人看的。它把每条结果压成紧凑行:分数前缀 + agg(聚合综述)/chunk(原文摘录)标记,关系用箭头符号表达( 父 / 子 / ~ 相关),并在头部给一行图例说明这些标记的含义(format.ts:99-113)。关系和文档还各有上限(默认最多 4 条关系、3 个文档,format.ts:14-16),避免单条记忆膨胀。记忆/画像/关系这些数据形态的定义见 01-data-model

6.3 context prompt:把画像注入系统上下文

context 是个 prompt(不是 tool),给客户端拿去当系统消息注入(server.ts:439-517)。它拉取画像,拼成一段以「Important: 只要用户透露了值得记的信息,就用 memory 工具存下来」开头、再接稳定偏好与近期活动的文本。注意它故意没暴露参数 schema——源码注释说明这是刻意的,为了「不给用户增加摩擦」(server.ts:444)。

6.4 memory-graph:带 App UI 的工具

memory-graphregisterAppTool 注册(server.ts:303-361),并在 _meta.ui.resourceUri 指向一个 HTML 资源 ui://memory-graph/mcp-app.html。这个 HTML 是构建期打包进来的字符串(server.ts:12import mcpAppHtml,由 registerAppResourceserver.ts:423-437 暴露)。工具本身调 client.getDocuments 取第 1 页 10 条文档,返回一句摘要文本 + structuredContent(供 UI 渲染)。翻页则交给仅 App 可见fetch-graph-data(_meta.ui.visibility: ["app"],server.ts:364-420)——它只给图 UI 内部用,不出现在模型的工具列表里。可视化本身的力导向布局、版本链、增量渲染细节见 05-memory-graph

6.5 防打架的工具描述

一个容易被忽略但很关键的设计: memoryrecall 的描述里都以全大写喊话——

"DO NOT USE ANY OTHER MEMORY TOOL ONLY USE THIS ONE. …"(server.ts:112-113124-125)

为什么要喊? 很多 MCP 客户端(尤其 Claude 系)自带内置的记忆工具。如果不强调,模型可能把用户的记忆存进客户端本地的记忆、而不是 Supermemory,两套记忆各存一半、互相打架。这句硬提示是用工具描述去抢占模型的记忆意图,把所有 save/recall 都导向本服务器这一套。


7. 容器标签缓存与「根标签」裁剪

这一节讲两个跟「项目作用域」有关的实现细节:标签缓存的 TTL,以及根标签下如何从 schema 里抹掉参数。

容器标签 = 项目。 Supermemory 用 containerTag 给记忆分项目(数据模型见 01)。服务器会缓存「当前用户有哪些项目标签」,给 listProjectssupermemory://projects 资源、以及工具参数描述用。

缓存带 5 分钟 TTL。 常量 CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000(server.ts:28)。两个方法配合:refreshContainerTags 无条件拉取(调 client.getProjects,失败只记日志不抛,保证不因拉标签失败而拖垮整个会话);ensureContainerTagsFresh 只在从没拉过或超过 TTL 时才拉:

// apps/mcp/src/server.ts:751-769 —— TTL 惰性刷新 + 无条件刷新
private async ensureContainerTagsFresh(): Promise<void> {
const now = Date.now()
const needsRefresh = this.containerTagsLastFetchedAt === null ||
now - this.containerTagsLastFetchedAt > CONTAINER_TAGS_TTL_MS
if (needsRefresh) await this.refreshContainerTags()
}
private async refreshContainerTags(): Promise<void> {
try {
const client = this.getClient()
this.cachedContainerTags = await client.getProjects()
this.containerTagsLastFetchedAt = Date.now()
} catch (error) { console.error("Failed to fetch container tags:", error) }
}

listProjects 工具还给了个 refresh: true 参数强制越过 TTL 立刻拉(server.ts:209-216)。缓存好的标签也会拼进 memory/recall 参数的 containerTag 描述里,直接告诉模型「可用项目有哪些」(getContainerTagDescription,server.ts:771-777)。

根标签(root containerTag)下的 schema 裁剪。 如果用户在连接时用 x-sm-project把整条连接钉死在某个项目上(这个头会经 props.containerTag 传进来),那再让模型每次填 containerTag 参数就是多余且可能出错的。于是 init() 里判断 hasRootContainerTag,为真时从工具 schema 里整个删掉 containerTag 字段:

// apps/mcp/src/server.ts:67-84 —— 有根标签就不给模型 containerTag 参数
const hasRootContainerTag = !!this.props?.containerTag
const containerTagField = {
containerTag: z.string().max(128, "…").describe(this.getContainerTagDescription()).optional(),
}
const memorySchema = z.object({
content: z.string().max(200000, "…").describe("…"),
action: z.enum(["save", "forget"]).optional().default("save"),
...(hasRootContainerTag ? {} : containerTagField), // ← 根标签下:展开空对象,字段消失
})

recallcontextmemory-graph 三个 schema 用了同一套三元展开(server.ts:92101298)。效果: 根标签连接下,模型看到的工具参数里根本没有 containerTag,想跨项目也跨不了;运行时统一用 props.containerTag 兜底(如 handleMemorycontainerTag || this.props?.containerTag,server.ts:545)。这是「用 schema 强制作用域隔离」的干净做法。


8. 埋点(PostHog)

posthog.ts 是个单例封装,三类事件——memoryAddedmemorySearchmemoryForgot(posthog.ts:36/66/95),每条都带上 userId、来源 mcp、客户端名/版本、会话 id 与容器标签。埋点在工具处理器里以 fire-and-forget 方式发出、.catch 吞掉错误,绝不阻塞主流程(如 server.ts:584-596)。没配 POSTHOG_API_KEYinitPosthog 直接空转(posthog.ts:14-25),整套埋点静默关闭。一句话:可观测但可选,失败不影响功能。


9. 巧妙之处(可带走的技术)

  • 鉴权外包 + 双模自省统一出口: 服务器不存、不签、不验密钥,把两种令牌都自省成一个 {userId, apiKey},信任只有主 API 一个来源(auth.ts 全,index.ts:145-151)。
  • apiKey 只从 props 流入 DO: 入口鉴权后把真实 key 塞进执行上下文 props,DO 里所有工具只读 this.props.apiKey,永不接触原始令牌(index.ts:180-191server.ts:527)。
  • 根标签下裁剪 schema: 用三元展开 ...(hasRootContainerTag ? {} : field) 在注册期就让越权参数「不存在」,而不是运行期校验(server.ts:67-102)。
  • 工具描述抢占记忆意图: 全大写「DO NOT USE ANY OTHER MEMORY TOOL」防止和客户端自带记忆工具打架(server.ts:112-125)。
  • forget 双层容错: 精确匹配→404 则语义搜索(阈值 0.85)→只删真 memory 不删 chunk(client.ts:173-240)。
  • new-project 即时刷缓存: save 出新项目就立刻更新标签缓存,让 listProjects 无延迟可见(server.ts:577-581)。
  • 给模型的紧凑排版: formatMemories 用符号图例 + 数量上限把搜索结果压成模型友好的短文本,并在 recall 出口做 200k 字符硬截断(format.tsserver.ts:701-712)。
  • 兼容不守规范的客户端: 额外代理 oauth-authorization-server,兜住那些不读 authorization_servers 数组的客户端(index.ts:82-107)。

10. 边界与局限

  • 服务器不做检索/嵌入/存储: 所有实际读写都转发给主 API(SupermemoryClient),这里只做鉴权 + 协议翻译 + 排版。它坏在哪、慢在哪,取决于主 API。
  • recall 是最终一致的: save 后要等主 API 的摄取流水线跑完才 recall 得到(README 提到 e2e 需轮询约 90s);forget 的移除更慢,测试里按「尽力而为」断言(apps/mcp/README.md:214-219)。
  • 单会话缓存不跨会话共享: clientInfo/标签缓存是每个 DO 实例私有的;不同会话(不同 DO)各拉各的。
  • 鉴权强依赖主 API 可达: 自省端点若挂了或限流,整条连接无法建立(auth.ts 对 429/5xx 只记录并返回 null)。
  • 埋点默认可能不开:POSTHOG_API_KEY 时全静默(posthog.ts:18-24)。
  • 不覆盖框架集成路径: 「一行 withSupermemory 给 agent 挂记忆」是另一条分发路径,不在本 Worker 内,见 03-framework-middleware

11. 横向对比(同库其它章)

你想了解去哪章
文档 / 记忆 / 画像 / 项目到底是什么数据01-data-model
不经 MCP,直接在 agent 代码里挂记忆03-framework-middleware
Claude 的文件式记忆工具怎么桥到远端04-claude-memory-tool
memory-graph 那张图怎么画出来的05-memory-graph
全景与阅读顺序index

一句话定位本章: MCP 服务器是「装进任意助手」的标准插座,鉴权最重、状态在 DO;框架中间件(03)是「装进你自己 agent」的代码内挂载,更轻、无鉴权发现。二者共享同一套 Supermemory 后端与数据模型(01)。


12. 代码地图(导航索引)

主题文件路径关键符号
DO/会话类、工具注册总入口apps/mcp/src/server.tsSupermemoryMCPinit
每会话缓存字段apps/mcp/src/server.tsclientInfocachedContainerTagscontainerTagsLastFetchedAt
握手抓客户端身份apps/mcp/src/server.ts:54oninitializedgetClientVersion
根标签裁剪 schemaapps/mcp/src/server.ts:67hasRootContainerTagcontainerTagField
memory 处理器apps/mcp/src/server.ts:539handleMemory
recall 处理器 + 截断apps/mcp/src/server.ts:622handleRecallMAX_RECALL_CHARS
标签缓存 TTLapps/mcp/src/server.ts:751ensureContainerTagsFreshrefreshContainerTagsCONTAINER_TAGS_TTL_MS
会话 idapps/mcp/src/server.ts:747getMcpSessionId
HTTP 入口 / 路由apps/mcp/src/index.tsapphandleMcpRequestmcpHandler
鉴权发现 / 401 质询apps/mcp/src/index.ts:69protectedResourceHandlerWWW-Authenticate
令牌类型判断apps/mcp/src/auth.ts:17isApiKey
API key 自省apps/mcp/src/auth.ts:25validateApiKey(/v3/session)
OAuth 令牌换 keyapps/mcp/src/auth.ts:96validateOAuthToken(/v3/mcp/session-with-key)
后端调用封装apps/mcp/src/client.tsSupermemoryClientcreateMemoryforgetMemorysearchgetProfilegetProjectsgetDocuments
结果排版apps/mcp/src/format.tsformatMemories
埋点apps/mcp/src/posthog.tsmemoryAddedmemorySearchmemoryForgot
部署配置apps/mcp/wrangler.jsoncdurable_objectsMCP_SERVERnew_sqlite_classes