@@ -77,6 +78,21 @@ No vector DB config. No embedding pipelines. No chunking strategies.
**[→ Jump to developer quickstart](#build-with-supermemory-api)**
+
+
+
+
+
+
🖥️ I want to run it myself
+
+State-of-the-art memory, on your machine. **One binary. Zero config.** Bring any model — or run fully offline with Ollama.
+
+```bash
+curl -fsSL https://supermemory.ai/install | bash
+```
+
+**[→ Jump to Supermemory local](#supermemory-local--run-it-yourself)**
+
@@ -301,6 +317,38 @@ Full API reference → [supermemory.ai/docs](https://supermemory.ai/docs)
---
+## Supermemory local — run it yourself
+
+State-of-the-art memory, on your machine. One binary. Zero config.
+
+```bash
+curl -fsSL https://supermemory.ai/install | bash
+# or
+npx supermemory local
+```
+
+```bash
+supermemory-server
+```
+
+First boot sets up the embedded Supermemory graph engine, local embeddings, and your credentials, then prints an API key. The full Memory API — documents, memories, user profiles, hybrid search — runs against `http://localhost:6767`.
+
+```typescript
+const client = new Supermemory({
+ apiKey: "sm_...",
+ baseURL: "http://localhost:6767", // that's the only change
+});
+```
+
+- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot.
+- **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine.
+- **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move.
+- **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`.
+
+Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, configuration, and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
+
+---
+
## Benchmarks
Supermemory is state of the art across all major AI memory benchmarks:
@@ -354,6 +402,7 @@ Your app / AI tool
- 📖 [Documentation](https://supermemory.ai/docs)
- 🚀 [Quickstart](https://supermemory.ai/docs/quickstart)
+- 🖥️ [Self-hosting (Supermemory local)](https://supermemory.ai/docs/self-hosting/overview)
- 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview)
- 🔌 [Integrations](https://supermemory.ai/docs/integrations)
- 💬 [Discord](https://supermemory.link/discord)
diff --git a/apps/docs/concepts/container-tags.mdx b/apps/docs/concepts/container-tags.mdx
new file mode 100644
index 00000000..71b8b0d7
--- /dev/null
+++ b/apps/docs/concepts/container-tags.mdx
@@ -0,0 +1,176 @@
+---
+title: "Container Tags"
+sidebarTitle: "Container Tags"
+description: "The isolation boundary that groups and partitions memories by user, project, or any logical scope"
+icon: "folder"
+---
+
+A **container tag** is the primary way you organize and isolate memories in Supermemory. It's a simple string identifier you attach to content when you add it — and that you pass back when you search, list, or update it.
+
+Think of a container tag as a **namespace**: every memory tagged with `user_alex` lives in its own isolated space, completely separate from memories tagged `user_jordan`. This is what makes Supermemory safe to use in multi-tenant applications — one user can never see another user's memories unless you explicitly query across both tags.
+
+
+
+ Bucket memories by user, project, agent, workspace, or any boundary that makes sense for your app.
+
+
+ Each container tag maps to its own vector namespace, so search and retrieval never leak across boundaries.
+
+
+
+---
+
+## How it works
+
+When you add a memory with a container tag, Supermemory automatically creates a **space** for that tag (scoped to your organization) if one doesn't already exist. You don't need to provision anything ahead of time — the first write with a new tag creates the container, and subsequent writes reuse it.
+
+```typescript
+// First call auto-creates the "user_alex" container
+await client.add({
+ content: "Alex prefers dark mode and concise answers",
+ containerTag: "user_alex",
+});
+
+// Later, retrieve only Alex's memories
+const results = await client.search.memories({
+ q: "what are the user's UI preferences?",
+ containerTag: "user_alex",
+});
+```
+
+Under the hood, each container tag is hashed into a dedicated vector namespace. Embeddings, chunks, and memory entries for one tag are stored and searched independently of every other tag — there is no shared index to filter through, which is why isolation is strict rather than best-effort.
+
+
+A container tag is an **opaque identifier you choose**. Supermemory does not parse meaning out of it — `user_123`, `project_mobile`, and `org:acme:team:growth` are all equally valid. Pick a convention that mirrors the access boundaries in your own application.
+
+
+---
+
+## Naming rules
+
+Container tags are validated on every request. A tag must:
+
+- Be **100 characters or less**
+- Contain only **alphanumeric characters, hyphens (`-`), underscores (`_`), and colons (`:`)**
+
+Matching pattern: `^[a-zA-Z0-9_:-]+$`
+
+```typescript
+// ✅ Valid
+"user_123"
+"project-mobile-app"
+"org:acme:user:john"
+"tenant_42_workspace_7"
+
+// ❌ Invalid — spaces, slashes, and other symbols are rejected
+"user 123"
+"project/mobile"
+"team@acme"
+```
+
+The colon is intentionally allowed so you can build **hierarchical** tags (for example `org:acme:user:john`) that encode several levels of structure in a single identifier.
+
+---
+
+## `containerTag` vs `containerTags`
+
+Supermemory's current API uses a **single** `containerTag` string per request.
+
+
+The plural `containerTags` array field is **deprecated**. It still works for backward compatibility on older (`/v3`) endpoints, but new integrations should use the singular `containerTag` string. The `/v4` API only accepts `containerTag`.
+
+
+| API field | Type | Status |
+|-----------|------|--------|
+| `containerTag` | `string` | ✅ Current — use this |
+| `containerTags` | `string[]` | ⚠️ Deprecated |
+
+---
+
+## Where container tags are used
+
+The same tag flows through the entire lifecycle of a memory. Pass it consistently and your data stays neatly partitioned.
+
+| Operation | Behavior |
+|-----------|----------|
+| **Add** | Writes the memory into the tag's container (auto-creating the space). |
+| **Search** | Restricts retrieval to the given tag's namespace. |
+| **List** | Returns only memories belonging to the tag(s). |
+| **Update / Delete** | Targets the memory inside the specified tag's container. |
+
+```typescript
+// Add
+await client.add({ content: "Q1 planning notes", containerTag: "project_q1" });
+
+// Search within the same container
+await client.search.memories({ q: "planning", containerTag: "project_q1" });
+
+// List everything in the container
+await client.documents.list({ containerTags: ["project_q1"] });
+```
+
+---
+
+## Access control
+
+Container tags are also an **authorization boundary**, not just an organizational one. Two mechanisms can restrict which tags a given caller may touch:
+
+- **API key scopes** — an API key can be limited to a specific set of container tags, with read or write permission per tag.
+- **Member restrictions** — an organization member can be granted access to only certain container tags.
+
+When a request is restricted, Supermemory validates the requested tag against the caller's allowed set:
+
+- Requesting a tag outside the allowed set returns `403 Forbidden`.
+- A write (add/update/delete) to a read-only tag returns `403 Forbidden`.
+- If no tag is supplied by a restricted caller, the request is automatically scoped to their allowed tag(s).
+
+This means you can hand out an API key that is physically incapable of reading or writing another tenant's data, enforced at the data layer rather than in your application code.
+
+---
+
+## Per-container settings
+
+Each container tag can carry its own configuration, independent of other tags in the same organization:
+
+| Setting | Purpose |
+|---------|---------|
+| `name` | A human-friendly display name for the container. |
+| `entityContext` | A custom context prompt applied when processing documents in this container — useful for steering extraction and summarization per project or tenant. |
+
+```typescript
+await client.containerTags.update("project_research", {
+ entityContext: "This project contains research papers about machine learning.",
+});
+```
+
+Container tags can also be **merged** when you need to consolidate two buckets of memories into one.
+
+---
+
+## Choosing a convention
+
+Pick a tagging scheme that maps onto the isolation boundaries your application actually needs.
+
+| Pattern | Example | Use case |
+|---------|---------|----------|
+| User isolation | `user_{userId}` | Per-user memory in a consumer app |
+| Project grouping | `project_{projectId}` | Project- or workspace-scoped content |
+| Agent scoping | `agent_{agentId}` | Separate long-term memory per AI agent |
+| Hierarchical | `org:{orgId}:user:{userId}` | Multi-level, multi-tenant SaaS |
+
+
+Keep tags **deterministic** — derive them directly from IDs you already have (a user ID, a tenant ID) so you can always reconstruct the right tag at query time without a lookup.
+
+
+---
+
+## Next steps
+
+
+
+ Combine container tags with metadata filters for precise retrieval.
+
+
+ See container tags in action across the add API.
+
+
diff --git a/apps/docs/deployment/self-hosting.mdx b/apps/docs/deployment/self-hosting.mdx
deleted file mode 100644
index 7f7db4d3..00000000
--- a/apps/docs/deployment/self-hosting.mdx
+++ /dev/null
@@ -1,243 +0,0 @@
----
-title: 'Self Hosting'
-description: 'Deploy your own instance of the supermemory API on Cloudflare Workers'
----
-
-
-This guide is intended for **enterprise customers only** who have specifically opted for self-hosting as part of their enterprise plan. If you're on a standard plan, please use our hosted API at [console.supermemory.ai](https://console.supermemory.ai).
-
-
-## Prerequisites
-
-Before you start, you'll need to gather several API keys and set up accounts with various services. This comprehensive guide will walk you through obtaining each required component.
-
-### Enterprise Deployment Package
-
-Your enterprise deployment package is provided by the supermemory team and contains:
-- Your unique Host ID (`NEXT_PUBLIC_HOST_ID`)
-- The compiled JavaScript bundle
-- The deployment script
-
-Contact your supermemory enterprise representative to receive your deployment package.
-
-### Cloudflare
-
-
-#### Create Account
-
-1. Go to [cloudflare.com](https://dash.cloudflare.com/sign-up) and create an account
-3. Your **Account ID** is the long randon string in the URL bar
-
-#### Create API Token
-
-1. Navigate to [Cloudflare API Tokens](https://dash.cloudflare.com/?to=/:account/api-tokens)
-2. Click **"Create Token"**
-3. Use the **"Custom token"** template
-4. Configure the token with these permissions:
- - **Account:AI Gateway:Edit**
- - **Account:Hyperdrive:Edit**
- - **Account:Workers KV Storage:Edit**
- - **Account:Workers R2 Storage:Edit**
-7. Click **"Continue to summary"** → **"Create Token"**
-8. **Important**: Copy and securely store the token immediately (it won't be shown again)
-
-#### Enable Workers
-
-1. In your Cloudflare dashboard, go to **Workers & Pages**
-2. If prompted, accept the Workers terms of service
-3. Choose a subdomain for your workers (e.g., `yourcompany.workers.dev`)
-
-Your `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are now ready.
-
-### Database
-
-You'll need to provide a PostgreSQL connection string via the `DATABASE_URL` environment variable.
-
-The database must:
-- Support the **pgvector extension** for vector operations
-- Be accessible from Cloudflare Workers
-- Support SSL connections
-- Allow connections from Cloudflare's IP ranges
-
-Your connection string should follow this format:
-```
-postgresql://username:password@hostname:port/database
-```
-
-### LLM Providers
-
-#### OpenAI
-
-1. Go to [platform.openai.com](https://platform.openai.com)
-2. Sign in or create an account
-3. Navigate to **API Keys** in the left sidebar
-4. Click **"Create new secret key"**
-5. Name your key (e.g., "supermemory Self-Hosted")
-6. Copy the key and store it securely
-7. Add billing information if you haven't already
-
-#### Anthropic
-
-1. Go to [console.anthropic.com](https://console.anthropic.com)
-2. Create an account and complete verification
-3. Navigate to **API Keys**
-4. Click **"Create Key"**
-5. Name your key and copy it securely
-
-#### Gemini
-
-1. Go to [Google AI Studio](https://aistudio.google.com)
-2. Sign in with your Google account
-3. Click **"Get API key"** → **"Create API key"**
-4. Choose an existing Google Cloud project or create a new one
-5. Copy your API key
-
-#### Groq
-
-1. Go to [console.groq.com](https://console.groq.com)
-2. Sign up for an account
-3. Navigate to **API Keys**
-4. Click **"Create API Key"**
-5. Name your key and copy it
-
-
-
-{/* TODO: Add OAuth documentation */}
-{/* ### Authentication Providers
-#### GitHub OAuth (Optional)
-
-1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
-2. Click **"New OAuth App"**
-3. Fill in the application details:
- - **Application name**: Your app name
- - **Homepage URL**: Your API domain (e.g., `https://api.yourdomain.com`)
- - **Authorization callback URL**: `https://api.yourdomain.com/api/auth/callback/github`
-4. Click **"Register application"**
-5. Note the **Client ID** and generate a **Client Secret**
-6. Use these for `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET`
-
-#### Google OAuth (Optional)
-
-1. Go to [Google Cloud Console](https://console.cloud.google.com)
-2. Create a new project or select an existing one
-3. Enable the **Google+ API**
-4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
-5. Choose **Web application**
-6. Add your domain to **Authorized JavaScript origins**
-7. Add `https://api.yourdomain.com/api/auth/callback/google` to **Authorized redirect URIs**
-8. Copy the **Client ID** and **Client secret**
-9. Use these for `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` */}
-
-### Email Service Setup
-
-#### Resend
-
-1. Go to [resend.com](https://resend.com) and create an account
-2. Navigate to **API Keys**
-3. Click **"Create API Key"**
-4. Name your key (e.g., "supermemory Production")
-5. Copy the key for `RESEND_API_KEY`
-6. Verify your sending domain in the **Domains** section
-
-### Connectors (Optional)
-
-#### Google Drive
-
-1. Go to [Google Cloud Console](https://console.cloud.google.com)
-2. Create or select a project
-3. Enable the **Google Drive API**
-4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
-5. Configure the OAuth consent screen if required
-6. Choose **Web application**
-7. Add authorized redirect URIs for your domain
-8. Copy `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
-
-#### Microsoft OneDrive
-
-1. Go to [Azure Portal](https://portal.azure.com)
-2. Navigate to **Microsoft Entra ID** → **App registrations**
-3. Click **"New registration"**
-4. Name your app and set redirect URI
-5. Go to **Certificates & secrets** → **New client secret**
-6. Copy the **Application (client) ID** and **Client secret**
-7. Use for `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET`
-
-#### Notion
-
-1. Go to [Notion Developers](https://developers.notion.com)
-2. Click **"Create new integration"**
-3. Fill in the integration details
-4. Copy the **Internal Integration Token**
-5. Set up OAuth if needed for user connections
-6. Use for `NOTION_CLIENT_ID` and `NOTION_CLIENT_SECRET`
-
----
-
-## Setup deployment files
-
-Extract the deployment package provided by the supermemory team to your preferred directory:
-
-```bash
-# Extract the deployment package
-$ unzip supermemory-enterprise-deployment.zip
-$ cd supermemory-deployment
-```
-
----
-
-## Configure environment variables
-
-The deployment script reads **all** environment variables from your shell at runtime. We ship an example file that lists the full set supported by the worker.
-
-```bash
-# Copy the template and start editing
-$ cp packages/alchemy/env.example .env
-
-# Open the file in your editor of choice and fill in the blanks
-$ $EDITOR .env
-```
-
-Below is a quick reference.
-**Required** values are mandatory for a successful deploy – leave optional ones empty if you don't need the related feature.
-
-| Name | Required? | Description |
-|------|-----------|-------------|
-| `NODE_ENV` | ✅ | `development`, `staging` or `production`. |
-| `NEXT_PUBLIC_HOST_ID` | ✅ | Your unique Host ID provided by the supermemory team. |
-| `BETTER_AUTH_SECRET` | ✅ | Random 32-byte string – run `openssl rand -base64 32`. |
-| `BETTER_AUTH_URL` | ✅ | Public base URL for the API (no trailing `/`). Example: `https://api.example.com`. |
-| `DATABASE_URL` | ✅ | Postgres connection string (e.g. `postgres://user:pass@host:5432/db`). |
-| `CLOUDFLARE_ACCOUNT_ID` | ✅ | Your Cloudflare account ID. |
-| `CLOUDFLARE_API_TOKEN` | ✅ | Token created in *Prerequisites*. |
-| `OPENAI_API_KEY` | ✅ | Key from [platform.openai.com](https://platform.openai.com). |
-| `RESEND_API_KEY` | ✅ | E-mail provider key if you plan to send e-mails. |
-| `ANTHROPIC_API_KEY` | | Needed to use Claude models. |
-| `GEMINI_API_KEY` | | Key for Google Gemini models. |
-| `GROQ_API_KEY` | | Key for Groq models. |
-| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | | Enable GitHub OAuth login. |
-| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | | Enable Google OAuth login. |
-| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | | Needed for Google Drive connector. |
-| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | | Needed for OneDrive connector. |
-| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | | Needed for Notion connector. |
-| `CLOUDFLARE_AI_GATEWAY_NAME` / `CLOUDFLARE_AI_GATEWAY_TOKEN` | | Only if you want to route requests through an AI Gateway. |
-| `SENTRY_DSN` | | If you use Sentry for error reporting. |
-
----
-
-## Deploy
-
-With your `.env` in place, run the deployment script:
-
-```bash
-# Run the deployment script provided in your package
-$ bun ./deploy.ts
-```
-
-
----
-
-## Updating Your Deployment
-
-To update your supermemory deployment, follow the same process as the initial deployment described in the **Deploy** section above. You can reuse your existing `.env` file and add/remove any new environment variables as needed.
-
----
\ No newline at end of file
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index f2bb9b80..6be467ff 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -71,6 +71,15 @@
"group": "Getting Started",
"pages": ["intro", "quickstart", "vibe-coding"]
},
+ {
+ "group": "Self-Hosting",
+ "pages": [
+ "self-hosting/overview",
+ "self-hosting/quickstart",
+ "self-hosting/configuration",
+ "self-hosting/local-vs-enterprise"
+ ]
+ },
{
"group": "Concepts",
"pages": [
@@ -79,6 +88,7 @@
"concepts/content-types",
"concepts/super-rag",
"concepts/memory-vs-rag",
+ "concepts/container-tags",
"concepts/filtering",
"concepts/user-profiles",
"concepts/customization",
diff --git a/apps/docs/integrations/claude-code.mdx b/apps/docs/integrations/claude-code.mdx
index 7e12099d..20401471 100644
--- a/apps/docs/integrations/claude-code.mdx
+++ b/apps/docs/integrations/claude-code.mdx
@@ -19,6 +19,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
[Claude-Supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
+
+**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
+
+
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:
diff --git a/apps/docs/integrations/codex.mdx b/apps/docs/integrations/codex.mdx
index 46eef945..6307d996 100644
--- a/apps/docs/integrations/codex.mdx
+++ b/apps/docs/integrations/codex.mdx
@@ -10,6 +10,10 @@ icon: "terminal"
- **Implicit** (hooks) — automatically recalls context before each prompt and captures conversations after each session.
- **Explicit** (skills) — lets you or the agent save, search, and manage memories on demand.
+
+**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` (or set `baseUrl` in `~/.codex/supermemory.json`) and use the API key printed on first boot.
+
+
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then export it in your shell profile:
diff --git a/apps/docs/integrations/openclaw.mdx b/apps/docs/integrations/openclaw.mdx
index 436022e8..aacc7ef0 100644
--- a/apps/docs/integrations/openclaw.mdx
+++ b/apps/docs/integrations/openclaw.mdx
@@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
[OpenClaw](https://github.com/supermemoryai/openclaw-supermemory) is a multi-platform AI messaging gateway that connects to WhatsApp, Telegram, Discord, Slack, iMessage, and other messaging channels. The Supermemory plugin gives OpenClaw memory across every channel.
+
+**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_BASE_URL="http://localhost:6767"` (or set `baseUrl` in the plugin config) and use the API key printed on first boot.
+
+
## Install the Plugin
Get started by installing the plugin with a single command.
diff --git a/apps/docs/integrations/opencode.mdx b/apps/docs/integrations/opencode.mdx
index d1a11b67..9f47706c 100644
--- a/apps/docs/integrations/opencode.mdx
+++ b/apps/docs/integrations/opencode.mdx
@@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
[OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
+
+**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
+
+
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:
diff --git a/apps/docs/integrations/supermemory-sdk.mdx b/apps/docs/integrations/supermemory-sdk.mdx
index 33c80517..474976d5 100644
--- a/apps/docs/integrations/supermemory-sdk.mdx
+++ b/apps/docs/integrations/supermemory-sdk.mdx
@@ -14,6 +14,10 @@ icon: "/images/supermemory.svg"
+
+Both SDKs also work against [self-hosted Supermemory](/self-hosting/overview) — pass `baseURL: "http://localhost:6767"` (TypeScript) or `base_url="http://localhost:6767"` (Python) when creating the client.
+
+
## Installation
diff --git a/apps/docs/intro.mdx b/apps/docs/intro.mdx
index 0712406d..319a55ea 100644
--- a/apps/docs/intro.mdx
+++ b/apps/docs/intro.mdx
@@ -82,5 +82,8 @@ All three approaches share the **same context pool** when using the same user ID
Understand the knowledge graph architecture
+
+ Run Supermemory on your own machine — one binary, zero config, fully offline
+
diff --git a/apps/docs/quickstart.mdx b/apps/docs/quickstart.mdx
index 963f1389..8953c175 100644
--- a/apps/docs/quickstart.mdx
+++ b/apps/docs/quickstart.mdx
@@ -8,6 +8,10 @@ icon: "play"
**Using Vercel AI SDK?** Check out the [AI SDK integration](/integrations/ai-sdk) for the cleanest implementation with `@supermemory/tools/ai-sdk`.
+
+**Prefer to run it locally?** Supermemory is also a [self-hostable single binary](/self-hosting/overview) — `curl -fsSL https://supermemory.ai/install | bash` and you're running.
+
+
## Memory API
**Step 1.** Sign up for [Supermemory's Developer Platform](http://console.supermemory.ai) to get the API key. Click on **API Keys -> Create API Key** to generate one.
diff --git a/apps/docs/self-hosting/configuration.mdx b/apps/docs/self-hosting/configuration.mdx
new file mode 100644
index 00000000..49580e6d
--- /dev/null
+++ b/apps/docs/self-hosting/configuration.mdx
@@ -0,0 +1,135 @@
+---
+title: "Self-Hosting Configuration"
+sidebarTitle: "Configuration"
+description: "Every environment variable the self-hosted server understands."
+icon: "settings"
+---
+
+The self-hosted server aims for **zero configuration** — the only thing it needs is one model provider key, which the first-boot wizard collects interactively (or set it via env var for non-interactive deployments). Everything else below is opt-in, layered on top as you need it.
+
+The installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager.
+
+## Core
+
+| Variable | Purpose | Default |
+|---|---|---|
+| `PORT` (or `SUPERMEMORY_PORT`) | HTTP listen port | `6767` |
+| `SUPERMEMORY_DATA_DIR` | Where the graph engine's data, auth secret, and model cache live | `./.supermemory` |
+
+## LLM providers
+
+In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**:
+
+| Variable | Provider |
+|---|---|
+| `OPENAI_API_KEY` | OpenAI — or any OpenAI-compatible endpoint, see below |
+| `ANTHROPIC_API_KEY` | Anthropic |
+| `GEMINI_API_KEY` | Google AI Studio (Gemini) |
+| `GROQ_API_KEY` | Groq |
+| `WORKERS_AI_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Workers AI |
+| `GOOGLE_VERTEX_PROJECT_ID` + `GOOGLE_VERTEX_LOCATION` | GCP Vertex AI |
+
+
+No key set? The server walks you through it. On first boot, an interactive setup wizard asks which provider you want, securely prompts for the key, and saves it encrypted — including a custom base URL and model name if you pick an OpenAI-compatible endpoint.
+
+
+With multiple providers configured, the first one in the order above is used.
+
+
+Image, video, and high-fidelity PDF understanding require a Gemini or Vertex AI key. Text ingestion, memory extraction, and search work with any provider.
+
+
+### Fully offline with local models
+
+`OPENAI_API_KEY` + `OPENAI_BASE_URL` covers any OpenAI-compatible endpoint: Ollama, LM Studio, vLLM, llama.cpp server, Together, Fireworks, and more.
+
+```bash
+# Ollama example — gpt-oss-20b works great
+OPENAI_BASE_URL=http://localhost:11434/v1
+OPENAI_API_KEY=ollama # any non-empty string for local runners
+OPENAI_MODEL=gpt-oss:20b
+```
+
+| Variable | Purpose | Default |
+|---|---|---|
+| `OPENAI_BASE_URL` | OpenAI-compatible endpoint URL | OpenAI |
+| `OPENAI_MODEL` | Model ID sent to that endpoint | `gpt-5.1` |
+| `OPENAI_FAST_MODEL` | Override for fast/light tasks | `OPENAI_MODEL` |
+| `OPENAI_TEXT_MODEL` | Override for heavier text tasks | `OPENAI_MODEL` |
+
+## File storage
+
+Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`.
+
+## Embedding performance
+
+Local embeddings are prewarmed at startup with conservative defaults — one worker, minimal CPU footprint. Turn these up if you're ingesting heavily and prefer throughput over headroom:
+
+| Variable | Purpose | Default |
+|---|---|---|
+| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` |
+| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` |
+| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` |
+| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` |
+| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset |
+
+## Memory limits & ingestion queue
+
+The server manages memory for you and separates the two kinds of work you send it:
+
+- **Searches are always served immediately.** They never wait behind ingestion, regardless of how much is queued.
+- **Adds are accepted instantly but processed through a queue.** A `POST /v3/documents` call returns in milliseconds with status `queued`; extraction, embedding, and indexing happen in the background at a controlled pace.
+
+Ingestion may grow the server's memory usage by at most `SUPERMEMORY_EMBEDDING_RAM_LIMIT` (default **1 GB**) above its post-boot baseline. Past that, new documents simply wait in the queue until memory drops back under the limit — nothing is dropped, ingestion just slows down. The limit is measured above the boot baseline because the built-in local embeddings and storage engine have a fixed footprint that exists before any document is processed.
+
+The limit is printed at boot, and whenever adds are waiting the binary shows a live status line in the terminal:
+
+```
+[ingest] memory limit 1.0 GB above baseline (1.6 GB) · 2 concurrent — set SUPERMEMORY_EMBEDDING_RAM_LIMIT=ngb to change
+[ingest] 2 running · 193 queued · 0.4 GB / 1.0 GB ingest memory
+[ingest] 2 running · 193 queued · paused — 1.1 GB / 1.0 GB ingest memory, waiting for it to drop
+[ingest] resumed — memory back under the 1.0 GB ingest limit
+```
+
+| Variable | Purpose | Default |
+|---|---|---|
+| `SUPERMEMORY_EMBEDDING_RAM_LIMIT` | Memory ingestion may use above the boot baseline. Accepts `1gb`, `1.5gb`, `512mb`, or a bare number (GB). | `1gb` |
+| `SUPERMEMORY_INGEST_CONCURRENCY` | Documents processed concurrently | `2` |
+
+```bash
+# Give ingestion 4 GB of headroom on a larger machine
+SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server
+```
+
+Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly.
+
+## Telemetry
+
+The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch:
+
+| Variable | Purpose | Default |
+|---|---|---|
+| `SUPERMEMORY_DISABLE_TELEMETRY` | Set to `1` to also disable internal AI SDK telemetry instrumentation | unset |
+
+## Platform-only features
+
+These exist in the codebase but are exclusive to the [hosted platform](https://console.supermemory.ai) — the self-hosted binary doesn't include them:
+
+- **Connectors** — Google Drive, Notion, Gmail, OneDrive background sync
+- **Supermemory MCP** — managed MCP server endpoints
+- **Optimized memory extraction** — the platform's extraction pipeline is tuned for higher quality at lower cost than bring-your-own-key
+- **Managed scale** — globally distributed infrastructure, no capacity planning
+
+Any other environment variables you may find referenced in the codebase are platform-only: the self-hosted binary ignores them even when set.
+
+## Example: production-ish `.env`
+
+```dotenv
+# Persistent data location
+SUPERMEMORY_DATA_DIR=/var/lib/supermemory
+
+# One LLM provider
+OPENAI_API_KEY=sk-...
+```
+
+That's enough for full ingestion, memory extraction, and hybrid search.
diff --git a/apps/docs/self-hosting/local-vs-enterprise.mdx b/apps/docs/self-hosting/local-vs-enterprise.mdx
new file mode 100644
index 00000000..3aecdf10
--- /dev/null
+++ b/apps/docs/self-hosting/local-vs-enterprise.mdx
@@ -0,0 +1,56 @@
+---
+title: "Local vs. Enterprise"
+sidebarTitle: "Local vs. Enterprise"
+description: "Supermemory local is for builders. Supermemory Enterprise is for organizations."
+icon: "building-2"
+---
+
+Supermemory local — the self-hosted binary — is free, open source, and built for individual developers: local-first workflows, prototyping, air-gapped experiments, privacy-sensitive side projects.
+
+**Supermemory Enterprise** is the full platform, run for your organization: the same memory engine with proprietary models, organizational controls, and infrastructure that scales with you — without you operating any of it.
+
+## At a glance
+
+| | Supermemory local | Enterprise |
+|---|---|---|
+| **Memory engine** | Full graph engine, embedded | Full graph engine, managed |
+| **Models** | Bring your own key (any provider, incl. fully offline) | Proprietary models tuned for long-horizon data understanding |
+| **Authentication** | Single auto-generated API key | Organization-wide authentication and access controls |
+| **Team access** | Single org on one machine | Multi-member organizations, roles, and scoped API keys |
+| **Observability** | Server logs | Control dashboard: usage analytics, ingestion monitoring, request logs |
+| **Control** | Env vars on your box | Org-wide settings, key management, and governance from the console |
+| **Connectors** | — | Google Drive, Notion, Gmail, OneDrive with continuous background sync |
+| **Scalability** | One machine, one process | Globally distributed, scales elastically with your workload |
+| **Hosting** | You run it | Fully managed — or dedicated deployments for compliance needs |
+| **Support** | Community ([GitHub](https://git.new/memory)) | Dedicated support, onboarding, and SLAs |
+
+## What Enterprise adds
+
+### Auth and team access
+
+Local runs as a single-tenant server with one API key. Enterprise gives your whole organization structured access: member roles, and API keys scoped per environment, per team, or per app — all revocable from one place.
+
+### Observability and the control dashboard
+
+Local gives you logs. Enterprise gives you the console: live usage analytics, ingestion pipeline visibility, search and request logs, and per-key attribution — so you always know what your agents are remembering, and what it costs.
+
+### Memory quality
+
+Local runs the extraction pipeline on whatever model you bring. Enterprise runs it on Supermemory's proprietary models, purpose-tuned for long-horizon data understanding — higher-quality memories at a lower effective cost than any bring-your-own-key setup.
+
+### Scale and hosting
+
+Local is bounded by one machine — which is the point. Enterprise runs on globally distributed infrastructure that scales with your ingestion volume and query load, with no capacity planning on your side. For strict data residency or compliance requirements, dedicated deployment options are available.
+
+## Moving between them
+
+The two speak the same API. Code written against your local server moves to Enterprise by changing the `baseURL` — and vice versa. Prototype locally, ship on Enterprise.
+
+
+
+ Get a walkthrough of Supermemory Enterprise for your team
+
+
+ Install the binary and build against the same API today
+
+
diff --git a/apps/docs/self-hosting/overview.mdx b/apps/docs/self-hosting/overview.mdx
new file mode 100644
index 00000000..b9db33e4
--- /dev/null
+++ b/apps/docs/self-hosting/overview.mdx
@@ -0,0 +1,85 @@
+---
+title: "Self-Hosting Supermemory"
+sidebarTitle: "Overview"
+description: "State-of-the-art memory, running on your machine. One binary, zero config."
+icon: "server"
+---
+
+Supermemory runs on your own hardware. It's the same memory engine behind the [hosted platform](https://console.supermemory.ai) — ingestion, memory extraction, hybrid semantic search, and the full API — as a single self-contained binary.
+
+
+```bash curl
+curl -fsSL https://supermemory.ai/install | bash
+```
+
+```bash npx
+npx supermemory local
+```
+
+
+No Docker. No database to provision. No config files. It boots in seconds with everything built in, and it's [open source](https://git.new/memory).
+
+## Zero config, actually
+
+Run the binary with nothing set and you get a complete memory system:
+
+- **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings.
+- **Built-in local embeddings** — vectors are computed on your machine. Nothing is sent anywhere to be embedded.
+- **An API key, generated for you** — printed on first boot, ready to paste into any SDK.
+- **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works.
+
+The only thing you bring is a model. In production, Supermemory runs its own proprietary models, purpose-tuned for long-horizon data understanding and memory extraction. Self-hosted, the same pipeline runs on whatever model you point it at — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. Bring a key and go. Or don't bring one at all:
+
+## Runs fully offline
+
+Supermemory works with any OpenAI-compatible endpoint, which means it runs end-to-end on your machine with a local model — Ollama, LM Studio, vLLM, llama.cpp. `gpt-oss-20b` is a great fit:
+
+```bash
+OPENAI_BASE_URL=http://localhost:11434/v1 \
+OPENAI_API_KEY=ollama \
+OPENAI_MODEL=gpt-oss:20b \
+supermemory-server
+```
+
+Local graph engine, local embeddings, local LLM. Your data never leaves the building.
+
+## Drop-in with your existing code
+
+The self-hosted server speaks the same API as the hosted platform. Point any Supermemory SDK at it with a one-line change:
+
+```typescript
+const client = new Supermemory({
+ apiKey: "sm_...", // printed on first boot
+ baseURL: "http://localhost:6767",
+})
+```
+
+Everything in the [Memory API docs](/quickstart) works the same way. The coding plugins do too — [Claude Code](/integrations/claude-code), [Codex](/integrations/codex), and [OpenCode](/integrations/opencode) all target your local server with `SUPERMEMORY_API_URL=http://localhost:6767`.
+
+## Self-hosted vs. the platform
+
+Self-hosted is free, open source, and great for local development, air-gapped environments, and privacy-sensitive workloads. The hosted platform is where the full product lives:
+
+| | Self-hosted | Platform |
+|---|---|---|
+| Full Memory API | ✅ | ✅ |
+| Hybrid semantic search | ✅ | ✅ |
+| Local embeddings | ✅ | Managed |
+| File ingestion (PDFs, images) | ✅ | ✅ |
+| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ |
+| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ |
+| Memory extraction | Your model, your key | Proprietary long-horizon models — higher quality, cheaper at scale |
+| Infrastructure | Your machine | Globally distributed, scales with you |
+
+If you outgrow a single machine — or want connectors, MCP, and the best-tuned extraction pipeline — [the platform](https://console.supermemory.ai) is one `baseURL` change away. Running this for a team or organization? See [Local vs. Enterprise](/self-hosting/local-vs-enterprise).
+
+## Next steps
+
+
+
+ Install, run, and store your first memory in under two minutes
+
+
+ Every environment variable: LLM providers, storage, auth, tuning
+
+
diff --git a/apps/docs/self-hosting/quickstart.mdx b/apps/docs/self-hosting/quickstart.mdx
new file mode 100644
index 00000000..713ea98b
--- /dev/null
+++ b/apps/docs/self-hosting/quickstart.mdx
@@ -0,0 +1,151 @@
+---
+title: "Self-Hosting Quickstart"
+sidebarTitle: "Quickstart"
+description: "From zero to your first memory in under two minutes."
+icon: "play"
+---
+
+## Install
+
+
+
+```bash
+curl -fsSL https://supermemory.ai/install | bash
+```
+
+
+```bash
+npx supermemory local
+```
+
+
+```bash
+bunx supermemory local
+```
+
+
+
+The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
+
+## Run
+
+```bash
+supermemory-server
+```
+
+First boot sets everything up — the embedded Supermemory graph engine, local embeddings, and your credentials:
+
+```
+ ┌──────────────────────────────────────────────────┐
+ │ url http://localhost:6767 │
+ │ database ./.supermemory │
+ │ api key sm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
+ │ org id xxxxxxxxxxxxxxxxxxxxxx │
+ └──────────────────────────────────────────────────┘
+```
+
+Save that API key — it's your bearer token for every request.
+
+
+In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
+
+
+## Add your first memory
+
+
+
+```typescript
+import Supermemory from "supermemory"
+
+const client = new Supermemory({
+ apiKey: "sm_...",
+ baseURL: "http://localhost:6767",
+})
+
+await client.memories.add({
+ content: "I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
+ containerTag: "user_dhravya",
+})
+```
+
+
+```python
+from supermemory import Supermemory
+
+client = Supermemory(
+ api_key="sm_...",
+ base_url="http://localhost:6767",
+)
+
+client.memories.add(
+ content="I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
+ container_tag="user_dhravya",
+)
+```
+
+
+```bash
+curl http://localhost:6767/v3/documents \
+ -H "Authorization: Bearer sm_..." \
+ -H "Content-Type: application/json" \
+ -d '{
+ "content": "I am Dhravya. I love building dev tools and I am allergic to peanuts.",
+ "containerTag": "user_dhravya"
+ }'
+```
+
+
+
+## Search it
+
+
+
+```typescript
+const results = await client.search.memories({
+ q: "what food should I avoid?",
+ containerTag: "user_dhravya",
+})
+```
+
+
+```python
+results = client.search.memories(
+ q="what food should I avoid?",
+ container_tag="user_dhravya",
+)
+```
+
+
+```bash
+curl http://localhost:6767/v3/search \
+ -H "Authorization: Bearer sm_..." \
+ -H "Content-Type: application/json" \
+ -d '{
+ "q": "what food should I avoid?",
+ "containerTag": "user_dhravya"
+ }'
+```
+
+
+
+That's it. Everything in the [Memory API](/quickstart) — documents, memories, user profiles, spaces, filtering — works identically against your local server.
+
+## Where things live
+
+By default, all state lives in a single directory you can back up or move:
+
+| Path | Contents |
+|---|---|
+| `./.supermemory/` (or `$SUPERMEMORY_DATA_DIR`) | The Supermemory graph engine's data, auth secret, embedding model cache |
+| `~/.supermemory/env` | API keys saved by the installer, loaded on every launch |
+
+## Next steps
+
+
+
+ LLM providers, local models, performance tuning
+
+
+ The full API — it all works against your local server
+
+
diff --git a/apps/mcp/README.md b/apps/mcp/README.md
index 6578939c..761c8ac1 100644
--- a/apps/mcp/README.md
+++ b/apps/mcp/README.md
@@ -170,6 +170,54 @@ The server will start at `http://localhost:8788`.
**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.
+
+```bash
+export SUPERMEMORY_API_KEY=sm_... # staging key (required; tests skip without it)
+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 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/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:
+
+- **A–C (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 (not the `sm_` API-key path). It
+ **skips** unless both env vars below are set.
+
+```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:
+- Tests **skip** (not fail) without `SUPERMEMORY_API_KEY`; Tier D OAuth tests skip without the
+ refresh-token env vars — 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.
+
### Deploy
```bash
diff --git a/apps/mcp/e2e/auth.test.ts b/apps/mcp/e2e/auth.test.ts
new file mode 100644
index 00000000..404487d6
--- /dev/null
+++ b/apps/mcp/e2e/auth.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest"
+import { MCP_URL, ORIGIN } from "./helpers"
+
+const initBody = JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2024-11-05",
+ capabilities: {},
+ clientInfo: { name: "smtest", version: "0.0.1" },
+ },
+})
+
+const mcpHeaders = (auth?: string) => ({
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ ...(auth ? { Authorization: auth } : {}),
+})
+
+// No API key needed — exercises the public surface and auth rejections.
+describe("MCP — transport & auth (raw HTTP)", () => {
+ it("GET / returns service info", async () => {
+ const res = await fetch(`${ORIGIN}/`)
+ expect(res.status).toBe(200)
+ const body = (await res.json()) as { name?: string; version?: string }
+ expect(body.name).toBe("supermemory-mcp")
+ expect(body.version).toBeTruthy()
+ })
+
+ it("exposes OAuth protected-resource discovery", async () => {
+ const res = await fetch(
+ `${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
+ )
+ expect(res.status).toBe(200)
+ const body = (await res.json()) as {
+ resource?: string
+ authorization_servers?: string[]
+ }
+ expect(body.resource).toMatch(/\/mcp$/)
+ expect(Array.isArray(body.authorization_servers)).toBe(true)
+ expect(body.authorization_servers?.length).toBeGreaterThan(0)
+ })
+
+ it("rejects a request with no token (401 + WWW-Authenticate)", async () => {
+ const res = await fetch(MCP_URL, {
+ method: "POST",
+ headers: mcpHeaders(),
+ body: initBody,
+ })
+ expect(res.status).toBe(401)
+ expect(res.headers.get("www-authenticate")).toMatch(/Bearer/)
+ })
+
+ it("rejects an invalid API key (401 with JSON-RPC error)", async () => {
+ const res = await fetch(MCP_URL, {
+ method: "POST",
+ headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"),
+ body: initBody,
+ })
+ expect(res.status).toBe(401)
+ const body = (await res.json()) as { error?: { message?: string } }
+ expect(body.error?.message).toMatch(/invalid|expired/i)
+ })
+})
diff --git a/apps/mcp/e2e/capture-oauth-token.ts b/apps/mcp/e2e/capture-oauth-token.ts
new file mode 100644
index 00000000..aad14d90
--- /dev/null
+++ b/apps/mcp/e2e/capture-oauth-token.ts
@@ -0,0 +1,103 @@
+// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
+
+import { createHash, randomBytes } from "node:crypto"
+import { createServer } from "node:http"
+import { exec } from "node:child_process"
+
+const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
+const PORT = 8765
+const REDIRECT_URI = `http://localhost:${PORT}/callback`
+
+const b64url = (b: Buffer) =>
+ b
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "")
+
+async function main() {
+ const meta = (await (
+ await fetch(`${API_URL}/.well-known/oauth-authorization-server`)
+ ).json()) as {
+ authorization_endpoint: string
+ token_endpoint: string
+ registration_endpoint: string
+ }
+
+ const reg = (await (
+ await fetch(meta.registration_endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ client_name: "sm-mcp-e2e-capture",
+ redirect_uris: [REDIRECT_URI],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ }),
+ })
+ ).json()) as { client_id: string }
+
+ const verifier = b64url(randomBytes(32))
+ const challenge = b64url(createHash("sha256").update(verifier).digest())
+ const state = b64url(randomBytes(16))
+
+ const authUrl = new URL(meta.authorization_endpoint)
+ authUrl.search = new URLSearchParams({
+ response_type: "code",
+ client_id: reg.client_id,
+ redirect_uri: REDIRECT_URI,
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ scope: "openid profile email offline_access",
+ state,
+ }).toString()
+
+ const code: string = await new Promise((resolve, reject) => {
+ const server = createServer((req, res) => {
+ const u = new URL(req.url ?? "", `http://localhost:${PORT}`)
+ if (u.pathname !== "/callback") return res.end()
+ if (u.searchParams.get("state") !== state) {
+ res.end("state mismatch")
+ return reject(new Error("state mismatch"))
+ }
+ const c = u.searchParams.get("code")
+ res.end("Done — you can close this tab.")
+ server.close()
+ c ? resolve(c) : reject(new Error("no code in callback"))
+ }).listen(PORT, () => {
+ console.log(`\nOpening browser to log in:\n${authUrl}\n`)
+ exec(`open "${authUrl}" || xdg-open "${authUrl}"`)
+ })
+ })
+
+ const tokenRes = (await (
+ await fetch(meta.token_endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ client_id: reg.client_id,
+ code_verifier: verifier,
+ redirect_uri: REDIRECT_URI,
+ }),
+ })
+ ).json()) as { refresh_token?: string; error?: string }
+
+ if (!tokenRes.refresh_token) {
+ console.error("No refresh_token returned:", tokenRes)
+ process.exit(1)
+ }
+
+ console.log("\nExport these to enable Tier D OAuth tests:\n")
+ console.log(`export SUPERMEMORY_MCP_CLIENT_ID="${reg.client_id}"`)
+ console.log(
+ `export SUPERMEMORY_MCP_REFRESH_TOKEN="${tokenRes.refresh_token}"`,
+ )
+}
+
+main().catch((e) => {
+ console.error(e)
+ process.exit(1)
+})
diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts
new file mode 100644
index 00000000..e18375cc
--- /dev/null
+++ b/apps/mcp/e2e/discovery.test.ts
@@ -0,0 +1,52 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest"
+import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
+
+const EXPECTED_TOOLS = [
+ "memory",
+ "recall",
+ "listProjects",
+ "whoAmI",
+ "memory-graph",
+]
+
+describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
+ let s: Session
+
+ beforeAll(async () => {
+ s = await connect()
+ })
+ afterAll(async () => {
+ await s?.close()
+ })
+
+ it("handshakes and lists the expected tools", async () => {
+ const { tools } = await s.client.listTools()
+ const names = tools.map((t) => t.name)
+ for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
+ })
+
+ it("lists profile & projects resources", async () => {
+ const { resources } = await s.client.listResources()
+ const uris = resources.map((r) => r.uri)
+ expect(uris).toContain("supermemory://profile")
+ expect(uris).toContain("supermemory://projects")
+ })
+
+ it("lists the context prompt", async () => {
+ const { prompts } = await s.client.listPrompts()
+ expect(prompts.map((p) => p.name)).toContain("context")
+ })
+
+ it("whoAmI resolves to the authenticated account", async () => {
+ const res = await callTool(s.client, "whoAmI")
+ expect(res.isError).toBeFalsy()
+ const parsed = JSON.parse(textOf(res))
+ expect(parsed.userId).toBeTruthy()
+ })
+
+ it("listProjects returns content", async () => {
+ const res = await callTool(s.client, "listProjects", { refresh: true })
+ expect(res.isError).toBeFalsy()
+ expect(textOf(res).length).toBeGreaterThan(0)
+ })
+})
diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts
new file mode 100644
index 00000000..33de0d2b
--- /dev/null
+++ b/apps/mcp/e2e/graph.test.ts
@@ -0,0 +1,59 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest"
+import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
+
+describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
+ let s: Session
+
+ beforeAll(async () => {
+ s = await connect()
+ })
+ afterAll(async () => {
+ await s?.close()
+ })
+
+ it("memory-graph returns a summary + structured documents", async () => {
+ const res = await callTool(s.client, "memory-graph")
+ expect(res.isError).toBeFalsy()
+ expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/)
+ const sc = res.structuredContent as {
+ documents?: unknown[]
+ totalCount?: number
+ }
+ expect(Array.isArray(sc?.documents)).toBe(true)
+ })
+
+ it("fetch-graph-data returns paginated documents", async () => {
+ const res = await callTool(s.client, "fetch-graph-data", {
+ page: 1,
+ limit: 5,
+ })
+ expect(res.isError).toBeFalsy()
+ const sc = res.structuredContent as {
+ documents?: unknown[]
+ pagination?: { limit?: number }
+ }
+ expect(Array.isArray(sc?.documents)).toBe(true)
+ expect(sc?.pagination?.limit).toBe(5)
+ })
+
+ it("reads the profile resource", async () => {
+ 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")
+ })
+
+ it("reads the projects resource as JSON", async () => {
+ const res = await s.client.readResource({ uri: "supermemory://projects" })
+ const text = res.contents[0].text as string
+ const parsed = JSON.parse(text)
+ expect(Array.isArray(parsed.projects)).toBe(true)
+ })
+
+ it("gets the context prompt as a system message", async () => {
+ 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)
+ })
+})
diff --git a/apps/mcp/e2e/helpers.ts b/apps/mcp/e2e/helpers.ts
new file mode 100644
index 00000000..1c9c2a52
--- /dev/null
+++ b/apps/mcp/e2e/helpers.ts
@@ -0,0 +1,186 @@
+import { Client } from "@modelcontextprotocol/sdk/client/index.js"
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
+
+export const MCP_URL =
+ process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp"
+export const API_KEY = process.env.SUPERMEMORY_API_KEY
+export const ORIGIN = new URL(MCP_URL).origin
+export const API_URL =
+ process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
+
+// Tier D (real OAuth token) creds — captured once via e2e/capture-oauth-token.ts.
+export const OAUTH_REFRESH_TOKEN = process.env.SUPERMEMORY_MCP_REFRESH_TOKEN
+export const OAUTH_CLIENT_ID = process.env.SUPERMEMORY_MCP_CLIENT_ID
+
+export type AuthServerMetadata = {
+ authorization_endpoint: string
+ token_endpoint: string
+ registration_endpoint: string
+ grant_types_supported?: string[]
+ code_challenge_methods_supported?: string[]
+ response_types_supported?: string[]
+}
+
+// Walk the discovery chain a real MCP client follows: protected-resource → authorization server.
+export async function authServerMetadata(): Promise<{
+ authServer: string
+ metadata: AuthServerMetadata
+}> {
+ const prRes = await fetch(
+ `${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
+ )
+ const pr = (await prRes.json()) as { authorization_servers?: string[] }
+ const authServer = pr.authorization_servers?.[0]
+ if (!authServer) throw new Error("no authorization_servers in metadata")
+ const metaRes = await fetch(
+ `${authServer}/.well-known/oauth-authorization-server`,
+ )
+ return { authServer, metadata: (await metaRes.json()) as AuthServerMetadata }
+}
+
+export async function registerClient(registrationEndpoint: string): Promise<{
+ status: number
+ body: { client_id?: string; grant_types?: string[] }
+}> {
+ const res = await fetch(registrationEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ client_name: "sm-mcp-e2e",
+ redirect_uris: ["http://localhost:8765/callback"],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ }),
+ })
+ return { status: res.status, body: await res.json() }
+}
+
+export async function exchangeRefreshToken(
+ tokenEndpoint: string,
+ refreshToken: string,
+ clientId: string,
+): Promise<{
+ status: number
+ body: { access_token?: string; error?: string }
+}> {
+ const res = await fetch(tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ client_id: clientId,
+ }),
+ })
+ return { status: res.status, body: await res.json() }
+}
+
+export type CallResult = {
+ content?: Array<{ type: string; text?: string }>
+ structuredContent?: unknown
+ isError?: boolean
+}
+
+export function textOf(res: CallResult): string {
+ return (res.content ?? [])
+ .filter((c) => c.type === "text" && c.text)
+ .map((c) => c.text)
+ .join("\n")
+}
+
+export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+
+export type Session = { client: Client; close: () => Promise }
+
+export async function connect(
+ opts: { apiKey?: string; token?: string; containerTag?: string } = {},
+): Promise {
+ const headers: Record = {
+ Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
+ }
+ if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
+
+ const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
+ requestInit: { headers },
+ })
+ const client = new Client({ name: "sm-mcp-e2e", version: "0.0.1" })
+ await client.connect(transport)
+ return {
+ client,
+ close: () => transport.close().catch(() => {}),
+ }
+}
+
+export async function callTool(
+ client: Client,
+ name: string,
+ args: Record = {},
+): Promise {
+ return (await client.callTool({ name, arguments: args })) as CallResult
+}
+
+// recall is eventually-consistent (save → ingestion pipeline → memories), so poll.
+export async function recallUntil(
+ client: Client,
+ query: string,
+ needle: string,
+ {
+ tries = 18,
+ delayMs = 5000,
+ containerTag = undefined as string | undefined,
+ } = {},
+): Promise {
+ for (let i = 0; i < tries; i++) {
+ const res = await callTool(client, "recall", {
+ query,
+ includeProfile: false,
+ ...(containerTag ? { containerTag } : {}),
+ })
+ const txt = textOf(res)
+ if (txt.includes(needle)) return txt
+ await sleep(delayMs)
+ }
+ return null
+}
+
+// forget only matches extracted memory entries, not raw chunks, so a just-saved doc
+// returns "No matching memory found..." until extraction completes — poll for real removal.
+export async function forgetUntilForgotten(
+ client: Client,
+ content: string,
+ {
+ tries = 18,
+ delayMs = 5000,
+ containerTag = undefined as string | undefined,
+ } = {},
+): Promise {
+ for (let i = 0; i < tries; i++) {
+ const res = await callTool(client, "memory", {
+ content,
+ action: "forget",
+ ...(containerTag ? { containerTag } : {}),
+ })
+ if (!res.isError && /forgot/i.test(textOf(res))) return textOf(res)
+ await sleep(delayMs)
+ }
+ return null
+}
+
+// poll until a memory is NO LONGER returned (for verifying forget).
+export async function recallUntilAbsent(
+ client: Client,
+ query: string,
+ needle: string,
+ { tries = 12, delayMs = 5000 } = {},
+): Promise {
+ for (let i = 0; i < tries; i++) {
+ const res = await callTool(client, "recall", {
+ query,
+ includeProfile: false,
+ })
+ if (!textOf(res).includes(needle)) return true
+ await sleep(delayMs)
+ }
+ return false
+}
diff --git a/apps/mcp/e2e/memory.test.ts b/apps/mcp/e2e/memory.test.ts
new file mode 100644
index 00000000..65f93612
--- /dev/null
+++ b/apps/mcp/e2e/memory.test.ts
@@ -0,0 +1,127 @@
+import { randomUUID } from "node:crypto"
+import { afterAll, beforeAll, describe, expect, it } from "vitest"
+import {
+ API_KEY,
+ callTool,
+ connect,
+ forgetUntilForgotten,
+ recallUntil,
+ recallUntilAbsent,
+ type Session,
+ textOf,
+} from "./helpers"
+
+describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
+ let s: Session
+ const created: Array<{ content: string; containerTag?: string }> = []
+
+ beforeAll(async () => {
+ s = await connect()
+ })
+ afterAll(async () => {
+ for (const { content, containerTag } of created) {
+ await callTool(s.client, "memory", {
+ content,
+ action: "forget",
+ ...(containerTag ? { containerTag } : {}),
+ }).catch(() => {})
+ }
+ await s?.close()
+ })
+
+ it("save → recall round-trips the saved memory", async () => {
+ const marker = `rt-${randomUUID()}`
+ const content = `e2e round-trip. token=${marker}. The test fruit is dragonfruit.`
+ created.push({ content })
+
+ const save = await callTool(s.client, "memory", { content, action: "save" })
+ expect(save.isError).toBeFalsy()
+ expect(textOf(save)).toMatch(/Saved memory/i)
+
+ const found = await recallUntil(s.client, "test fruit dragonfruit", marker)
+ expect(found, `recall never returned marker ${marker}`).not.toBeNull()
+ }, 120_000)
+
+ it("recall includeProfile=true returns profile + memories sections", async () => {
+ const res = await callTool(s.client, "recall", {
+ query: "dragonfruit",
+ includeProfile: true,
+ })
+ expect(res.isError).toBeFalsy()
+ const txt = textOf(res)
+ expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
+ }, 30_000)
+
+ // Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty.
+ it("recall responds gracefully for an unmatched query", async () => {
+ const res = await callTool(s.client, "recall", {
+ query: `zzz-no-such-memory-${randomUUID()}`,
+ includeProfile: false,
+ })
+ expect(res.isError).toBeFalsy()
+ expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
+ })
+
+ // Hard-asserts forget is accepted; removal is eventually-consistent, so disappearance is best-effort.
+ it("forget accepts and removes a saved memory", async () => {
+ const marker = `fg-${randomUUID()}`
+ const content = `e2e forget target. token=${marker}. Secret animal is axolotl.`
+ created.push({ content })
+
+ await callTool(s.client, "memory", { content, action: "save" })
+ const found = await recallUntil(s.client, "secret animal axolotl", marker)
+ expect(found, "memory should exist before forget").not.toBeNull()
+
+ // Polls forget until it confirms real removal ("forgot"), past the extraction window.
+ const forgotten = await forgetUntilForgotten(s.client, content)
+ expect(
+ forgotten,
+ `forget never confirmed removal for ${marker} (memory entry never extracted in time)`,
+ ).not.toBeNull()
+
+ const gone = await recallUntilAbsent(
+ s.client,
+ "secret animal axolotl",
+ marker,
+ )
+ if (!gone) {
+ console.warn(
+ `[e2e] forget confirmed but ${marker} still indexed after ~60s (eventual deletion)`,
+ )
+ }
+ }, 240_000)
+
+ it("containerTag scopes memories (isolation)", async () => {
+ // Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
+ const tagA = "sm_e2e_scope_a"
+ const tagB = "sm_e2e_scope_b"
+ const marker = `sc-${randomUUID()}`
+ const content = `e2e scoping. token=${marker}. Project color is teal.`
+ created.push({ content, containerTag: tagA })
+
+ await callTool(s.client, "memory", {
+ content,
+ action: "save",
+ containerTag: tagA,
+ })
+
+ const inA = await recallUntil(s.client, "project color teal", marker, {
+ containerTag: tagA,
+ })
+ expect(inA, "marker should be found in its own container").not.toBeNull()
+
+ // Same query scoped to a different container must NOT see it.
+ const leaked = await recallUntil(s.client, "project color teal", marker, {
+ containerTag: tagB,
+ tries: 3,
+ delayMs: 3000,
+ })
+ expect(leaked, "marker leaked across containers").toBeNull()
+ }, 120_000)
+
+ it("returns an error result for a missing required argument", async () => {
+ const res = await callTool(s.client, "recall", {})
+ expect(res.isError).toBe(true)
+ expect(textOf(res).length).toBeGreaterThan(0)
+ })
+})
diff --git a/apps/mcp/e2e/oauth.test.ts b/apps/mcp/e2e/oauth.test.ts
new file mode 100644
index 00000000..4c815391
--- /dev/null
+++ b/apps/mcp/e2e/oauth.test.ts
@@ -0,0 +1,126 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest"
+import {
+ authServerMetadata,
+ type AuthServerMetadata,
+ callTool,
+ connect,
+ exchangeRefreshToken,
+ OAUTH_CLIENT_ID,
+ OAUTH_REFRESH_TOKEN,
+ registerClient,
+ type Session,
+ textOf,
+} from "./helpers"
+
+// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser.
+describe("MCP — OAuth protocol (no secrets)", () => {
+ let meta: AuthServerMetadata
+
+ beforeAll(async () => {
+ meta = (await authServerMetadata()).metadata
+ })
+
+ // Tier A — the discovery chain a client walks from a 401 to the auth server.
+ it("discovers the authorization server from protected-resource metadata", () => {
+ expect(meta.authorization_endpoint).toMatch(/\/authorize$/)
+ expect(meta.token_endpoint).toMatch(/\/token$/)
+ expect(meta.registration_endpoint).toMatch(/\/register$/)
+ })
+
+ it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => {
+ expect(meta.code_challenge_methods_supported).toContain("S256")
+ expect(meta.response_types_supported).toContain("code")
+ expect(meta.grant_types_supported).toContain("authorization_code")
+ expect(meta.grant_types_supported).toContain("refresh_token")
+ })
+
+ // Tier B — Dynamic Client Registration, the first authenticated-flow step.
+ it("issues a client_id via dynamic client registration", async () => {
+ const { status, body } = await registerClient(meta.registration_endpoint)
+ expect(status).toBe(201)
+ expect(body.client_id).toBeTruthy()
+ expect(body.grant_types).toContain("refresh_token")
+ })
+
+ // Tier C — token endpoint rejects forged grants with proper OAuth errors.
+ it("rejects a bogus refresh_token with invalid_grant", async () => {
+ const { status, body } = await exchangeRefreshToken(
+ meta.token_endpoint,
+ "bogus_rt_for_e2e",
+ "bogus_client",
+ )
+ expect(status).toBe(401)
+ expect(body.error).toBe("invalid_grant")
+ })
+
+ it("rejects a bogus authorization code with invalid_grant", async () => {
+ const res = await fetch(meta.token_endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code: "bogus_code",
+ client_id: "bogus",
+ code_verifier: "abc",
+ redirect_uri: "http://localhost:8765/callback",
+ }),
+ })
+ expect(res.status).toBe(401)
+ expect(((await res.json()) as { error?: string }).error).toBe(
+ "invalid_grant",
+ )
+ })
+
+ it("redirects an unauthenticated authorize request to login", async () => {
+ const url = new URL(meta.authorization_endpoint)
+ url.search = new URLSearchParams({
+ response_type: "code",
+ client_id: "any",
+ redirect_uri: "http://localhost:8765/callback",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "S256",
+ scope: "openid profile email offline_access",
+ state: "xyz",
+ }).toString()
+ const res = await fetch(url, { redirect: "manual" })
+ expect(res.status).toBe(302)
+ expect(res.headers.get("location")).toMatch(/\/login/)
+ })
+})
+
+// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
+describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
+ "MCP — real OAuth token round-trip",
+ () => {
+ let s: Session
+ let accessToken: string
+
+ beforeAll(async () => {
+ const { metadata } = await authServerMetadata()
+ const { status, body } = await exchangeRefreshToken(
+ metadata.token_endpoint,
+ OAUTH_REFRESH_TOKEN as string,
+ OAUTH_CLIENT_ID as string,
+ )
+ expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe(
+ 200,
+ )
+ expect(body.access_token).toBeTruthy()
+ accessToken = body.access_token as string
+ })
+ afterAll(async () => {
+ await s?.close()
+ })
+
+ it("mints an OAuth access token that is not an sm_ API key", () => {
+ expect(accessToken.startsWith("sm_")).toBe(false)
+ })
+
+ it("connects to /mcp with the OAuth token and resolves identity", async () => {
+ s = await connect({ token: accessToken })
+ const res = await callTool(s.client, "whoAmI")
+ expect(res.isError).toBeFalsy()
+ expect(JSON.parse(textOf(res)).userId).toBeTruthy()
+ })
+ },
+)
diff --git a/apps/mcp/e2e/root-scope.test.ts b/apps/mcp/e2e/root-scope.test.ts
new file mode 100644
index 00000000..000490a9
--- /dev/null
+++ b/apps/mcp/e2e/root-scope.test.ts
@@ -0,0 +1,80 @@
+import { randomUUID } from "node:crypto"
+import { describe, expect, it } from "vitest"
+import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers"
+
+type ToolLike = {
+ name: string
+ inputSchema?: { properties?: Record }
+}
+
+const propsOf = (tools: ToolLike[], name: string): Record =>
+ 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"
+
+// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
+describe.skipIf(!API_KEY)("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, "memory")).toHaveProperty("containerTag")
+ expect(propsOf(plainTools, "recall")).toHaveProperty("containerTag")
+
+ expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag")
+ expect(propsOf(scopedTools, "recall")).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, "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, "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)
+})
diff --git a/apps/mcp/package.json b/apps/mcp/package.json
index 936c8446..2def592a 100644
--- a/apps/mcp/package.json
+++ b/apps/mcp/package.json
@@ -8,7 +8,8 @@
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"deploy": "vite build && wrangler deploy --minify",
- "cf-typegen": "wrangler types --env-interface CloudflareBindings"
+ "cf-typegen": "wrangler types --env-interface CloudflareBindings",
+ "test:e2e": "vitest run"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
@@ -27,6 +28,7 @@
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
+ "vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
}
diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts
index 762414e2..bc522c02 100644
--- a/apps/mcp/src/client.ts
+++ b/apps/mcp/src/client.ts
@@ -5,21 +5,29 @@ const DEFAULT_PROJECT_ID = "sm_project_default"
const DEFAULT_LIST_LIMIT = 50
const MAX_LIST_LIMIT = 200
+interface MemoryRichFields {
+ metadata?: Record | null
+ updatedAt?: string
+ context?: Record
+ documents?: Array>
+ isAggregated?: boolean
+}
+
export type Memory =
- | {
+ | ({
id: string
memory: string
similarity: number
title?: string
content?: string
- }
- | {
+ } & MemoryRichFields)
+ | ({
id: string
chunk: string
similarity: number
title?: string
content?: string
- }
+ } & MemoryRichFields)
export interface SearchResult {
results: Memory[]
@@ -67,6 +75,17 @@ export interface ListMemoriesOptions {
export interface ListMemoriesResult {
memories: ListedMemory[]
nextCursor: string | null
+export interface SearchOptions {
+ searchMode?: "memories" | "hybrid" | "documents"
+ rerank?: boolean
+ rewriteQuery?: boolean
+ include?: {
+ documents?: boolean
+ relatedMemories?: boolean
+ summaries?: boolean
+ chunks?: boolean
+ forgottenMemories?: boolean
+ }
}
export interface Profile {
@@ -142,7 +161,11 @@ interface SDKResult {
content?: string
similarity: number
title?: string
- context?: string
+ metadata?: Record | null
+ updatedAt?: string
+ context?: Record
+ documents?: Array>
+ isAggregated?: boolean
}
interface SDKListMemory {
@@ -377,26 +400,32 @@ export class SupermemoryClient {
query: string,
limit = 10,
threshold?: number,
+ options?: SearchOptions,
): Promise {
try {
const result = await this.client.search.memories({
q: query,
limit,
containerTag: this.containerTag,
- searchMode: "hybrid",
+ searchMode: options?.searchMode ?? "hybrid",
threshold, // Optional threshold parameter
+ rerank: options?.rerank,
+ rewriteQuery: options?.rewriteQuery,
+ include: options?.include,
})
- // Normalize and limit response size — preserve memory vs chunk distinction
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
- const text = limitByChars(
- r.content || r.memory || r.chunk || r.context || "",
- )
+ const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
+ metadata: r.metadata,
+ updatedAt: r.updatedAt,
+ context: r.context,
+ documents: r.documents,
+ isAggregated: r.isAggregated,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
@@ -468,9 +497,7 @@ export class SupermemoryClient {
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
- const text = limitByChars(
- r.content || r.memory || r.chunk || r.context || "",
- )
+ const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,
diff --git a/apps/mcp/src/format.ts b/apps/mcp/src/format.ts
new file mode 100644
index 00000000..cbd074cf
--- /dev/null
+++ b/apps/mcp/src/format.ts
@@ -0,0 +1,157 @@
+export function formatMemories(
+ response: { results?: Array>; total?: number },
+ opts: {
+ minSimilarity?: number
+ maxRelations?: number
+ maxDocuments?: number
+ maxChunkLength?: number
+ includeScores?: boolean
+ includeLegend?: boolean
+ } = {},
+) {
+ const {
+ minSimilarity = 0,
+ maxRelations = 4,
+ maxDocuments = 3,
+ maxChunkLength = Number.POSITIVE_INFINITY,
+ includeScores = true,
+ includeLegend = true,
+ } = opts
+
+ const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
+ const mime = (m: string | undefined) =>
+ !m
+ ? ""
+ : m === "application/pdf"
+ ? "pdf"
+ : m.includes("spreadsheet")
+ ? "xlsx"
+ : m.includes("presentation")
+ ? "pptx"
+ : m.includes("document")
+ ? "doc"
+ : (m.split("/").pop() ?? "")
+
+ const temporal = (tc: Record | undefined) => {
+ if (!tc) return [] as string[]
+ const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean)
+ return [
+ tc.documentDate && `doc ${day(tc.documentDate as string)}`,
+ ev.length === 1 && `event ${ev[0]}`,
+ ev.length > 1 && `event ${ev[0]} → ${ev.at(-1)}`,
+ ].filter(Boolean) as string[]
+ }
+
+ const describeMeta = (m: Record | undefined | null) => {
+ if (!m) return ""
+ const tags = [
+ mime(m.mimeType as string | undefined),
+ m.source as string | undefined,
+ ...temporal(m.temporalContext as Record | undefined),
+ ].filter(Boolean)
+ return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
+ .filter(Boolean)
+ .join(" ")
+ }
+
+ const renderRelations = (
+ rels: Array> | undefined,
+ arrow: string,
+ root: string,
+ ) => {
+ if (!rels?.length) return [] as string[]
+ const seen = new Set()
+ const items = rels.filter((r) => {
+ const k = (r.memory as string).trim()
+ if (k === root.trim() || seen.has(k)) return false
+ seen.add(k)
+ return true
+ })
+ const shown = items.slice(0, maxRelations)
+ const lines = shown.map((r) => {
+ const t = temporal(
+ (r.metadata as Record | undefined)?.temporalContext as
+ | Record
+ | undefined,
+ )
+ const when = t.length ? t.join(", ") : day(r.updatedAt as string)
+ return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}`
+ })
+ if (items.length > shown.length)
+ lines.push(` ${arrow} … +${items.length - shown.length} more`)
+ return lines
+ }
+
+ const renderDocs = (ds: Array> | undefined) =>
+ (ds ?? []).slice(0, maxDocuments).map((d) => {
+ const title = d.title ? `"${d.title}"` : "(untitled)"
+ const type = d.type ? ` (${d.type})` : ""
+ const summary = d.summary ? ` — ${d.summary}` : ""
+ return ` Document: ${title}${type}${summary}`
+ })
+
+ const results = (response.results ?? []).filter(
+ (m) => ((m.similarity as number) ?? 0) >= minSimilarity,
+ )
+ if (!results.length) return "No relevant memories found."
+
+ const total = response.total ?? results.length
+ const header = [
+ `${results.length} memor${results.length === 1 ? "y" : "ies"}` +
+ (total !== results.length ? ` of ${total}` : "") +
+ ", ranked by relevance.",
+ includeLegend &&
+ "Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.",
+ ]
+ .filter(Boolean)
+ .join(" ")
+
+ const arrows = [
+ ["parents", "←"],
+ ["children", "→"],
+ ["related", "~"],
+ ] as const
+
+ const blocks = results.map((m) => {
+ const score = (m.similarity as number)?.toFixed(2) ?? "—"
+ const prefix = includeScores ? `${score} ` : ""
+ const memory = (m.memory as string) ?? ""
+
+ if (m.isAggregated) return `${prefix}agg ${memory}`
+
+ if (m.chunk != null && m.memory == null) {
+ const body = (m.chunk as string).replace(/\s+$/, "")
+ const text =
+ body.length > maxChunkLength
+ ? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]`
+ : body
+ return [
+ `${prefix}chunk ${describeMeta(m.metadata as Record | null)}`.trimEnd(),
+ ...renderDocs(
+ m.documents as Array> | undefined,
+ ),
+ ...text.split("\n").map((l: string) => ` ${l}`),
+ ].join("\n")
+ }
+
+ const meta = describeMeta(m.metadata as Record | null)
+ const ctx = (m.context ?? {}) as Record<
+ string,
+ Array>
+ >
+ return [
+ `${prefix}${memory}`,
+ meta
+ ? ` Source: ${meta}`
+ : day(m.updatedAt as string)
+ ? ` Source: updated ${day(m.updatedAt as string)}`
+ : null,
+ ...renderDocs(m.documents as Array> | undefined),
+ ...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
+ ]
+ .filter(Boolean)
+ .join("\n")
+ })
+
+ return [header, "", blocks.join("\n\n")].join("\n")
+}
diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts
index 00d3692d..ce0f377c 100644
--- a/apps/mcp/src/server.ts
+++ b/apps/mcp/src/server.ts
@@ -5,7 +5,8 @@ import {
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
-import { SupermemoryClient, getMemoryText } from "./client"
+import { SupermemoryClient } from "./client"
+import { formatMemories } from "./format"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
@@ -26,6 +27,8 @@ type Props = {
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
+const MAX_RECALL_CHARS = 200000
+
export class SupermemoryMCP extends McpAgent {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
@@ -680,10 +683,21 @@ export class SupermemoryMCP extends McpAgent {
const clientInfo = await this.getClientInfo()
const startTime = Date.now()
- if (includeProfile) {
- const profileResult = await client.getProfile(query)
- const parts: string[] = []
+ const searchResult = await client.search(query, 10, undefined, {
+ searchMode: "hybrid",
+ include: {
+ documents: true,
+ relatedMemories: true,
+ summaries: false,
+ chunks: false,
+ forgottenMemories: false,
+ },
+ })
+ const parts: string[] = []
+
+ if (includeProfile) {
+ const profileResult = await client.getProfile()
if (
profileResult.profile.static.length > 0 ||
profileResult.profile.dynamic.length > 0
@@ -701,54 +715,23 @@ export class SupermemoryMCP extends McpAgent {
parts.push(`- ${fact}`)
}
}
- }
-
- if (profileResult.searchResults?.results.length) {
- parts.push("\n## Relevant Memories")
- for (const [
- i,
- memory,
- ] of profileResult.searchResults.results.entries()) {
- parts.push(
- `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
- )
- if (memory.title) parts.push(`**${memory.title}**`)
- parts.push(getMemoryText(memory))
- }
- }
-
- const endTime = Date.now()
-
- // Track search event
- posthog
- .memorySearch({
- query_length: query.length,
- results_count: profileResult.searchResults?.results.length || 0,
- search_duration_ms: endTime - startTime,
- container_tags_count: 1,
- source: "mcp",
- userId: this.props?.userId || "unknown",
- mcp_client_name: clientInfo?.name,
- mcp_client_version: clientInfo?.version,
- sessionId: this.getMcpSessionId(),
- containerTag: containerTag || this.props?.containerTag,
- })
- .catch((error) => console.error("PostHog tracking error:", error))
-
- return {
- content: [
- {
- type: "text" as const,
- text:
- parts.length > 0
- ? parts.join("\n")
- : "No memories or profile found.",
- },
- ],
+ parts.push("")
}
}
- const searchResult = await client.search(query, 10)
+ parts.push("## Relevant Memories")
+ parts.push(
+ formatMemories(
+ {
+ results: searchResult.results as unknown as Array<
+ Record
+ >,
+ total: searchResult.total,
+ },
+ { includeScores: true, includeLegend: true },
+ ),
+ )
+
const endTime = Date.now()
// Track search event
@@ -767,22 +750,18 @@ export class SupermemoryMCP extends McpAgent {
})
.catch((error) => console.error("PostHog tracking error:", error))
- if (searchResult.results.length === 0) {
- return {
- content: [{ type: "text" as const, text: "No memories found." }],
- }
+ const text = parts.join("\n")
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text:
+ text.length > MAX_RECALL_CHARS
+ ? `${text.slice(0, MAX_RECALL_CHARS)}...`
+ : text,
+ },
+ ],
}
-
- const parts = ["## Relevant Memories"]
- for (const [i, memory] of searchResult.results.entries()) {
- parts.push(
- `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
- )
- if (memory.title) parts.push(`**${memory.title}**`)
- parts.push(getMemoryText(memory))
- }
-
- return { content: [{ type: "text" as const, text: parts.join("\n") }] }
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts
new file mode 100644
index 00000000..8289ccd9
--- /dev/null
+++ b/apps/mcp/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+ test: {
+ include: ["e2e/**/*.test.ts"],
+ testTimeout: 90_000,
+ hookTimeout: 30_000,
+ },
+})
diff --git a/apps/memory-graph-playground/src/app/api/container-tags/route.ts b/apps/memory-graph-playground/src/app/api/container-tags/route.ts
new file mode 100644
index 00000000..324e3bc5
--- /dev/null
+++ b/apps/memory-graph-playground/src/app/api/container-tags/route.ts
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server"
+
+const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
+
+export async function POST(request: Request) {
+ try {
+ const { apiKey } = await request.json()
+
+ if (!apiKey) {
+ return NextResponse.json(
+ { error: "API key is required" },
+ { status: 400 },
+ )
+ }
+
+ const containerTagsUrl = new URL(
+ "/v3/container-tags/list",
+ SUPERMEMORY_API_BASE_URL,
+ )
+
+ const response = await fetch(containerTagsUrl, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ return NextResponse.json(
+ { error: errorData.message || `API error: ${response.status}` },
+ { status: response.status },
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Container tags API error:", error)
+ return NextResponse.json(
+ { error: "Failed to fetch container tags" },
+ { status: 500 },
+ )
+ }
+}
diff --git a/apps/memory-graph-playground/src/app/api/graph/route.ts b/apps/memory-graph-playground/src/app/api/graph/route.ts
index c722c625..67b62d34 100644
--- a/apps/memory-graph-playground/src/app/api/graph/route.ts
+++ b/apps/memory-graph-playground/src/app/api/graph/route.ts
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"
+const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
+
export async function POST(request: Request) {
try {
const body = await request.json()
@@ -9,6 +11,7 @@ export async function POST(request: Request) {
limit = 500,
sort = "createdAt",
order = "desc",
+ containerTags,
} = body
if (!apiKey) {
@@ -18,23 +21,28 @@ export async function POST(request: Request) {
)
}
- const response = await fetch(
- "https://api.supermemory.ai/v3/documents/documents",
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${apiKey}`,
- },
- body: JSON.stringify({
- page,
- limit,
- sort,
- order,
- }),
- },
+ const graphUrl = new URL(
+ "/v3/documents/documents",
+ SUPERMEMORY_API_BASE_URL,
)
+ const response = await fetch(graphUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ page,
+ limit,
+ sort,
+ order,
+ ...(Array.isArray(containerTags) && containerTags.length > 0
+ ? { containerTags }
+ : {}),
+ }),
+ })
+
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json(
diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx
index 68a6f945..8131ef69 100644
--- a/apps/memory-graph-playground/src/app/page.tsx
+++ b/apps/memory-graph-playground/src/app/page.tsx
@@ -1,16 +1,52 @@
"use client"
-import { useState, useCallback, useMemo } from "react"
+import { useState, useCallback, useEffect, useMemo } from "react"
import {
MemoryGraph,
- type DocumentWithMemories,
type GraphApiDocument,
type GraphApiMemory,
+ type GraphThemeColors,
+ type MemoryRelation,
} from "@supermemory/memory-graph"
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
+interface PlaygroundApiMemory {
+ id: string
+ memory?: string | null
+ content?: string | null
+ isStatic?: boolean
+ spaceId?: string | null
+ isLatest?: boolean
+ isForgotten?: boolean
+ forgetAfter?: string | null
+ forgetReason?: string | null
+ version?: number
+ parentMemoryId?: string | null
+ rootMemoryId?: string | null
+ createdAt: string
+ updatedAt: string
+ relation?: MemoryRelation | null
+ updatesMemoryId?: string | null
+ nextVersionId?: string | null
+ memoryRelations?: Record | null
+ spaceContainerTag?: string | null
+}
+
+interface PlaygroundApiDocument {
+ id: string
+ title: string | null
+ summary?: string | null
+ documentType?: string
+ type?: string
+ containerTags?: string[]
+ createdAt: string
+ updatedAt: string
+ memories?: PlaygroundApiMemory[]
+ memoryEntries?: PlaygroundApiMemory[]
+}
+
interface DocumentsResponse {
- documents: DocumentWithMemories[]
+ documents: PlaygroundApiDocument[]
pagination: {
currentPage: number
limit: number
@@ -19,42 +55,82 @@ interface DocumentsResponse {
}
}
+interface ContainerTagOption {
+ id: string
+ name?: string | null
+ containerTag: string
+ documentCount?: number
+ memoryCount?: number
+ lastActivityAt?: string | null
+}
+
+type GraphVariant = "consumer" | "console"
+type LoadBehavior = "zoom" | "manual" | "background"
+
+const PAGE_SIZE = 100
+const BACKGROUND_LOAD_DELAY_MS = 900
+const CONSUMER_GRAPH_COLORS = {
+ bg: "transparent",
+ edgeDerives: "#9ca3af",
+} satisfies Partial
+
/** Convert the external API format to the internal graph format */
-function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
- return docs.map((doc) => ({
- id: doc.id,
- title: doc.title,
- summary: doc.summary ?? null,
- documentType: doc.documentType,
- createdAt: doc.createdAt,
- updatedAt: doc.updatedAt,
- memories: doc.memories.map(
- (mem): GraphApiMemory => ({
- id: mem.id,
- memory: mem.content,
- isStatic: mem.isStatic ?? false,
- spaceId: mem.spaceId ?? "",
- isLatest: mem.isLatest ?? true,
- isForgotten: mem.isForgotten ?? false,
- forgetAfter: mem.forgetAfter ?? null,
- forgetReason: mem.forgetReason ?? null,
- version: mem.version ?? 1,
- parentMemoryId: mem.parentMemoryId ?? null,
- rootMemoryId: mem.rootMemoryId ?? null,
- createdAt: mem.createdAt,
- updatedAt: mem.updatedAt,
- }),
- ),
- }))
+function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
+ return docs.map((doc) => {
+ const memories = doc.memories ?? doc.memoryEntries ?? []
+
+ return {
+ id: doc.id,
+ title: doc.title,
+ summary: doc.summary ?? null,
+ documentType: doc.documentType ?? doc.type ?? "unknown",
+ createdAt: doc.createdAt,
+ updatedAt: doc.updatedAt,
+ memories: memories.map(
+ (mem): GraphApiMemory => ({
+ id: mem.id,
+ memory: mem.memory ?? mem.content ?? "",
+ isStatic: mem.isStatic ?? false,
+ spaceId: mem.spaceId ?? "",
+ isLatest: mem.isLatest ?? true,
+ isForgotten: mem.isForgotten ?? false,
+ forgetAfter: mem.forgetAfter ?? null,
+ forgetReason: mem.forgetReason ?? null,
+ version: mem.version ?? 1,
+ parentMemoryId: mem.parentMemoryId ?? null,
+ rootMemoryId: mem.rootMemoryId ?? null,
+ createdAt: mem.createdAt,
+ updatedAt: mem.updatedAt,
+ relation: mem.relation ?? null,
+ updatesMemoryId: mem.updatesMemoryId ?? null,
+ nextVersionId: mem.nextVersionId ?? null,
+ memoryRelations: mem.memoryRelations ?? null,
+ spaceContainerTag: mem.spaceContainerTag ?? null,
+ }),
+ ),
+ }
+ })
}
export default function Home() {
const [apiKey, setApiKey] = useState("")
- const [documents, setDocuments] = useState([])
+ const [containerTag, setContainerTag] = useState("")
+ const [containerTags, setContainerTags] = useState([])
+ const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
+ const [containerTagsError, setContainerTagsError] = useState(
+ null,
+ )
+ const [documents, setDocuments] = useState([])
const [isLoading, setIsLoading] = useState(false)
+ const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState(null)
const [showGraph, setShowGraph] = useState(false)
const [stressTestCount, setStressTestCount] = useState(0)
+ const [graphVariant, setGraphVariant] = useState("consumer")
+ const [loadBehavior, setLoadBehavior] = useState("zoom")
+ const [pagination, setPagination] = useState<
+ DocumentsResponse["pagination"] | null
+ >(null)
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
@@ -64,13 +140,47 @@ export default function Home() {
documents: GraphApiDocument[]
} | null>(null)
- const PAGE_SIZE = 500
+ const selectedContainerTags = useMemo(() => {
+ const trimmed = containerTag.trim()
+ return trimmed ? [trimmed] : undefined
+ }, [containerTag])
+
+ const fetchContainerTags = useCallback(async () => {
+ if (!apiKey || isLoadingContainerTags) return
+
+ setIsLoadingContainerTags(true)
+ setContainerTagsError(null)
+
+ try {
+ const response = await fetch("/api/container-tags", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || "Failed to fetch container tags")
+ }
+
+ const data = (await response.json()) as ContainerTagOption[]
+ setContainerTags(data)
+ } catch (err) {
+ setContainerTagsError(
+ err instanceof Error ? err : new Error("Unknown error"),
+ )
+ } finally {
+ setIsLoadingContainerTags(false)
+ }
+ }, [apiKey, isLoadingContainerTags])
const fetchDocuments = useCallback(
async (page: number, append = false) => {
if (!apiKey) return
- if (page === 1) {
+ if (append) {
+ setIsLoadingMore(true)
+ } else {
setIsLoading(true)
}
setError(null)
@@ -87,6 +197,7 @@ export default function Home() {
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
+ containerTags: selectedContainerTags,
}),
})
@@ -103,6 +214,7 @@ export default function Home() {
setDocuments(data.documents)
}
+ setPagination(data.pagination)
setShowGraph(true)
setMockData(null)
setStressTestCount(0)
@@ -110,19 +222,27 @@ export default function Home() {
setError(err instanceof Error ? err : new Error("Unknown error"))
} finally {
setIsLoading(false)
+ setIsLoadingMore(false)
}
},
- [apiKey],
+ [apiKey, selectedContainerTags],
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey) {
setDocuments([])
+ setPagination(null)
+ void fetchContainerTags()
fetchDocuments(1)
}
}
+ const handleLoadMoreDocuments = useCallback(() => {
+ if (!pagination || pagination.currentPage >= pagination.totalPages) return
+ fetchDocuments(pagination.currentPage + 1, true)
+ }, [fetchDocuments, pagination])
+
const handleStressTest = (count: number) => {
const data = generateMockGraphData({
documentCount: count,
@@ -131,6 +251,7 @@ export default function Home() {
})
setMockData({ documents: data.documents })
setDocuments([])
+ setPagination(null)
setStressTestCount(count)
setShowGraph(true)
setError(null)
@@ -157,7 +278,67 @@ export default function Home() {
return toGraphDocuments(documents)
}, [documents, mockData])
+ const availableContainerTags = useMemo(() => {
+ const options = new Map()
+ for (const tag of containerTags) {
+ if (tag.containerTag) options.set(tag.containerTag, tag)
+ }
+ for (const doc of documents) {
+ for (const tag of doc.containerTags ?? []) {
+ if (tag && !options.has(tag)) {
+ options.set(tag, { id: tag, containerTag: tag, name: tag })
+ }
+ }
+ const memories = doc.memories ?? doc.memoryEntries ?? []
+ for (const mem of memories) {
+ const tag = mem.spaceContainerTag
+ if (tag && !options.has(tag)) {
+ options.set(tag, { id: tag, containerTag: tag, name: tag })
+ }
+ }
+ }
+ return [...options.values()]
+ }, [containerTags, documents])
+
const displayCount = mockData ? stressTestCount : documents.length
+ const hasMore =
+ !mockData &&
+ pagination != null &&
+ pagination.currentPage < pagination.totalPages
+ const totalCount = mockData
+ ? stressTestCount
+ : (pagination?.totalItems ?? documents.length)
+ const maxNodes = mockData ? 1000 : undefined
+ const graphHandlesLoadMore = loadBehavior === "zoom"
+
+ useEffect(() => {
+ if (
+ loadBehavior !== "background" ||
+ !showGraph ||
+ mockData ||
+ !hasMore ||
+ isLoading ||
+ isLoadingMore ||
+ error
+ ) {
+ return
+ }
+
+ const timer = window.setTimeout(
+ handleLoadMoreDocuments,
+ BACKGROUND_LOAD_DELAY_MS,
+ )
+ return () => window.clearTimeout(timer)
+ }, [
+ error,
+ handleLoadMoreDocuments,
+ hasMore,
+ isLoading,
+ isLoadingMore,
+ loadBehavior,
+ mockData,
+ showGraph,
+ ])
return (