migrate mcp app to stateless sdk v2

This commit is contained in:
Prasanna A P 2026-07-29 16:39:58 -07:00
parent 9523e563dd
commit f85366c847
54 changed files with 2207 additions and 951 deletions

View file

@ -1,24 +1,33 @@
# Supermemory MCP Server 4.0
# Supermemory MCP Server
A standalone MCP (Model Context Protocol) server for Supermemory that gives AI assistants persistent memory across conversations. Built on Cloudflare Workers with Durable Objects for scalable, persistent connections.
The Supermemory MCP server gives authenticated AI clients access to a user's
memories, profile, workspaces, and interactive MCP Apps.
## Features
## Runtime Model
- **Authentication** - OAuth 2.1 with dynamic client registration
- **Persistent Memory** - Save and recall information across sessions
- **User Profiles** - Auto-generated profiles from stored memories
- **Project Scoping** - Organize memories by project with `x-sm-project` header
- **Analytics** - PostHog integration for usage tracking
- MCP SDK v2 with a fresh `McpServer` for every HTTP request
- Modern MCP `2026-07-28` plus stateless compatibility for 2025 clients
- OAuth token validation on every request
- No MCP protocol session or protocol Durable Object
- Active workspace stored as application state in a dedicated Durable Object
- Workspace state keyed by authenticated `organizationId + userId`
## Setup
The workspace used by an operation resolves in this order:
### Server URL
1. An explicit `containerTag` tool or prompt argument
2. The account's durable active workspace
3. The Supermemory client default, `sm_project_default`
An explicit override applies only to that call. It does not mutate the active
workspace.
## Server URL
```text
https://mcp.supermemory.ai/mcp
```
Add to your MCP client config (Claude, Cursor, Windsurf, VS Code, etc.):
Example client configuration:
```json
{
@ -30,223 +39,103 @@ Add to your MCP client config (Claude, Cursor, Windsurf, VS Code, etc.):
}
```
The server requires OAuth authentication. Your MCP client will automatically discover the authorization server via `/.well-known/oauth-protected-resource` and prompt you to authenticate.
### Project Scoping (Optional)
To scope all operations to a specific project, add the `x-sm-project` header:
```json
{
"mcpServers": {
"supermemory": {
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"x-sm-project": "your-project-id"
}
}
}
}
```
The client discovers the OAuth authorization server through
`/.well-known/oauth-protected-resource/mcp`.
## Tools
### `memory`
### Model-visible tools
Save or forget information about the user.
| Tool | Purpose |
| --- | --- |
| `search_memory` | Search memories and optionally include profile context |
| `listMemories` | List extracted memories grouped by source document |
| `listSpaces` | List workspaces visible to the authenticated account |
| `whoAmI` | Return identity, access, client, and active-workspace context |
| `add_memory` | Save or forget a memory |
```json
{
"content": "User prefers dark mode and uses TypeScript",
"action": "save",
"containerTag": "optional-project-tag"
}
```
### MCP App launchers
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | Yes | The memory content to save or forget |
| `action` | `"save"` \| `"forget"` | No | Default: `"save"` |
| `containerTag` | string | No | Project tag to scope the memory |
| Tool | Purpose |
| --- | --- |
| `select-workspace` | Open the interactive workspace picker |
| `memory-graph` | Open the interactive memory graph |
| `guided-save` | Open the guided memory form |
| `upload-file` | Open the file upload form |
### `recall`
### App-only tools
Search memories and get user profile.
These tools are available to the embedded MCP App and hidden from the model.
```json
{
"query": "What are the user's programming preferences?",
"includeProfile": true,
"containerTag": "optional-project-tag"
}
```
| Tool | Purpose |
| --- | --- |
| `set-active-tag` | Persist the selected active workspace |
| `save-memory` | Submit the guided save form |
| `upload-file-submit` | Submit an encoded file upload |
| `fetch-graph-data` | Fetch graph documents for the app |
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Search query to find relevant memories |
| `includeProfile` | boolean | No | Include user profile summary. Default: `true` |
| `containerTag` | string | No | Project tag to scope the search |
## Resources And Prompt
### `listMemories`
| Kind | Name or URI | Purpose |
| --- | --- | --- |
| Resource | `supermemory://profile` | Profile facts in the effective workspace |
| Resource | `supermemory://container-tags` | Visible workspaces |
| Resource | `ui://supermemory/app-v3.html` | Embedded MCP App bundle |
| Prompt | `context` | Profile and recent context for an optional workspace |
Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search.
```json
{
"page": 1,
"limit": 10,
"containerTag": "optional-project-tag"
}
```
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `page` | integer | No | Page number (1-based). Default: `1` |
| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` |
| `containerTag` | string | No | Project tag to scope the listing |
### `whoAmI`
Get the current logged-in user's information.
```json
{}
```
Returns: `{ userId, email, name, client, sessionId }`
## Resources
| URI | Description |
|-----|-------------|
| `supermemory://profile` | User profile with stable preferences and recent activity |
| `supermemory://projects` | List of available memory projects |
## Prompts
| Name | Description |
|------|-------------|
| `context` | User profile and preferences for system context injection |
The App resource and tool metadata include both current nested `ui` metadata and
the legacy flat resource URI key while MCP Apps completes its SDK v2 migration.
The Worker runtime does not import the SDK v1 Apps server helpers.
## Development
### Prerequisites
- [Bun](https://bun.sh/) or Node.js
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
### Install Dependencies
Install from the repository root:
```bash
bun install
```
### Environment Variables
Create a `.dev.vars` file:
```env
API_URL=http://localhost:8787
or
API_URL=https://api.supermemory.ai
```
| Variable | Description | Default |
|----------|-------------|---------|
| `API_URL` | Main Supermemory API URL for OAuth validation | `https://api.supermemory.ai` |
### Run Locally
Run the local Worker:
```bash
cd apps/mcp
bun run dev
```
The server will start at `http://localhost:8788`.
The portless URL is `http://mcp.dev.supermemory`.
**Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation.
### End-to-End Tests
The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the
core journey: handshake → tool/resource/prompt discovery → `whoAmI``listProjects`
`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads,
the `context` prompt, container-tag isolation, and auth rejections.
Useful commands:
```bash
export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default
export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server
bun e2e/capture-oauth-token.ts # one-time browser authorization
bun run build
bun run check-types
bun run test:unit
bun run test:e2e
```
| File | Covers |
|------|--------|
| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) |
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation |
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |
#### OAuth flow tests
`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main
API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers:
- **AC (no secrets)** — discovery chain, dynamic client registration, and token/authorize
negatives. These exercise the protocol wiring with no key and no browser, so they always run.
- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to
`/mcp` with it, exercising the OAuth-token validation path. It **skips** unless both OAuth
credentials below are available.
Authenticated end-to-end tests use credentials captured by:
```bash
# One-time capture (opens a browser for login + consent, prints the env vars):
bun e2e/capture-oauth-token.ts
export SUPERMEMORY_MCP_CLIENT_ID=...
export SUPERMEMORY_MCP_REFRESH_TOKEN=...
```
Notes:
- Authenticated tests **skip** (not fail) without stored OAuth credentials or the refresh-token
environment variables, so CI is safe without secrets.
- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip
**polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort.
- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account.
Without stored OAuth credentials, authenticated test groups skip. Public OAuth
discovery and rejection tests still run.
### Deploy
## Configuration
```bash
bun run deploy
```
| Variable | Purpose | Default |
| --- | --- | --- |
| `API_URL` | Supermemory API and OAuth issuer | `https://api.supermemory.ai` |
| `MCP_RESOURCE` | Expected OAuth audience | `https://mcp.supermemory.ai/mcp` |
| `ALLOWED_MCP_ORIGIN_HOSTNAMES` | Additional comma-separated browser origins | Built-in host allowlist |
## Architecture
## Storage And Rollout
```
┌─────────────────┐ OAuth ┌──────────────────┐
│ MCP Client │◄──────────────►│ Supermemory API │
│ (Claude, Cursor)│ │ (api.supermemory.ai)
└────────┬────────┘ └──────────────────┘
│ ▲
│ MCP Protocol │ Auth Validation
▼ │
┌─────────────────────────────────────────────────────┐
│ Supermemory MCP Server │
│ (mcp.supermemory.ai/mcp) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Cloudflare Durable Object │ │
│ │ • Session state │ │
│ │ • Client info persistence │ │
│ │ • MCP protocol handling │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
`WorkspaceState` stores only the active container tag. It never stores bearer
tokens, MCP client identity, or protocol messages.
## Tech Stack
- **Runtime:** Cloudflare Workers
- **State:** Durable Objects with SQLite
- **Framework:** Hono
- **MCP SDK:** @modelcontextprotocol/sdk + agents
- **API Client:** supermemory SDK
- **Analytics:** PostHog
The old `SupermemoryMCP` class and binding remain inert for one rollout. This
keeps the migration non-destructive and rollback-safe. A later deployment can
delete the old protocol class after production traffic and rollback windows
have been checked.

View file

@ -47,22 +47,35 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
const res = await s.client.readResource({ uri: "supermemory://profile" })
expect(res.contents.length).toBeGreaterThan(0)
expect(res.contents[0].mimeType).toBe("text/plain")
expect(typeof res.contents[0].text).toBe("string")
expect(res.contents[0].text).toMatch(/# Active Workspace Profile/)
expect(res.contents[0].text).toMatch(/Workspace:/)
expect(res.contents[0].text).toMatch(
/Use `listSpaces` to find the relevant workspace key/,
)
})
it("reads the container-tags resource as JSON", async () => {
it("reads all workspaces in a compact human-readable format", async () => {
const res = await s.client.readResource({
uri: "supermemory://container-tags",
})
const text = res.contents[0].text as string
const parsed = JSON.parse(text)
expect(Array.isArray(parsed.containerTags)).toBe(true)
expect(res.contents[0].mimeType).toBe("text/plain")
expect(text).toMatch(/# My Workspaces/)
expect(text).toMatch(/Active:/)
expect(text).not.toMatch(/"containerTags":/)
})
it("gets the context prompt as a system message", async () => {
it("gets compact active-workspace context without prompt arguments", async () => {
const prompts = await s.client.listPrompts()
const contextPrompt = prompts.prompts.find(
(prompt) => prompt.name === "context",
)
expect(contextPrompt?.arguments ?? []).toHaveLength(0)
const res = await s.client.getPrompt({ name: "context", arguments: {} })
expect(res.messages.length).toBeGreaterThan(0)
const text = res.messages[0].content.text as string
expect(text).toMatch(/memory|context/i)
expect(text).toMatch(/# Supermemory Context/)
expect(text).toMatch(/Active workspace:/)
})
})

View file

@ -183,13 +183,13 @@ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export type Session = { client: Client; close: () => Promise<void> }
export async function connect(
opts: { token?: string; containerTag?: string } = {},
opts: { token?: string; headers?: Record<string, string> } = {},
): Promise<Session> {
const bearerToken = opts.token ?? (await defaultBearerToken())
const headers: Record<string, string> = {
Authorization: `Bearer ${bearerToken}`,
...opts.headers,
}
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: { headers },

View file

@ -1,93 +0,0 @@
import { randomUUID } from "node:crypto"
import { describe, expect, it } from "vitest"
import {
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
connect,
recallUntil,
textOf,
} from "./helpers"
type ToolLike = {
name: string
inputSchema?: { properties?: Record<string, unknown> }
}
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
const SCOPE_TAG = "sm_e2e_root"
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
describeWithAuth("MCP — x-sm-project root scoping", () => {
it("strips containerTag from tool schemas when x-sm-project is set", async () => {
const scoped = await connect({ containerTag: SCOPE_TAG })
const plain = await connect()
try {
const scopedTools = (await scoped.client.listTools()).tools
const plainTools = (await plain.client.listTools()).tools
expect(propsOf(plainTools, "add_memory")).toHaveProperty("containerTag")
expect(propsOf(plainTools, "search_memory")).toHaveProperty(
"containerTag",
)
expect(propsOf(scopedTools, "add_memory")).not.toHaveProperty(
"containerTag",
)
expect(propsOf(scopedTools, "search_memory")).not.toHaveProperty(
"containerTag",
)
} finally {
await scoped.close()
await plain.close()
}
})
it("scopes saves to the connection project and isolates them from default", async () => {
const marker = `root-${randomUUID()}`
const content = `e2e root scope. token=${marker}. The root flower is bluebell.`
const rooted = await connect({ containerTag: SCOPE_TAG })
try {
const save = await callTool(rooted.client, "add_memory", {
content,
action: "save",
})
expect(save.isError).toBeFalsy()
expect(textOf(save)).toContain(SCOPE_TAG)
const found = await recallUntil(
rooted.client,
"root flower bluebell",
marker,
)
expect(found, "marker not found within its root scope").not.toBeNull()
} finally {
await callTool(rooted.client, "add_memory", {
content,
action: "forget",
}).catch(() => {})
await rooted.close()
}
// A default connection searches sm_project_default only — must not see it.
const plain = await connect()
try {
const leaked = await recallUntil(
plain.client,
"root flower bluebell",
marker,
{
tries: 3,
delayMs: 3000,
},
)
expect(leaked, "rooted memory leaked into the default project").toBeNull()
} finally {
await plain.close()
}
}, 120_000)
})

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest"
import { OAUTH_CREDENTIALS_AVAILABLE, connect } from "./helpers"
type ToolLike = {
name: string
inputSchema?: { properties?: Record<string, unknown> }
}
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
describeWithAuth("MCP - workspace scoping", () => {
it("keeps per-call containerTag overrides when an obsolete header is sent", async () => {
const scoped = await connect({
headers: { "x-sm-project": "obsolete-root-scope" },
})
const plain = await connect()
try {
const scopedTools = (await scoped.client.listTools()).tools
const plainTools = (await plain.client.listTools()).tools
expect(propsOf(plainTools, "add_memory")).toHaveProperty("containerTag")
expect(propsOf(plainTools, "search_memory")).toHaveProperty(
"containerTag",
)
expect(propsOf(scopedTools, "add_memory")).toHaveProperty("containerTag")
expect(propsOf(scopedTools, "search_memory")).toHaveProperty(
"containerTag",
)
} finally {
await scoped.close()
await plain.close()
}
})
})

View file

@ -2,6 +2,10 @@
"name": "supermemory-mcp",
"version": "1.0.0",
"type": "module",
"portless": {
"name": "mcp.dev.supermemory",
"script": "dev:app"
},
"scripts": {
"build:widget": "vite build",
"build": "vite build",
@ -10,19 +14,23 @@
"dev:widget": "vite --config vite.config.dev.ts",
"studio": "vite --config vite.config.dev.ts --open /studio.html",
"deploy": "vite build && wrangler deploy --minify",
"check-types": "tsc --noEmit -p tsconfig.widget.json",
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.widget.json",
"test:unit": "vitest run src/server src/widget",
"test:e2e": "vitest run e2e",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
"@modelcontextprotocol/ext-apps": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/server": "2.0.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@supermemory/memory-graph": "^0.2.0",
"agents": "^0.3.5",
"agents": "^0.20.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.11.1",
@ -31,7 +39,7 @@
"react-dom": "^19.2.4",
"supermemory": "^4.0.0",
"tailwind-merge": "^3.4.0",
"zod": "^3.25.76"
"zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0",
@ -43,6 +51,7 @@
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
}

View file

@ -1,124 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { McpAgent } from "agents/mcp"
import type { Props } from "../shared/types"
import { fetchSession } from "./auth"
import { SupermemoryClient } from "./client"
import { registerContextPrompt } from "./prompts/context"
import { registerContainerTagsResource } from "./resources/container-tags"
import { registerProfileResource } from "./resources/profile"
import { registerWidgetResource } from "./resources/widget"
import { registerAllTools } from "./tools"
import { errorResult } from "./tools/types"
type Env = {
MCP_SERVER: DurableObjectNamespace<SupermemoryMCP>
API_URL?: string
}
const DEFAULT_API_URL = "https://api.supermemory.ai"
const ACTIVE_CONTAINER_TAG_KEY = "activeContainerTag"
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
// @ts-expect-error - agents/mcp ships its own bundled @modelcontextprotocol/sdk;
// our installed sdk has a private `_serverInfo` field with a different declaration.
server = new McpServer({
name: "supermemory",
version: "1.0.0",
})
async init() {
const stored = await this.ctx.storage.get<{
name: string
version?: string
}>("clientInfo")
if (stored) this.clientInfo = stored
this.server.server.oninitialized = async () => {
const v = this.server.server.getClientVersion()
if (v) {
this.clientInfo = { name: v.name, version: v.version }
await this.ctx.storage.put("clientInfo", this.clientInfo)
}
}
const deps = {
server: this.server,
props: this.props,
getClient: (containerTag?: string) => this.getClient(containerTag),
getSession: () => this.getSession(),
resolveContainerTag: (explicit?: string) =>
this.resolveContainerTag(explicit),
getActiveContainerTag: () => this.getActiveContainerTag(),
setActiveContainerTag: (containerTag: string) =>
this.setActiveContainerTag(containerTag),
getClientInfo: () => this.clientInfo,
getMcpSessionId: () => this.getSessionId(),
errorResult,
}
registerAllTools(deps)
registerProfileResource(this.server, () => this.getClient())
registerContainerTagsResource(this.server, () => this.getClient())
registerWidgetResource(this.server)
registerContextPrompt(
this.server,
!!this.props?.containerTag,
(tag) => this.getClient(tag),
(explicit) => this.resolveContainerTag(explicit),
)
}
private getClient(containerTag?: string): SupermemoryClient {
return new SupermemoryClient(
this.props?.bearerToken || "",
containerTag || this.props?.containerTag,
this.env.API_URL || DEFAULT_API_URL,
)
}
private async resolveContainerTag(
explicit?: string,
): Promise<string | undefined> {
if (this.props?.containerTag) return this.props.containerTag
if (explicit) return explicit
const activeTag = await this.getActiveContainerTag()
if (activeTag) return activeTag
return undefined
}
private workspaceState() {
const userId = this.props?.userId
if (!userId) throw new Error("Authenticated user ID is required")
const organizationId = this.props?.organizationId ?? "default"
return this.env.MCP_SERVER.getByName(
`workspace-state:${userId}:${organizationId}`,
)
}
private getActiveContainerTag(): Promise<string | undefined> {
return this.workspaceState().readActiveContainerTag()
}
private setActiveContainerTag(containerTag: string): Promise<void> {
return this.workspaceState().writeActiveContainerTag(containerTag)
}
async readActiveContainerTag(): Promise<string | undefined> {
return this.ctx.storage.get<string>(ACTIVE_CONTAINER_TAG_KEY)
}
async writeActiveContainerTag(containerTag: string): Promise<void> {
await this.ctx.storage.put(ACTIVE_CONTAINER_TAG_KEY, containerTag)
}
private getSession() {
return fetchSession(
this.props?.bearerToken || "",
this.env.API_URL || DEFAULT_API_URL,
)
}
}

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest"
import { SUPERMEMORY_RESOURCE_URI } from "../shared/types"
import { appResultMeta, appToolMeta } from "./app-metadata"
describe("MCP Apps metadata compatibility", () => {
it("advertises both current and legacy resource URI metadata", () => {
expect(appToolMeta()).toEqual({
ui: { resourceUri: SUPERMEMORY_RESOURCE_URI },
"ui/resourceUri": SUPERMEMORY_RESOURCE_URI,
})
})
it("keeps App-only tools hidden from the model", () => {
expect(appToolMeta(["app"])).toMatchObject({
ui: {
resourceUri: SUPERMEMORY_RESOURCE_URI,
visibility: ["app"],
},
})
})
it("provides a stable ChatGPT widget-state key", () => {
expect(appResultMeta("view-123")).toEqual({
"openai/widgetSessionId": "view-123",
})
})
})

View file

@ -0,0 +1,29 @@
import { SUPERMEMORY_RESOURCE_URI } from "../shared/types"
export const APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
type AppVisibility = "model" | "app"
export function appToolMeta(
visibility?: AppVisibility[],
): Record<string, unknown> {
const ui = {
resourceUri: SUPERMEMORY_RESOURCE_URI,
...(visibility ? { visibility } : {}),
}
return {
ui,
"ui/resourceUri": SUPERMEMORY_RESOURCE_URI,
}
}
/**
* ChatGPT keys widget-scoped state by this id. Other MCP Apps hosts safely
* ignore the namespaced metadata and use the View's own checkpoint fallback.
*/
export function appResultMeta(viewId: string): Record<string, unknown> {
return {
"openai/widgetSessionId": viewId,
}
}

View file

@ -24,9 +24,20 @@ describe("MCP authentication", () => {
})
async function signToken(
overrides: { audience?: string; subject?: string; expiresIn?: string } = {},
overrides: {
audience?: string
subject?: string
expiresIn?: string
organizationId?: string
} = {},
) {
let token = new SignJWT({ organization_id: "org_test" })
let token = new SignJWT({
...(overrides.organizationId === ""
? {}
: { organization_id: overrides.organizationId ?? "org_test" }),
azp: "client_test",
scope: "openid profile",
})
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setIssuer(ISSUER)
.setAudience(overrides.audience ?? MCP_RESOURCE)
@ -51,10 +62,21 @@ describe("MCP authentication", () => {
userId: "user_test",
organizationId: "org_test",
bearerToken: token,
oauthClientId: "client_test",
scopes: ["openid", "profile"],
expiresAt: expect.any(Number),
})
expect(fetchSpy).not.toHaveBeenCalled()
})
it("rejects a token without an organization boundary", async () => {
const token = await signToken({ organizationId: "" })
await expect(
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
).resolves.toBeNull()
})
it("rejects a token issued for a different audience", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
const token = await signToken({ audience: "https://api.example.com" })

View file

@ -5,8 +5,11 @@ const FETCH_TIMEOUT_MS = 30_000
export interface AuthUser {
userId: string
organizationId?: string
organizationId: string
bearerToken: string
oauthClientId?: string
scopes: string[]
expiresAt?: number
}
const remoteJwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
@ -65,13 +68,32 @@ export async function validateOAuthToken(
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
return null
}
if (
typeof payload.organization_id !== "string" ||
payload.organization_id.length === 0
) {
return null
}
const rawScopes = payload.scope ?? payload.scopes
const scopes = Array.isArray(rawScopes)
? rawScopes.filter((scope): scope is string => typeof scope === "string")
: typeof rawScopes === "string"
? rawScopes.split(/\s+/).filter(Boolean)
: []
return {
userId: payload.sub,
organizationId:
typeof payload.organization_id === "string"
? payload.organization_id
: undefined,
organizationId: payload.organization_id,
bearerToken: token,
oauthClientId:
typeof payload.azp === "string"
? payload.azp
: typeof payload.client_id === "string"
? payload.client_id
: undefined,
scopes,
expiresAt: payload.exp,
}
} catch (error) {
console.error("OAuth token validation error:", error)

View file

@ -7,7 +7,7 @@ import type {
} from "../../shared/types"
const MAX_CHARS = 200000
const DEFAULT_PROJECT_ID = "sm_project_default"
export const DEFAULT_PROJECT_ID = "sm_project_default"
const FETCH_TIMEOUT_MS = 30_000
export type {

View file

@ -0,0 +1,8 @@
import { z } from "zod"
export const containerTagSchema = z
.string()
.min(1, "Container tag is required")
.max(128, "Container tag exceeds maximum length")
export const optionalContainerTagSchema = containerTagSchema.optional()

View file

@ -1,17 +1,15 @@
import type { AuthInfo } from "@modelcontextprotocol/server"
import { createMcpHandler } from "agents/mcp/server"
import { Hono, type Context } from "hono"
import { cors } from "hono/cors"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import type { Props } from "../shared/types"
import { SupermemoryMCP } from "./agent"
import { validateOAuthToken } from "./auth"
import { validateOAuthToken, type AuthUser } from "./auth"
import { SupermemoryMCP } from "./legacy-protocol-state"
import { createSupermemoryServer } from "./server"
import type { ActorContext, ServerEnv } from "./types"
import { WorkspaceState } from "./workspace-state"
type Bindings = {
MCP_SERVER: DurableObjectNamespace<SupermemoryMCP>
API_URL?: string
MCP_RESOURCE?: string
}
export type { Props }
type Bindings = ServerEnv
const app = new Hono<{ Bindings: Bindings }>()
@ -19,22 +17,31 @@ const DEFAULT_API_URL = "https://api.supermemory.ai"
const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp"
const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource/mcp"
const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [
"app.supermemory.ai",
"mcp.supermemory.ai",
"mcp.dev.supermemory.ai",
"mcp.dev.supermemory",
"claude.ai",
"chatgpt.com",
"chat.openai.com",
"gemini.google.com",
"grok.com",
"x.ai",
"t3.chat",
"localhost",
"127.0.0.1",
"[::1]",
]
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowHeaders: [
"Content-Type",
"Authorization",
"x-sm-project",
"Accept",
"Mcp-Session-Id",
"MCP-Protocol-Version",
"Last-Event-ID",
],
exposeHeaders: ["Mcp-Session-Id", "WWW-Authenticate"],
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
exposeHeaders: ["WWW-Authenticate"],
}),
)
@ -42,31 +49,27 @@ app.get("/", (c) => {
return c.json({
name: "supermemory-mcp",
version: "1.0.0",
description: "Supermemory MCP AI memory for teams",
docs: "https://docs.supermemory.ai/mcp",
description: "Supermemory MCP - AI memory for teams",
docs: "https://supermemory.ai/docs/supermemory-mcp/mcp",
})
})
// OAuth discovery: resource metadata. The protected resource is the MCP
// endpoint itself, so path-aware clients discover metadata at the well-known
// URL with `/mcp` appended.
function resourceMetadata(c: Context<{ Bindings: Bindings }>) {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
return c.json({
resource: mcpResource,
authorization_servers: [apiUrl],
authorization_servers: [`${apiUrl.replace(/\/+$/, "")}/api/auth`],
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
resource_documentation: "https://docs.supermemory.ai/mcp",
resource_documentation: "https://supermemory.ai/docs/supermemory-mcp/mcp",
})
}
app.get("/.well-known/oauth-protected-resource", resourceMetadata)
app.get(PROTECTED_RESOURCE_METADATA_PATH, resourceMetadata)
// OAuth discovery: proxy authorization server metadata
app.get("/.well-known/oauth-authorization-server", async (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
@ -80,42 +83,46 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
{ status: response.status as ContentfulStatusCode },
)
}
const metadata = await response.json()
return c.json(metadata)
return c.json(await response.json())
} catch (error) {
console.error("Error fetching OAuth metadata:", error)
return c.json({ error: "Internal server error" }, 500)
}
})
const mcpHandler = SupermemoryMCP.serve("/mcp", {
binding: "MCP_SERVER",
corsOptions: {
origin: "*",
methods: "GET, POST, DELETE, OPTIONS",
headers: "Content-Type, Authorization, x-sm-project",
},
})
function allowedOriginHostnames(env: Bindings): string[] {
const configured =
env.ALLOWED_MCP_ORIGIN_HOSTNAMES?.split(",")
.map((hostname) => hostname.trim().toLowerCase())
.filter(Boolean) ?? []
async function handleMcpRequest(
c: Context<{ Bindings: Bindings }>,
rewritePath?: string,
) {
const authHeader = c.req.header("Authorization")
const token = authHeader?.replace(/^Bearer\s+/i, "")
const containerTag = c.req.header("x-sm-project")
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
return [...new Set([...DEFAULT_ALLOWED_ORIGIN_HOSTNAMES, ...configured])]
}
// Build absolute resource_metadata URL from incoming request (works
// behind tunnels where the scheme/host differ from localhost)
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
const reqProto = c.req.header("x-forwarded-proto") || "https"
const resourceMetadataUrl = reqHost
? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}`
: PROTECTED_RESOURCE_METADATA_PATH
function authInfoFor(
authUser: AuthUser,
resource: string,
): AuthInfo | undefined {
if (!authUser.oauthClientId) return undefined
if (!token) {
return {
token: authUser.bearerToken,
clientId: authUser.oauthClientId,
scopes: authUser.scopes,
expiresAt: authUser.expiresAt,
resource: new URL(resource),
extra: {
userId: authUser.userId,
organizationId: authUser.organizationId,
},
}
}
function unauthorizedResponse(
resourceMetadataUrl: string,
invalidToken = false,
): Response {
if (!invalidToken) {
return new Response("Unauthorized", {
status: 401,
headers: {
@ -126,55 +133,76 @@ async function handleMcpRequest(
})
}
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
if (!authUser) {
return new Response(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Invalid or expired token",
},
id: null,
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
return Response.json(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Invalid or expired token",
},
)
id: null,
},
{
status: 401,
headers: {
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
},
)
}
async function handleMcpRequest(
c: Context<{ Bindings: Bindings }>,
rewritePath?: string,
) {
const authHeader = c.req.header("Authorization")
const token = authHeader?.replace(/^Bearer\s+/i, "").trim()
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
const reqProto = c.req.header("x-forwarded-proto") || "https"
const resourceMetadataUrl = reqHost
? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}`
: PROTECTED_RESOURCE_METADATA_PATH
if (!token) return unauthorizedResponse(resourceMetadataUrl)
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
const actor: ActorContext = {
userId: authUser.userId,
organizationId: authUser.organizationId,
bearerToken: authUser.bearerToken,
oauthClientId: authUser.oauthClientId,
}
const ctx = {
...c.executionCtx,
props: {
userId: authUser.userId,
organizationId: authUser.organizationId,
bearerToken: authUser.bearerToken,
containerTag,
} satisfies Props,
} as ExecutionContext & { props: Props }
const request = rewritePath
? new Request(new URL(rewritePath, c.req.url).toString(), c.req.raw)
: c.req.raw
const handler = createMcpHandler(
() => createSupermemoryServer(c.env, actor),
{
route: "/mcp",
legacy: "stateless",
corsOptions: false,
allowedOriginHostnames: allowedOriginHostnames(c.env),
onerror: (error) => console.error("MCP request error:", error),
},
)
return mcpHandler.fetch(request, c.env, ctx)
return handler.fetch(request, {
authInfo: authInfoFor(authUser, mcpResource),
})
}
app.all("/", async (c) => {
return handleMcpRequest(c, "/mcp")
})
app.all("/", (c) => handleMcpRequest(c, "/mcp"))
app.all("/mcp", (c) => handleMcpRequest(c))
app.all("/mcp/", (c) => handleMcpRequest(c, "/mcp"))
app.all("/mcp/*", async (c) => {
return handleMcpRequest(c)
})
export { SupermemoryMCP }
export { SupermemoryMCP, WorkspaceState }
export type { ActorContext, ServerEnv }
export default app

View file

@ -0,0 +1,5 @@
import { DurableObject } from "cloudflare:workers"
// Kept for one rollout so the old protocol Durable Object class remains
// deployable and rollback-safe. No request path uses this class anymore.
export class SupermemoryMCP extends DurableObject {}

View file

@ -1,76 +1,98 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { z } from "zod"
import type { SupermemoryClient } from "../client"
import type { McpServer } from "@modelcontextprotocol/server"
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
import {
compactDescription,
formatFactSection,
formatWorkspaceRow,
sortWorkspaces,
workspaceDisplayName,
workspaceMetadata,
} from "../workspace-presentation"
const CONTEXT_FACT_LIMIT = 8
const RECENT_WORKSPACE_LIMIT = 3
export function registerContextPrompt(
server: McpServer,
hasRootContainerTag: boolean,
getClient: (tag?: string) => SupermemoryClient,
resolveContainerTag: (explicit?: string) => Promise<string | undefined>,
resolveContainerTag: () => Promise<string | undefined>,
) {
const containerTagField: Record<string, z.ZodTypeAny> = hasRootContainerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const argsSchema = {
includeRecent: z.boolean().optional().default(true),
...containerTagField,
}
server.registerPrompt(
"context",
{
description: "Get user context including profile and workspace info",
argsSchema,
description: "Attach compact context for the active workspace",
},
async (rawArgs) => {
const args = rawArgs as {
includeRecent?: boolean
containerTag?: string
}
async () => {
try {
const effectiveTag = await resolveContainerTag(args.containerTag)
const client = getClient(effectiveTag)
const profileResult = await client.getProfile()
const selectedTag = await resolveContainerTag()
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
const [profileResult, workspaces] = await Promise.all([
getClient(activeKey).getProfile(),
getClient().listContainerTags(),
])
const activeWorkspace = workspaces.find(
(workspace) => workspace.containerTag === activeKey,
)
const activeLabel = workspaceDisplayName(activeWorkspace, activeKey)
const fallback = selectedTag ? "" : " (default)"
const parts: string[] = [
"# Supermemory Context",
`Active workspace: ${activeLabel} [${activeKey}]${fallback}`,
]
const parts: string[] = []
if (profileResult.profile.static.length > 0) {
parts.push("## About the user")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
if (activeWorkspace) {
const metadata = workspaceMetadata(activeWorkspace)
if (metadata) parts.push(metadata)
const description = compactDescription(activeWorkspace.description)
if (description) parts.push(description)
}
parts.push(
"",
...formatFactSection(
"Stable Context",
profileResult.profile.static,
CONTEXT_FACT_LIMIT,
),
...formatFactSection(
"Recent Context",
profileResult.profile.dynamic,
CONTEXT_FACT_LIMIT,
),
)
if (
args.includeRecent !== false &&
profileResult.profile.dynamic.length > 0
profileResult.profile.static.length === 0 &&
profileResult.profile.dynamic.length === 0
) {
parts.push("\n## Recent context")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
parts.push("No profile facts are available for this workspace yet.")
}
if (effectiveTag) {
parts.push(`\n## Active workspace: ${effectiveTag}`)
const recentWorkspaces = sortWorkspaces(workspaces, activeKey)
.filter((workspace) => workspace.containerTag !== activeKey)
.slice(0, RECENT_WORKSPACE_LIMIT)
if (recentWorkspaces.length > 0) {
parts.push(
"",
"## Recently Active Workspaces",
...recentWorkspaces.map((workspace) =>
formatWorkspaceRow(workspace, activeKey, 100),
),
)
}
parts.push(
"",
"Use a workspace key with workspace-aware tools when the user asks about another workspace. Keep workspace contexts separate unless the user asks to combine them.",
)
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
parts.length > 0
? parts.join("\n")
: "No user context available yet.",
text: parts.join("\n"),
},
},
],

View file

@ -1,23 +1,48 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { SupermemoryClient } from "../client"
import type { McpServer } from "@modelcontextprotocol/server"
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
import {
formatWorkspaceRow,
sortWorkspaces,
workspaceDisplayName,
} from "../workspace-presentation"
export function registerContainerTagsResource(
server: McpServer,
getClient: () => SupermemoryClient,
resolveContainerTag: () => Promise<string | undefined>,
) {
server.registerResource(
"My Container Tags",
"My Workspaces",
"supermemory://container-tags",
{},
async () => {
const client = getClient()
const containerTags = await client.listContainerTags()
const [containerTags, selectedTag] = await Promise.all([
client.listContainerTags(),
resolveContainerTag(),
])
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
const activeWorkspace = containerTags.find(
(workspace) => workspace.containerTag === activeKey,
)
const rows = sortWorkspaces(containerTags, activeKey).map((workspace) =>
formatWorkspaceRow(workspace, activeKey),
)
const activeLabel = workspaceDisplayName(activeWorkspace, activeKey)
const fallback = selectedTag ? "" : " (default)"
const text = [
"# My Workspaces",
`${containerTags.length} available · Active: ${activeLabel} [${activeKey}]${fallback}`,
"",
...rows,
].join("\n")
return {
contents: [
{
uri: "supermemory://container-tags",
mimeType: "application/json",
text: JSON.stringify({ containerTags }, null, 2),
mimeType: "text/plain",
text,
},
],
}

View file

@ -1,42 +1,79 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { SupermemoryClient } from "../client"
import type { McpServer } from "@modelcontextprotocol/server"
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
import {
compactDescription,
formatFactSection,
workspaceDisplayName,
workspaceMetadata,
} from "../workspace-presentation"
const PROFILE_FACT_LIMIT = 12
export function registerProfileResource(
server: McpServer,
getClient: () => SupermemoryClient,
getClient: (containerTag?: string) => SupermemoryClient,
resolveContainerTag: () => Promise<string | undefined>,
) {
server.registerResource(
"User Profile",
"Active Workspace Profile",
"supermemory://profile",
{},
async () => {
const client = getClient()
const profileResult = await client.getProfile()
const parts: string[] = ["# User Profile\n"]
const selectedTag = await resolveContainerTag()
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
const [profileResult, workspaces] = await Promise.all([
getClient(activeKey).getProfile(),
getClient().listContainerTags(),
])
const activeWorkspace = workspaces.find(
(workspace) => workspace.containerTag === activeKey,
)
const activeLabel = workspaceDisplayName(activeWorkspace, activeKey)
const fallback = selectedTag ? "" : " (default)"
const parts: string[] = [
"# Active Workspace Profile",
`Workspace: ${activeLabel} [${activeKey}]${fallback}`,
]
if (profileResult.profile.static.length > 0) {
parts.push("## Stable Preferences")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
if (activeWorkspace) {
const metadata = workspaceMetadata(activeWorkspace)
if (metadata) parts.push(metadata)
const description = compactDescription(activeWorkspace.description)
if (description) parts.push(description)
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n## Recent Activity")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
parts.push(
"",
...formatFactSection(
"Stable Context",
profileResult.profile.static,
PROFILE_FACT_LIMIT,
),
...formatFactSection(
"Recent Context",
profileResult.profile.dynamic,
PROFILE_FACT_LIMIT,
),
)
if (
profileResult.profile.static.length === 0 &&
profileResult.profile.dynamic.length === 0
) {
parts.push("No profile facts are available for this workspace yet.")
}
parts.push(
"",
"Other workspaces are available. Use `listSpaces` to find the relevant workspace key, then use that key with workspace-aware tools when the user asks about another workspace. Keep workspace contexts separate unless the user asks to combine them.",
)
return {
contents: [
{
uri: "supermemory://profile",
mimeType: "text/plain",
text:
parts.length > 1
? parts.join("\n")
: "No profile yet. Start saving memories.",
text: parts.join("\n"),
},
],
}

View file

@ -1,10 +1,7 @@
import {
RESOURCE_MIME_TYPE,
registerAppResource,
} from "@modelcontextprotocol/ext-apps/server"
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { McpServer } from "@modelcontextprotocol/server"
import supermemoryAppHtml from "../../../dist/src/widget/index.html"
import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
import { APP_RESOURCE_MIME_TYPE } from "../app-metadata"
const CSP_DOMAINS = [
"https://esm.sh",
@ -21,15 +18,14 @@ const RESOURCE_UI_META = {
}
export function registerWidgetResource(server: McpServer) {
registerAppResource(
server,
server.registerResource(
"Supermemory MCP UI",
SUPERMEMORY_RESOURCE_URI,
// Listing-level metadata: hosts use this when discovering resources
// before invoking the read callback. Mirrors the read response below
// so prefetch/connect-time decisions match what the host will get.
{
mimeType: RESOURCE_MIME_TYPE,
mimeType: APP_RESOURCE_MIME_TYPE,
_meta: { ui: RESOURCE_UI_META },
},
// Read response: per spec, content-item `_meta.ui` takes precedence
@ -39,7 +35,7 @@ export function registerWidgetResource(server: McpServer) {
contents: [
{
uri: SUPERMEMORY_RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
mimeType: APP_RESOURCE_MIME_TYPE,
text: supermemoryAppHtml,
_meta: { ui: RESOURCE_UI_META },
},

View file

@ -0,0 +1,80 @@
import {
CLIENT_INFO_META_KEY,
McpServer,
type ServerContext,
} from "@modelcontextprotocol/server"
import { fetchSession } from "./auth"
import { SupermemoryClient } from "./client"
import { registerContextPrompt } from "./prompts/context"
import { registerContainerTagsResource } from "./resources/container-tags"
import { registerProfileResource } from "./resources/profile"
import { registerWidgetResource } from "./resources/widget"
import { registerAllTools } from "./tools"
import { errorResult } from "./tools/types"
import type { ActorContext, ServerEnv } from "./types"
import {
resolveContainerTag as resolveWorkspaceContainerTag,
workspaceStateName,
} from "./workspace"
const DEFAULT_API_URL = "https://api.supermemory.ai"
type ClientInfo = { name: string; version?: string }
function clientInfoFromContext(context: ServerContext): ClientInfo | null {
const envelope = context.mcpReq.envelope as
| Record<string, unknown>
| undefined
const value = envelope?.[CLIENT_INFO_META_KEY]
if (!value || typeof value !== "object") return null
const name = Reflect.get(value, "name")
const version = Reflect.get(value, "version")
if (typeof name !== "string") return null
return {
name,
...(typeof version === "string" ? { version } : {}),
}
}
export function createSupermemoryServer(
env: ServerEnv,
actor: ActorContext,
): McpServer {
const server = new McpServer({
name: "supermemory",
version: "1.0.0",
})
const apiUrl = env.API_URL || DEFAULT_API_URL
const workspaceState = env.WORKSPACE_STATE.getByName(
workspaceStateName(actor),
)
const getClient = (containerTag?: string) =>
new SupermemoryClient(actor.bearerToken, containerTag, apiUrl)
const getActiveContainerTag = () => workspaceState.getActiveContainerTag()
const setActiveContainerTag = (containerTag: string) =>
workspaceState.setActiveContainerTag(containerTag)
const resolveContainerTag = (explicit?: string) =>
resolveWorkspaceContainerTag(explicit, getActiveContainerTag)
registerAllTools({
server,
actor,
getClient,
getSession: () => fetchSession(actor.bearerToken, apiUrl),
resolveContainerTag,
getActiveContainerTag,
setActiveContainerTag,
getClientInfo: clientInfoFromContext,
errorResult,
})
registerProfileResource(server, getClient, resolveContainerTag)
registerContainerTagsResource(server, () => getClient(), resolveContainerTag)
registerWidgetResource(server)
registerContextPrompt(server, getClient, resolveContainerTag)
return server
}

View file

@ -1,26 +1,17 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
?.containerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = {
const inputSchema = z.object({
content: z
.string()
.max(200000, "Content exceeds maximum length")
.describe("The memory content to save or forget"),
action: z.enum(["save", "forget"]).optional().default("save"),
...containerTagField,
}
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"add_memory",
@ -30,12 +21,7 @@ export function register(deps: ToolDeps) {
inputSchema,
annotations: MEMORY_TOOL_ANNOTATIONS,
},
async (rawArgs) => {
const args = rawArgs as {
content: string
action?: "save" | "forget"
containerTag?: string
}
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)

View file

@ -1,34 +1,23 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
import { appToolMeta } from "../app-metadata"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"fetch-graph-data",
{
description: "Fetch documents with memories for graph display",
inputSchema: {
containerTag: z.string().optional(),
inputSchema: z.object({
containerTag: optionalContainerTagSchema,
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
},
}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: {
ui: {
resourceUri: SUPERMEMORY_RESOURCE_URI,
visibility: ["app"],
},
},
_meta: appToolMeta(["app"]),
},
async (rawArgs) => {
const args = rawArgs as {
containerTag?: string
page?: number
limit?: number
}
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)

View file

@ -1,24 +1,24 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"guided-save",
{
title: "Add Memory",
description: "Save information to memory with an interactive form.",
inputSchema: {
inputSchema: z.object({
prefill: z.string().optional().describe("Optional content to prefill"),
},
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
}),
_meta: appToolMeta(),
},
async (args) => {
try {
const prefill = (args as { prefill?: string }).prefill
const { prefill } = args
const viewId = crypto.randomUUID()
const [activeTag, tags, session] = await Promise.all([
deps.getActiveContainerTag(),
deps.getClient().listContainerTags(),
@ -33,6 +33,7 @@ export function register(deps: ToolDeps) {
const sc: ViewMessage = {
view: "save",
viewId,
activeTag,
writableTags,
prefill,
@ -43,6 +44,7 @@ export function register(deps: ToolDeps) {
{ type: "text" as const, text: "Opening memory save form..." },
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,3 +1,4 @@
import { z } from "zod"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
@ -7,7 +8,7 @@ export function register(deps: ToolDeps) {
{
description:
"List the spaces available to you. Spaces are the workspaces you organize memories into — returns each space's name, identifier, emoji, document/memory counts, and last activity. The list is auto-filtered to spaces you have access to.",
inputSchema: {},
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async () => {

View file

@ -1,20 +1,11 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { formatMemoriesList } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
?.containerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = {
const inputSchema = z.object({
page: z
.number()
.int()
@ -32,8 +23,8 @@ export function register(deps: ToolDeps) {
.describe(
"Documents per page; each document groups its extracted memories (default 10, max 50)",
),
...containerTagField,
}
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"listMemories",
@ -43,12 +34,7 @@ export function register(deps: ToolDeps) {
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (rawArgs) => {
const args = rawArgs as {
page?: number
limit?: number
containerTag?: string
}
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)

View file

@ -1,21 +1,16 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ViewMessage } from "../../shared/types"
import { appToolMeta } from "../app-metadata"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema: Record<string, z.ZodTypeAny> = deps.props?.containerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = z.object({
containerTag: optionalContainerTagSchema,
})
registerAppTool(
deps.server,
deps.server.registerTool(
"memory-graph",
{
title: "Memory Graph",
@ -23,12 +18,11 @@ export function register(deps: ToolDeps) {
"Visualize the user's memory graph as an interactive force-directed graph.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
_meta: appToolMeta(),
},
async (rawArgs) => {
async (args) => {
try {
const explicit = (rawArgs as { containerTag?: string }).containerTag
const effectiveTag = await deps.resolveContainerTag(explicit)
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined

View file

@ -1,34 +1,31 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"save-memory",
{
description: "Save content to memory",
inputSchema: {
inputSchema: z.object({
content: z.string().min(1),
containerTag: z.string().min(1),
},
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
annotations: MEMORY_TOOL_ANNOTATIONS,
_meta: {
ui: {
resourceUri: SUPERMEMORY_RESOURCE_URI,
visibility: ["app"],
},
},
_meta: appToolMeta(["app"]),
},
async (rawArgs) => {
const args = rawArgs as { content: string; containerTag: string }
async (args) => {
try {
const viewId = args.viewId ?? crypto.randomUUID()
const client = deps.getClient(args.containerTag)
const result = await client.createMemory(args.content)
const sc: ViewMessage = {
view: "save-success",
viewId,
id: result.id,
containerTag: args.containerTag,
}
@ -37,6 +34,7 @@ export function register(deps: ToolDeps) {
{ type: "text" as const, text: `Memory saved: ${result.id}` },
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,27 +1,18 @@
import { z } from "zod"
import { getMemoryText } from "../client"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
?.containerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = {
const inputSchema = z.object({
query: z
.string()
.max(1000, "Query exceeds maximum length")
.describe("The search query to find relevant memories"),
includeProfile: z.boolean().optional().default(true),
...containerTagField,
}
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"search_memory",
@ -31,12 +22,7 @@ export function register(deps: ToolDeps) {
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (rawArgs) => {
const args = rawArgs as {
query: string
includeProfile?: boolean
containerTag?: string
}
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)

View file

@ -1,21 +1,22 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"select-workspace",
{
title: "Select Workspace",
description:
"Choose which container tag to work in. Shows available container tags as interactive cards.",
inputSchema: {},
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
inputSchema: z.object({}),
_meta: appToolMeta(),
},
async () => {
try {
const viewId = crypto.randomUUID()
const client = deps.getClient()
const [tags, session, activeTag] = await Promise.all([
client.listContainerTags(),
@ -29,6 +30,7 @@ export function register(deps: ToolDeps) {
const sc: ViewMessage = {
view: "picker",
viewId,
containerTags: tags,
activeTag,
assignedTags,
@ -42,6 +44,7 @@ export function register(deps: ToolDeps) {
},
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,27 +1,24 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"set-active-tag",
{
description: "Set the active container tag for this session",
inputSchema: {
containerTag: z.string().min(1),
},
_meta: {
ui: {
resourceUri: SUPERMEMORY_RESOURCE_URI,
visibility: ["app"],
},
},
description: "Set the active container tag for this account",
inputSchema: z.object({
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
_meta: appToolMeta(["app"]),
},
async (args) => {
const containerTag = (args as { containerTag: string }).containerTag
const { containerTag } = args
try {
const viewId = args.viewId ?? crypto.randomUUID()
const tags = await deps.getClient().listContainerTags()
if (!tags.some((tag) => tag.containerTag === containerTag)) {
return deps.errorResult(
@ -31,6 +28,7 @@ export function register(deps: ToolDeps) {
await deps.setActiveContainerTag(containerTag)
const sc: ViewMessage = {
view: "confirmation",
viewId,
containerTag,
}
return {
@ -41,6 +39,7 @@ export function register(deps: ToolDeps) {
},
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,19 +1,21 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { Props, SessionInfo } from "../../shared/types"
import type { McpServer, ServerContext } from "@modelcontextprotocol/server"
import type { SessionInfo } from "../../shared/types"
import type { SupermemoryClient } from "../client"
import type { ActorContext } from "../types"
// Dependencies passed to every tool's register() function.
// Keep this surface small — tools should read this rather than reach into the agent.
export interface ToolDeps {
server: McpServer
props: Props | undefined
actor: ActorContext
getClient: (containerTag?: string) => SupermemoryClient
getSession: () => Promise<SessionInfo>
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
getActiveContainerTag: () => Promise<string | undefined>
setActiveContainerTag: (containerTag: string) => Promise<void>
getClientInfo: () => { name: string; version?: string } | null
getMcpSessionId: () => string
getClientInfo: (
context: ServerContext,
) => { name: string; version?: string } | null
errorResult: (error: unknown) => {
content: { type: "text"; text: string }[]
isError: true

View file

@ -1,37 +1,28 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"upload-file-submit",
{
description: "Submit a file upload",
inputSchema: {
inputSchema: z.object({
fileData: z.string().describe("Base64-encoded file content"),
fileName: z.string(),
mimeType: z.string(),
containerTag: z.string().min(1),
},
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
annotations: MEMORY_TOOL_ANNOTATIONS,
_meta: {
ui: {
resourceUri: SUPERMEMORY_RESOURCE_URI,
visibility: ["app"],
},
},
_meta: appToolMeta(["app"]),
},
async (rawArgs) => {
const args = rawArgs as {
fileData: string
fileName: string
mimeType: string
containerTag: string
}
async (args) => {
try {
const viewId = args.viewId ?? crypto.randomUUID()
const binaryString = atob(args.fileData)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
@ -48,6 +39,7 @@ export function register(deps: ToolDeps) {
const sc: ViewMessage = {
view: "upload-success",
viewId,
id: result.id,
fileName: args.fileName,
containerTag: args.containerTag,
@ -61,6 +53,7 @@ export function register(deps: ToolDeps) {
},
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,20 +1,21 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
deps.server.registerTool(
"upload-file",
{
title: "Upload File",
description: "Upload a file (PDF, text, image, video) to memory.",
inputSchema: {},
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
inputSchema: z.object({}),
_meta: appToolMeta(),
},
async () => {
try {
const viewId = crypto.randomUUID()
const [activeTag, tags, session] = await Promise.all([
deps.getActiveContainerTag(),
deps.getClient().listContainerTags(),
@ -29,6 +30,7 @@ export function register(deps: ToolDeps) {
const sc: ViewMessage = {
view: "upload",
viewId,
activeTag,
writableTags,
}
@ -38,6 +40,7 @@ export function register(deps: ToolDeps) {
{ type: "text" as const, text: "Opening file upload form..." },
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,3 +1,4 @@
import { z } from "zod"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
@ -6,10 +7,10 @@ export function register(deps: ToolDeps) {
"whoAmI",
{
description: "Get current user info, role, and workspace context",
inputSchema: {},
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async () => {
async (_args, context) => {
try {
const [session, activeTag] = await Promise.all([
deps.getSession(),
@ -31,8 +32,8 @@ export function register(deps: ToolDeps) {
? session.containerTags
: null,
scope: session.scope,
client: deps.getClientInfo(),
sessionId: deps.getMcpSessionId(),
client: deps.getClientInfo(context),
sessionId: context.sessionId ?? null,
}),
},
],

View file

@ -0,0 +1,15 @@
import type { WorkspaceState } from "./workspace-state"
export interface ActorContext {
userId: string
organizationId: string
bearerToken: string
oauthClientId?: string
}
export interface ServerEnv {
WORKSPACE_STATE: DurableObjectNamespace<WorkspaceState>
API_URL?: string
MCP_RESOURCE?: string
ALLOWED_MCP_ORIGIN_HOSTNAMES?: string
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest"
import type { ContainerTag } from "../shared/types"
import {
compactDescription,
formatFactSection,
formatWorkspaceRow,
sortWorkspaces,
} from "./workspace-presentation"
const workspace = (
containerTag: string,
lastActivityAt: string | null,
): ContainerTag => ({
id: containerTag,
name: `Workspace ${containerTag}`,
containerTag,
description: "A compact workspace description.",
visibility: "private",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
isExperimental: false,
isNova: false,
documentCount: 2,
memoryCount: 3,
lastActivityAt,
})
describe("workspace presentation", () => {
it("keeps the active workspace first, then sorts by activity", () => {
const sorted = sortWorkspaces(
[
workspace("older", "2026-01-01T00:00:00.000Z"),
workspace("active", "2025-01-01T00:00:00.000Z"),
workspace("newer", "2026-02-01T00:00:00.000Z"),
],
"active",
)
expect(sorted.map((item) => item.containerTag)).toEqual([
"active",
"newer",
"older",
])
})
it("formats compact rows without internal database IDs", () => {
const row = formatWorkspaceRow(
workspace("project-key", "2026-07-29T19:44:28.177Z"),
"project-key",
)
expect(row).toContain("[project-key] · Active")
expect(row).toContain("2 documents · 3 memories")
expect(row).toContain("Last active Jul 29, 2026")
expect(row).not.toContain('"id"')
})
it("caps descriptions and fact lists", () => {
expect(compactDescription("A".repeat(30), 12)).toBe("AAAAAAAAA...")
expect(
formatFactSection("Recent Context", ["one", "two", "three"], 2),
).toEqual(["## Recent Context", "- one", "- two", "- +1 more"])
})
})

View file

@ -0,0 +1,104 @@
import type { ContainerTag } from "../shared/types"
const DEFAULT_DESCRIPTION_LIMIT = 160
const plural = (count: number, singular: string, pluralForm: string) =>
`${count} ${count === 1 ? singular : pluralForm}`
export function workspaceDisplayName(
workspace: ContainerTag | undefined,
key: string,
): string {
return workspace?.name || key
}
export function compactDescription(
description: string | null | undefined,
limit = DEFAULT_DESCRIPTION_LIMIT,
): string | undefined {
const compact = description?.replace(/\s+/g, " ").trim()
if (!compact) return undefined
if (compact.length <= limit) return compact
const candidate = compact.slice(0, Math.max(0, limit - 3)).trimEnd()
const lastSpace = candidate.lastIndexOf(" ")
const boundary =
lastSpace >= Math.floor(limit * 0.6) ? lastSpace : candidate.length
return `${candidate.slice(0, boundary)}...`
}
export function formatActivityDate(value: string | null): string | undefined {
if (!value) return undefined
const date = new Date(value)
if (Number.isNaN(date.getTime())) return undefined
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date)
}
export function workspaceMetadata(workspace: ContainerTag): string {
const lastActivity = formatActivityDate(workspace.lastActivityAt)
const fields = [
workspace.visibility
? `${workspace.visibility.charAt(0).toUpperCase()}${workspace.visibility.slice(1)}`
: undefined,
plural(workspace.documentCount, "document", "documents"),
plural(workspace.memoryCount, "memory", "memories"),
lastActivity ? `Last active ${lastActivity}` : undefined,
]
return fields.filter(Boolean).join(" · ")
}
export function formatWorkspaceRow(
workspace: ContainerTag,
activeKey: string,
descriptionLimit = DEFAULT_DESCRIPTION_LIMIT,
): string {
const active = workspace.containerTag === activeKey ? " · Active" : ""
const metadata = workspaceMetadata(workspace)
const description = compactDescription(
workspace.description,
descriptionLimit,
)
const firstLine =
`- ${workspaceDisplayName(workspace, workspace.containerTag)} ` +
`[${workspace.containerTag}]${active}${metadata ? ` · ${metadata}` : ""}`
return description ? `${firstLine}\n ${description}` : firstLine
}
export function sortWorkspaces(
workspaces: ContainerTag[],
activeKey: string,
): ContainerTag[] {
return [...workspaces].sort((left, right) => {
if (left.containerTag === activeKey) return -1
if (right.containerTag === activeKey) return 1
const leftTime = left.lastActivityAt
? new Date(left.lastActivityAt).getTime()
: 0
const rightTime = right.lastActivityAt
? new Date(right.lastActivityAt).getTime()
: 0
return rightTime - leftTime
})
}
export function formatFactSection(
title: string,
facts: string[],
limit: number,
): string[] {
if (facts.length === 0) return []
const shown = facts.slice(0, limit)
const lines = [`## ${title}`, ...shown.map((fact) => `- ${fact}`)]
const remaining = facts.length - shown.length
if (remaining > 0) lines.push(`- +${remaining} more`)
return lines
}

View file

@ -0,0 +1,15 @@
import { DurableObject } from "cloudflare:workers"
import { containerTagSchema } from "./container-tag"
const ACTIVE_CONTAINER_TAG_KEY = "activeContainerTag"
export class WorkspaceState extends DurableObject {
async getActiveContainerTag(): Promise<string | undefined> {
return this.ctx.storage.get<string>(ACTIVE_CONTAINER_TAG_KEY)
}
async setActiveContainerTag(containerTag: string): Promise<void> {
const validatedTag = containerTagSchema.parse(containerTag)
await this.ctx.storage.put(ACTIVE_CONTAINER_TAG_KEY, validatedTag)
}
}

View file

@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest"
import { resolveContainerTag, workspaceStateName } from "./workspace"
describe("workspace application state", () => {
it("keys active state by organization and user without collisions", () => {
expect(
workspaceStateName({
organizationId: "org:one",
userId: "user:two",
}),
).not.toBe(
workspaceStateName({
organizationId: "org",
userId: "one:user:two",
}),
)
})
it("uses an explicit tool argument without reading active state", async () => {
const getActive = vi.fn().mockResolvedValue("active")
await expect(resolveContainerTag("explicit", getActive)).resolves.toBe(
"explicit",
)
expect(getActive).not.toHaveBeenCalled()
})
it("falls back to durable active state and then the client default", async () => {
await expect(
resolveContainerTag(undefined, vi.fn().mockResolvedValue("active")),
).resolves.toBe("active")
await expect(
resolveContainerTag(undefined, vi.fn().mockResolvedValue(undefined)),
).resolves.toBeUndefined()
})
})

View file

@ -0,0 +1,14 @@
import type { ActorContext } from "./types"
export function workspaceStateName(
actor: Pick<ActorContext, "organizationId" | "userId">,
): string {
return `workspace:${JSON.stringify([actor.organizationId, actor.userId])}`
}
export async function resolveContainerTag(
explicit: string | undefined,
getActiveContainerTag: () => Promise<string | undefined>,
): Promise<string | undefined> {
return explicit ?? (await getActiveContainerTag())
}

View file

@ -31,6 +31,8 @@ export interface ContainerTag {
id: string
name: string
containerTag: string
description?: string | null
visibility?: string | null
createdAt: string
updatedAt: string
isExperimental: boolean
@ -81,7 +83,7 @@ export interface DocumentsApiResponse {
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
// Adding a new view here is a compile error in App.tsx until the case is handled.
export type ViewMessage =
type ViewMessagePayload =
| {
view: "picker"
containerTags: ContainerTag[]
@ -114,15 +116,18 @@ export type ViewMessage =
containerTag?: string
}
export type ViewName = ViewMessage["view"]
// Auth context passed from the OAuth/API-key middleware into the McpAgent via ctx.props.
export type Props = {
userId: string
organizationId?: string
bearerToken: string
containerTag?: string
export type ViewMessage = ViewMessagePayload & {
/**
* Stable identity for one rendered widget instance.
*
* The host may remount the iframe when a conversation is revisited. The
* widget uses this id to restore a completed local view without treating UI
* state as the source of truth for the underlying Supermemory write.
*/
viewId?: string
}
export type ViewName = ViewMessage["view"]
// Hosts cache MCP UI resources by URI, so bump this when shipping a new widget bundle.
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v2.html"
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v3.html"

View file

@ -105,6 +105,7 @@ function renderView(
containerTags={msg.containerTags}
onAdvance={setView}
onError={setError}
viewId={msg.viewId}
/>
)
case "save":
@ -114,6 +115,7 @@ function renderView(
onAdvance={setView}
onError={setError}
prefill={msg.prefill}
viewId={msg.viewId}
writableTags={msg.writableTags}
/>
)
@ -123,6 +125,7 @@ function renderView(
activeTag={msg.activeTag}
onAdvance={setView}
onError={setError}
viewId={msg.viewId}
writableTags={msg.writableTags}
/>
)

View file

@ -2,6 +2,10 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useMemo } from "react"
import type { ViewMessage } from "../../shared/types"
import { app } from "../lib/app"
import {
handoffToModel as performModelHandoff,
type ModelHandoffRequest,
} from "../lib/modelHandoff"
export interface ToolCallResult<T = unknown> {
ok: boolean
@ -59,6 +63,15 @@ export function useApp() {
}
},
/**
* Publish a silent state snapshot, then explicitly return control to
* the conversation agent. The message is still attempted when a host
* rejects or drops model-context updates.
*/
handoffToModel(request: ModelHandoffRequest) {
return performModelHandoff(app, request)
},
/** Send a structured log line to the host. */
log(level: "debug" | "info" | "warning" | "error", message: string) {
return app.sendLog({ level, data: message })

View file

@ -2,6 +2,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useEffect, useState } from "react"
import type { ViewMessage } from "../../shared/types"
import { app } from "../lib/app"
import { loadViewCheckpoint, saveViewCheckpoint } from "../lib/viewCheckpoint"
function safeLog(
level: "debug" | "info" | "warning" | "error",
@ -37,7 +38,12 @@ export function useViewState(): {
setView: (msg: ViewMessage) => void
setError: (message: string) => void
} {
const [state, setState] = useState<ViewState>({ kind: "loading" })
const [state, setState] = useState<ViewState>(() => {
const checkpoint = loadViewCheckpoint()
return checkpoint
? { kind: "view", message: checkpoint }
: { kind: "loading" }
})
useEffect(() => {
app.ontoolinput = (input: unknown) => {
@ -63,7 +69,8 @@ export function useViewState(): {
if ("view" in sc) {
const msg = sc as ViewMessage
safeLog("info", `[host] ontoolresult: view=${msg.view}`)
setState({ kind: "view", message: msg })
const checkpoint = loadViewCheckpoint(msg.viewId)
setState({ kind: "view", message: checkpoint ?? msg })
return
}
safeLog("warning", "[host] ontoolresult: structuredContent without view")
@ -85,7 +92,10 @@ export function useViewState(): {
return {
state,
setView: (msg) => setState({ kind: "view", message: msg }),
setView: (msg) => {
saveViewCheckpoint(msg)
setState({ kind: "view", message: msg })
},
setError: (message) => setState({ kind: "error", message }),
}
}

View file

@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest"
import { handoffToModel } from "./modelHandoff"
function createApp(overrides?: {
updateModelContext?: () => Promise<unknown>
sendMessage?: () => Promise<{ isError?: boolean }>
}) {
return {
updateModelContext:
overrides?.updateModelContext ?? vi.fn(async () => ({})),
sendMessage: overrides?.sendMessage ?? vi.fn(async () => ({})),
}
}
const request = {
context: "Detailed state",
message: "Continue from the widget action",
structuredContent: { action: "saved" },
}
describe("handoffToModel", () => {
it("updates context before sending the portable conversation message", async () => {
const order: string[] = []
const app = createApp({
updateModelContext: vi.fn(async () => {
order.push("context")
}),
sendMessage: vi.fn(async () => {
order.push("message")
return {}
}),
})
const result = await handoffToModel(app, request, undefined)
expect(order).toEqual(["context", "message"])
expect(result).toEqual({
ok: true,
contextUpdate: { ok: true },
conversationMessage: { ok: true },
})
expect(app.updateModelContext).toHaveBeenCalledWith({
content: [{ type: "text", text: request.context }],
structuredContent: request.structuredContent,
})
})
it("prefers ChatGPT's follow-up helper when available", async () => {
const app = createApp()
const sendFollowUpMessage = vi.fn(async () => undefined)
const result = await handoffToModel(app, request, {
sendFollowUpMessage,
})
expect(result.conversationMessage).toEqual({ ok: true })
expect(sendFollowUpMessage).toHaveBeenCalledWith({
prompt: request.message,
scrollToBottom: true,
})
expect(app.sendMessage).not.toHaveBeenCalled()
})
it("falls back to the portable message when ChatGPT's helper fails", async () => {
const app = createApp()
const result = await handoffToModel(app, request, {
sendFollowUpMessage: vi.fn(async () => {
throw new Error("unavailable")
}),
})
expect(result.conversationMessage).toEqual({ ok: true })
expect(app.sendMessage).toHaveBeenCalledOnce()
})
it("still sends the conversation message when context publication fails", async () => {
const app = createApp({
updateModelContext: vi.fn(async () => {
throw new Error("unsupported")
}),
})
const result = await handoffToModel(app, request, undefined)
expect(result.ok).toBe(true)
expect(result.contextUpdate).toMatchObject({
ok: false,
error: "Error: unsupported",
})
expect(app.sendMessage).toHaveBeenCalledOnce()
})
it("reports a rejected conversation message as the failed handoff", async () => {
const app = createApp({
sendMessage: vi.fn(async () => ({ isError: true })),
})
const result = await handoffToModel(app, request, undefined)
expect(result.ok).toBe(false)
expect(result.conversationMessage).toEqual({
ok: false,
error: "Host rejected the MCP Apps message",
})
})
})

View file

@ -0,0 +1,115 @@
import type { ChatGptHostApi } from "./openaiHost"
import { getChatGptHostApi } from "./openaiHost"
interface ModelHandoffApp {
updateModelContext(params: {
content?: Array<{ type: "text"; text: string }>
structuredContent?: Record<string, unknown>
}): Promise<unknown>
sendMessage(params: {
role: "user"
content: Array<{ type: "text"; text: string }>
}): Promise<{ isError?: boolean }>
}
export interface HandoffStepResult {
ok: boolean
error?: string
}
export interface ModelHandoffResult {
ok: boolean
contextUpdate: HandoffStepResult
conversationMessage: HandoffStepResult
}
export interface ModelHandoffRequest {
context: string
message: string
structuredContent?: Record<string, unknown>
}
async function updateContext(
app: ModelHandoffApp,
request: ModelHandoffRequest,
): Promise<HandoffStepResult> {
try {
await app.updateModelContext({
content: [{ type: "text", text: request.context }],
structuredContent: request.structuredContent,
})
return { ok: true }
} catch (error) {
return { ok: false, error: String(error) }
}
}
async function sendConversationMessage(
app: ModelHandoffApp,
message: string,
chatGptHost: ChatGptHostApi | undefined,
): Promise<HandoffStepResult> {
let chatGptError: string | undefined
if (chatGptHost?.sendFollowUpMessage) {
try {
await chatGptHost.sendFollowUpMessage({
prompt: message,
scrollToBottom: true,
})
return { ok: true }
} catch (error) {
// Match OpenAI's own example: fall back to the portable MCP Apps
// message method when the ChatGPT-specific helper rejects.
chatGptError = String(error)
}
}
try {
const result = await app.sendMessage({
role: "user",
content: [{ type: "text", text: message }],
})
if (result.isError) {
return {
ok: false,
error: chatGptError
? `ChatGPT follow-up failed (${chatGptError}); host rejected the MCP Apps message`
: "Host rejected the MCP Apps message",
}
}
return { ok: true }
} catch (error) {
const mcpError = String(error)
return {
ok: false,
error: chatGptError
? `ChatGPT follow-up failed (${chatGptError}); MCP Apps message failed (${mcpError})`
: mcpError,
}
}
}
/**
* Keep the silent context snapshot and the explicit conversation turn as two
* separate operations. An accepted context update is not proof that a host
* attached it to a model turn, so the message is always attempted.
*/
export async function handoffToModel(
app: ModelHandoffApp,
request: ModelHandoffRequest,
chatGptHost: ChatGptHostApi | undefined = getChatGptHostApi(),
): Promise<ModelHandoffResult> {
const contextUpdate = await updateContext(app, request)
const conversationMessage = await sendConversationMessage(
app,
request.message,
chatGptHost,
)
return {
ok: conversationMessage.ok,
contextUpdate,
conversationMessage,
}
}

View file

@ -0,0 +1,18 @@
export interface ChatGptHostApi {
sendFollowUpMessage?: (args: {
prompt: string
scrollToBottom?: boolean
}) => Promise<unknown>
setWidgetState?: (state: unknown) => unknown
widgetState?: unknown
}
/**
* Optional ChatGPT host extensions. Shared MCP Apps methods remain the
* cross-host fallback; this bridge is used where ChatGPT provides a stronger
* follow-up or widget-state primitive.
*/
export function getChatGptHostApi(): ChatGptHostApi | undefined {
if (typeof window === "undefined") return undefined
return (window as Window & { openai?: ChatGptHostApi }).openai
}

View file

@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import type { ViewMessage } from "../../shared/types"
import { loadViewCheckpoint, saveViewCheckpoint } from "./viewCheckpoint"
function createStorage() {
const values = new Map<string, string>()
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value)
}),
removeItem: vi.fn((key: string) => {
values.delete(key)
}),
clear: vi.fn(() => values.clear()),
key: vi.fn(() => null),
get length() {
return values.size
},
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe("view checkpoints", () => {
it("restores a completed view from localStorage by stable view id", () => {
const storage = createStorage()
vi.stubGlobal("localStorage", storage)
const view: ViewMessage = {
view: "save-success",
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
id: "memory-123",
containerTag: "model_test",
}
saveViewCheckpoint(view)
expect(loadViewCheckpoint(view.viewId)).toEqual(view)
})
it("does not persist non-terminal form views", () => {
const storage = createStorage()
vi.stubGlobal("localStorage", storage)
const view: ViewMessage = {
view: "save",
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
writableTags: ["model_test"],
}
saveViewCheckpoint(view)
expect(storage.setItem).not.toHaveBeenCalled()
expect(loadViewCheckpoint(view.viewId)).toBeNull()
})
it("mirrors compact state into ChatGPT widget state", () => {
const storage = createStorage()
const setWidgetState = vi.fn()
vi.stubGlobal("localStorage", storage)
vi.stubGlobal("window", {
openai: {
widgetState: {
privateContent: { existing: true },
},
setWidgetState,
},
})
const view: ViewMessage = {
view: "confirmation",
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
containerTag: "model_test",
}
saveViewCheckpoint(view)
expect(setWidgetState).toHaveBeenCalledWith({
modelContent: 'Supermemory active workspace is now "model_test".',
privateContent: {
existing: true,
supermemoryView: view,
},
})
})
it("can restore from ChatGPT widget state before a tool result is replayed", () => {
const view: ViewMessage = {
view: "upload-success",
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
id: "document-123",
fileName: "notes.txt",
containerTag: "model_test",
}
vi.stubGlobal("window", {
openai: {
widgetState: {
privateContent: { supermemoryView: view },
},
},
})
expect(loadViewCheckpoint()).toEqual(view)
})
})

View file

@ -0,0 +1,143 @@
import type { ViewMessage, ViewName } from "../../shared/types"
import { getChatGptHostApi } from "./openaiHost"
const CHECKPOINT_VERSION = 1
const STORAGE_PREFIX = "supermemory:mcp:view:"
const CHECKPOINTABLE_VIEWS = new Set<ViewName>([
"confirmation",
"save-success",
"upload-success",
])
const VIEW_NAMES = new Set<ViewName>([
"picker",
"confirmation",
"save",
"save-success",
"upload",
"upload-success",
"graph",
])
interface CheckpointEnvelope {
version: number
view: ViewMessage
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function isViewMessage(value: unknown): value is ViewMessage {
if (!isRecord(value) || typeof value.view !== "string") return false
if (!VIEW_NAMES.has(value.view as ViewName)) return false
return value.viewId === undefined || typeof value.viewId === "string"
}
function checkpointKey(viewId: string): string {
return `${STORAGE_PREFIX}${viewId}`
}
function getStorage(): Storage | undefined {
try {
return typeof localStorage === "undefined" ? undefined : localStorage
} catch {
return undefined
}
}
function modelContentForView(view: ViewMessage): string {
switch (view.view) {
case "confirmation":
return `Supermemory active workspace is now "${view.containerTag}".`
case "save-success":
return `A memory was saved to Supermemory workspace "${view.containerTag}" with memory ID "${view.id}".`
case "upload-success":
return `"${view.fileName}" was uploaded to Supermemory workspace "${view.containerTag}" with document ID "${view.id}".`
default:
return "Supermemory widget state updated."
}
}
function readHostCheckpoint(): ViewMessage | null {
const state = getChatGptHostApi()?.widgetState
if (!isRecord(state) || !isRecord(state.privateContent)) return null
const checkpoint = state.privateContent.supermemoryView
return isViewMessage(checkpoint) ? checkpoint : null
}
function readLocalCheckpoint(viewId: string): ViewMessage | null {
const storage = getStorage()
if (!storage) return null
try {
const raw = storage.getItem(checkpointKey(viewId))
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed) || parsed.version !== CHECKPOINT_VERSION) return null
return isViewMessage(parsed.view) ? parsed.view : null
} catch {
return null
}
}
/**
* Load the latest completed presentation state for this widget instance.
* Business data remains authoritative on the server.
*/
export function loadViewCheckpoint(viewId?: string): ViewMessage | null {
const hostView = readHostCheckpoint()
if (hostView && (!viewId || hostView.viewId === viewId)) {
return hostView
}
if (!viewId) return null
const localView = readLocalCheckpoint(viewId)
return localView?.viewId === viewId ? localView : null
}
/**
* Save only compact terminal views. Forms and graph payloads can be large and
* should always be reconstructed from server tool results.
*/
export function saveViewCheckpoint(view: ViewMessage): void {
if (!view.viewId || !CHECKPOINTABLE_VIEWS.has(view.view)) return
const envelope: CheckpointEnvelope = {
version: CHECKPOINT_VERSION,
view,
}
const storage = getStorage()
if (storage) {
try {
storage.setItem(checkpointKey(view.viewId), JSON.stringify(envelope))
} catch {
// Sandboxed hosts may disable or quota-limit localStorage.
}
}
const chatGptHost = getChatGptHostApi()
if (!chatGptHost?.setWidgetState) return
const currentState = isRecord(chatGptHost.widgetState)
? chatGptHost.widgetState
: {}
const currentPrivate = isRecord(currentState.privateContent)
? currentState.privateContent
: {}
try {
const stateResult = chatGptHost.setWidgetState({
...currentState,
modelContent: modelContentForView(view),
privateContent: {
...currentPrivate,
supermemoryView: view,
},
})
void Promise.resolve(stateResult).catch(() => {
// The local checkpoint remains available if the async host write fails.
})
} catch {
// The local checkpoint still covers hosts without widget-state support.
}
}

View file

@ -17,6 +17,7 @@ interface Props {
assignedTags?: ContainerTagAccess[] | null
onAdvance: (msg: ViewMessage) => void
onError: (message: string) => void
viewId?: string
}
// Show the search box once the list is long enough to need it.
@ -28,8 +29,9 @@ export function Picker({
assignedTags,
onAdvance,
onError,
viewId,
}: Props) {
const { callTool, updateModelContext } = useApp()
const { callTool, handoffToModel } = useApp()
const log = useLog()
const [pending, setPending] = useState<string | null>(null)
const [query, setQuery] = useState("")
@ -49,6 +51,7 @@ export function Picker({
setPending(containerTag)
const result = await callTool<ViewMessage>("set-active-tag", {
containerTag,
viewId,
})
setPending(null)
if (!result.ok || !result.data) {
@ -57,13 +60,26 @@ export function Picker({
return
}
onAdvance(result.data)
const contextUpdate = await updateModelContext(
`Supermemory workspace selection changed. Active workspace: "${containerTag}". Use it for future Supermemory actions until another workspace is selected.`,
)
if (!contextUpdate.ok) {
const handoff = await handoffToModel({
context: `Supermemory workspace selection changed. Active workspace: "${containerTag}". Use it for future Supermemory actions until another workspace is selected.`,
message: `I selected "${containerTag}" as my active Supermemory workspace. Use this workspace for future Supermemory actions until I select another one.`,
structuredContent: {
supermemory: {
action: "workspace-selected",
activeWorkspace: containerTag,
},
},
})
if (!handoff.contextUpdate.ok) {
log(
"warning",
`[picker] model context update failed: ${contextUpdate.error}`,
`[picker] model context update failed: ${handoff.contextUpdate.error}`,
)
}
if (!handoff.conversationMessage.ok) {
log(
"warning",
`[picker] agent handoff failed: ${handoff.conversationMessage.error}`,
)
}
}

View file

@ -19,6 +19,7 @@ interface Props {
prefill?: string
onAdvance: (msg: ViewMessage) => void
onError: (message: string) => void
viewId?: string
}
export function Save({
@ -27,8 +28,9 @@ export function Save({
prefill,
onAdvance,
onError,
viewId,
}: Props) {
const { callTool, updateModelContext } = useApp()
const { callTool, handoffToModel } = useApp()
const log = useLog()
const [content, setContent] = useState(prefill ?? "")
const [selectedTag, setSelectedTag] = useState<string | null>(
@ -62,6 +64,7 @@ export function Save({
const result = await callTool<ViewMessage>("save-memory", {
content: trimmed,
containerTag: selectedTag,
viewId,
})
setSaving(false)
if (!result.ok || !result.data) {
@ -72,13 +75,28 @@ export function Save({
const memoryId =
result.data.view === "save-success" ? result.data.id : undefined
onAdvance(result.data)
const contextUpdate = await updateModelContext(
`Supermemory widget action completed. A memory was saved to workspace "${selectedTag}"${memoryId ? ` with memory ID "${memoryId}"` : ""}. It is already saved; do not save it again.`,
)
if (!contextUpdate.ok) {
const handoff = await handoffToModel({
context: `Supermemory widget action completed. A memory was saved to workspace "${selectedTag}"${memoryId ? ` with memory ID "${memoryId}"` : ""}. Saved content:\n\n${trimmed}\n\nIt is already saved; do not save it again.`,
message: `I used the Supermemory widget to save a memory to workspace "${selectedTag}"${memoryId ? ` (memory ID: ${memoryId})` : ""}. The memory is already saved; do not save it again.`,
structuredContent: {
supermemory: {
action: "memory-saved",
activeWorkspace: selectedTag,
memoryId,
content: trimmed,
},
},
})
if (!handoff.contextUpdate.ok) {
log(
"warning",
`[save] model context update failed: ${contextUpdate.error}`,
`[save] model context update failed: ${handoff.contextUpdate.error}`,
)
}
if (!handoff.conversationMessage.ok) {
log(
"warning",
`[save] agent handoff failed: ${handoff.conversationMessage.error}`,
)
}
}

View file

@ -20,6 +20,7 @@ interface Props {
writableTags: string[]
onAdvance: (msg: ViewMessage) => void
onError: (message: string) => void
viewId?: string
}
function formatFileSize(bytes: number): string {
@ -30,8 +31,14 @@ function formatFileSize(bytes: number): string {
const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4"
export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
const { callTool, updateModelContext } = useApp()
export function Upload({
activeTag,
writableTags,
onAdvance,
onError,
viewId,
}: Props) {
const { callTool, handoffToModel } = useApp()
const log = useLog()
const [file, setFile] = useState<File | null>(null)
const [selectedTag, setSelectedTag] = useState<string | null>(
@ -62,6 +69,7 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
fileName: file.name,
mimeType: file.type,
containerTag: selectedTag,
viewId,
})
if (!result.ok || !result.data) {
log("error", `[upload] failed: ${result.error}`)
@ -71,13 +79,28 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
const documentId =
result.data.view === "upload-success" ? result.data.id : undefined
onAdvance(result.data)
const contextUpdate = await updateModelContext(
`Supermemory widget action completed. "${file.name}" was uploaded to workspace "${selectedTag}"${documentId ? ` with document ID "${documentId}"` : ""}. It is already uploaded; do not upload it again.`,
)
if (!contextUpdate.ok) {
const handoff = await handoffToModel({
context: `Supermemory widget action completed. "${file.name}" was uploaded to workspace "${selectedTag}"${documentId ? ` with document ID "${documentId}"` : ""}. It is already uploaded; do not upload it again.`,
message: `I used the Supermemory widget to upload "${file.name}" to workspace "${selectedTag}"${documentId ? ` (document ID: ${documentId})` : ""}. The file is already uploaded; do not upload it again.`,
structuredContent: {
supermemory: {
action: "file-uploaded",
activeWorkspace: selectedTag,
documentId,
fileName: file.name,
},
},
})
if (!handoff.contextUpdate.ok) {
log(
"warning",
`[upload] model context update failed: ${contextUpdate.error}`,
`[upload] model context update failed: ${handoff.contextUpdate.error}`,
)
}
if (!handoff.conversationMessage.ok) {
log(
"warning",
`[upload] agent handoff failed: ${handoff.conversationMessage.error}`,
)
}
} catch (err) {

View file

@ -27,6 +27,10 @@
{
"name": "MCP_SERVER",
"class_name": "SupermemoryMCP"
},
{
"name": "WORKSPACE_STATE",
"class_name": "WorkspaceState"
}
]
},
@ -35,6 +39,10 @@
{
"tag": "v1",
"new_sqlite_classes": ["SupermemoryMCP"]
},
{
"tag": "v2",
"new_sqlite_classes": ["WorkspaceState"]
}
],

726
bun.lock

File diff suppressed because it is too large Load diff