MCP Revamp (#1120) (#1380)

Co-authored-by: Prasanna <106952318+Prasanna721@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: ved015 <vedant.04.mahajan@gmail.com>
Co-authored-by: ved015 <ved015@users.noreply.github.com>
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Dhravya Shah 2026-07-30 17:08:51 -07:00 committed by GitHub
parent d4377b1609
commit 19e8f06cf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 10731 additions and 4552 deletions

View file

@ -77,8 +77,6 @@ jobs:
Branch: ${{ github.event.workflow_run.head_branch }}
Repository: ${{ github.repository }}
Check supermemory for similar past CI failures and fixes.
Fix the CI failures. Common fixes:
- Biome lint errors: Run `bun run format-lint` or `biome check --fix .`
- Type errors: Run `bun run check-types` and fix reported issues
@ -87,21 +85,8 @@ jobs:
After fixing, commit the changes and push directly to the branch `${{ github.event.workflow_run.head_branch }}`.
Do NOT create a new PR — the fixes should be pushed to the existing PR branch.
Save the fix pattern to supermemory for future reference.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: |
--max-turns 20
--model claude-opus-4-5-20251101
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(*),WebSearch,WebFetch,Task,mcp__supermemory,mcp__github"
--mcp-config '{
"mcpServers": {
"supermemory": {
"type": "http",
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer ${{ secrets.SUPERMEMORY_API_KEY }}"
}
}
}
}'
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(*),WebSearch,WebFetch,Task,mcp__github"

View file

@ -48,18 +48,7 @@ jobs:
# Enable inline comments for specific issues
claude_args: |
--model claude-opus-4-5-20251101
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(*),WebSearch,WebFetch,Task,mcp__supermemory__*,mcp__github__*"
--mcp-config '{
"mcpServers": {
"supermemory": {
"type": "http",
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer ${{ secrets.SUPERMEMORY_API_KEY }}"
}
}
}
}'
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(*),WebSearch,WebFetch,Task,mcp__github__*"
prompt: |
You are a senior engineer reviewing a pull request. Your job is to catch real bugs, security issues, and logic errors that a human reviewer might miss. You are NOT a linter — do not comment on style, naming, formatting, or minor nitpicks.

View file

@ -67,15 +67,4 @@ jobs:
claude_args: |
--max-turns 15
--model claude-opus-4-5-20251101
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(*),WebSearch,WebFetch,Task,mcp__supermemory,mcp__github"
--mcp-config '{
"mcpServers": {
"supermemory": {
"type": "http",
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer ${{ secrets.SUPERMEMORY_API_KEY }}"
}
}
}
}'
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(*),WebSearch,WebFetch,Task,mcp__github"

View file

@ -192,21 +192,6 @@ Add this to your MCP client config:
}
```
Or use an API key instead of OAuth:
```json
{
"mcpServers": {
"supermemory": {
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer sm_your_api_key_here"
}
}
}
}
```
---
## Build with Supermemory (API)

View file

@ -168,21 +168,6 @@ MCP 服务器开源——[查看源码](https://supermemory.ai/docs/supermemory-
}
```
如果想用 API key 代替 OAuth
```json
{
"mcpServers": {
"supermemory": {
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer sm_your_api_key_here"
}
}
}
}
```
---
## 用 Supermemory API 构建

View file

@ -34,7 +34,7 @@ Thats enough for agents to build real continuity — without tool sprawl.
## How it fits together
1. You connect your client to `https://mcp.supermemory.ai/mcp` (OAuth or API key).
1. You connect your client to `https://mcp.supermemory.ai/mcp` (OAuth).
2. During conversations, the model stores important facts with **`memory`**.
3. When context is needed, **`recall`** (and the profile) pull the right history back in.
4. Switch tools tomorrow — same account, same memory.
@ -61,29 +61,10 @@ Add it to your MCP client config:
}
```
The server uses **OAuth** by default. Your client discovers the authorization server via `/.well-known/oauth-protected-resource` and prompts you to sign in.
The server requires **OAuth**. Your client will discover the authorization server via `/.well-known/oauth-protected-resource` and prompt you to authenticate.
For Claude (Settings → Connectors), see **[Claude Desktop](/supermemory-mcp/claude-desktop)**. For client-specific examples, see **[Setup and Usage](/supermemory-mcp/setup)**.
### API Key Authentication (Alternative)
If you prefer API keys over OAuth, get one from [app.supermemory.ai](https://app.supermemory.ai) and pass it in the `Authorization` header:
```json
{
"mcpServers": {
"supermemory": {
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer sm_your_api_key_here"
}
}
}
}
```
API keys start with `sm_` and skip OAuth when provided.
### Project Scoping
Scope all operations to a specific project with `x-sm-project`:
@ -162,7 +143,7 @@ In Cursor and Claude Code you can often invoke this with **`/context`**, which g
<CardGroup cols={2}>
<Card title="Setup and Usage" icon="settings" href="/supermemory-mcp/setup">
Client configs, API keys, and project scoping.
Client configs, OAuth, and project scoping.
</Card>
<Card title="MCP Server Source" icon="/docs/images/github-icon.svg" href="https://github.com/supermemoryai/supermemory/tree/main/apps/mcp">
Open-source implementation.

View file

@ -22,26 +22,7 @@ Add this to your MCP client config (Claude, Cursor, Windsurf, VS Code, etc.):
}
```
The server uses **OAuth authentication** by default. Your MCP client will automatically discover the authorization server via `/.well-known/oauth-protected-resource` and prompt you to authenticate.
## API Key Authentication (Alternative)
If you prefer to use an API key instead of OAuth, get one from [app.supermemory.ai](https://app.supermemory.ai) and pass it in the `Authorization` header:
```json
{
"mcpServers": {
"supermemory": {
"url": "https://mcp.supermemory.ai/mcp",
"headers": {
"Authorization": "Bearer sm_your_api_key_here"
}
}
}
}
```
API keys start with `sm_` and are automatically detected. When an API key is provided, OAuth authentication is skipped.
The server requires **OAuth authentication**. Your MCP client will automatically discover the authorization server via `/.well-known/oauth-protected-resource` and prompt you to authenticate.
## Project Scoping (Optional)
@ -86,4 +67,4 @@ Or use the one-click install button at [app.supermemory.ai](https://app.supermem
### Windsurf / VS Code
Configuration varies by extension. Generally, add the server URL (`https://mcp.supermemory.ai/mcp`) to your MCP settings.
Configuration varies by extension. Generally, add the server URL (`https://mcp.supermemory.ai/mcp`) to your MCP settings.

View file

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

View file

@ -18,7 +18,7 @@ const mcpHeaders = (auth?: string) => ({
...(auth ? { Authorization: auth } : {}),
})
// No API key needed — exercises the public surface and auth rejections.
// No credentials 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}/`)
@ -52,7 +52,7 @@ describe("MCP — transport & auth (raw HTTP)", () => {
expect(res.headers.get("www-authenticate")).toMatch(/Bearer/)
})
it("rejects an invalid API key (401 with JSON-RPC error)", async () => {
it("rejects an opaque API key as an invalid OAuth token", async () => {
const res = await fetch(MCP_URL, {
method: "POST",
headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"),
@ -62,4 +62,15 @@ describe("MCP — transport & auth (raw HTTP)", () => {
const body = (await res.json()) as { error?: { message?: string } }
expect(body.error?.message).toMatch(/invalid|expired/i)
})
it("rejects a malformed OAuth bearer without API introspection", async () => {
const res = await fetch(MCP_URL, {
method: "POST",
headers: mcpHeaders("Bearer not-a-jwt"),
body: initBody,
})
expect(res.status).toBe(401)
const body = (await res.json()) as { error?: { message?: string } }
expect(body.error?.message).toMatch(/invalid|expired/i)
})
})

View file

@ -1,11 +1,19 @@
// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
import { createHash, randomBytes } from "node:crypto"
import { chmod, mkdir, writeFile } from "node:fs/promises"
import { createServer } from "node:http"
import { exec } from "node:child_process"
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"
const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
const PORT = 8765
const MCP_RESOURCE =
process.env.SUPERMEMORY_MCP_RESOURCE ?? "https://mcp.supermemory.ai/mcp"
const CREDENTIAL_FILE =
process.env.SUPERMEMORY_MCP_CREDENTIAL_FILE ??
fileURLToPath(new URL("../../../.context/mcp-oauth.env", import.meta.url))
const PORT = Number(process.env.SUPERMEMORY_MCP_CALLBACK_PORT ?? "8765")
const REDIRECT_URI = `http://localhost:${PORT}/callback`
const b64url = (b: Buffer) =>
@ -50,6 +58,7 @@ async function main() {
code_challenge: challenge,
code_challenge_method: "S256",
scope: "openid profile email offline_access",
resource: MCP_RESOURCE,
state,
}).toString()
@ -81,6 +90,7 @@ async function main() {
client_id: reg.client_id,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
resource: MCP_RESOURCE,
}),
})
).json()) as { refresh_token?: string; error?: string }
@ -90,11 +100,19 @@ async function main() {
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}"`,
await mkdir(dirname(CREDENTIAL_FILE), { recursive: true })
await writeFile(
CREDENTIAL_FILE,
[
`SUPERMEMORY_MCP_CLIENT_ID=${JSON.stringify(reg.client_id)}`,
`SUPERMEMORY_MCP_REFRESH_TOKEN=${JSON.stringify(tokenRes.refresh_token)}`,
"",
].join("\n"),
{ mode: 0o600 },
)
await chmod(CREDENTIAL_FILE, 0o600)
console.log(`\nOAuth test credentials saved to ${CREDENTIAL_FILE}`)
}
main().catch((e) => {

View file

@ -1,19 +1,37 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
import {
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
connect,
textOf,
type Session,
} from "./helpers"
const EXPECTED_TOOLS = [
"memory",
"recall",
"add_memory",
"fetch-graph-data",
"getDocument",
"guided-save",
"listDocuments",
"listMemories",
"listProjects",
"whoAmI",
"listSpaces",
"memory-graph",
"save-memory",
"search_memory",
"select-space",
"set-active-tag",
"upload-file",
"upload-file-submit",
"whoAmI",
]
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
const READ_ONLY_TOOL_NAMES = [
"recall",
"search_memory",
"listDocuments",
"listMemories",
"listProjects",
"getDocument",
"listSpaces",
"whoAmI",
"memory-graph",
]
@ -32,7 +50,7 @@ const MEMORY_TOOL_ANNOTATIONS = {
openWorldHint: false,
}
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
describeWithAuth("MCP — discovery & identity", () => {
let s: Session
beforeAll(async () => {
@ -44,8 +62,8 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
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)
const names = tools.map((t) => t.name).sort()
expect(names).toEqual([...EXPECTED_TOOLS].sort())
})
it("marks read-only tools as non-destructive", async () => {
@ -56,17 +74,17 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
}
})
it("marks memory as mutating", async () => {
it("marks add_memory as mutating", async () => {
const { tools } = await s.client.listTools()
const memory = tools.find((t) => t.name === "memory")
const memory = tools.find((t) => t.name === "add_memory")
expect(memory?.annotations).toMatchObject(MEMORY_TOOL_ANNOTATIONS)
})
it("lists profile & projects resources", async () => {
it("lists profile and space 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")
expect(uris).toContain("supermemory://spaces")
})
it("lists the context prompt", async () => {
@ -79,10 +97,11 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
expect(res.isError).toBeFalsy()
const parsed = JSON.parse(textOf(res))
expect(parsed.userId).toBeTruthy()
expect(parsed).toHaveProperty("activeSpace")
})
it("listProjects returns content", async () => {
const res = await callTool(s.client, "listProjects", { refresh: true })
it("listSpaces returns content", async () => {
const res = await callTool(s.client, "listSpaces")
expect(res.isError).toBeFalsy()
expect(textOf(res).length).toBeGreaterThan(0)
})

View file

@ -1,7 +1,14 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
import {
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
connect,
type Session,
textOf,
} from "./helpers"
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
describeWithAuth("MCP — graph, resources & prompts", () => {
let s: Session
beforeAll(async () => {
@ -14,7 +21,9 @@ describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
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/)
expect(textOf(res)).toMatch(
/Rendered the interactive Memory Graph MCP App: \d+ documents/,
)
const sc = res.structuredContent as {
documents?: unknown[]
totalCount?: number
@ -40,20 +49,35 @@ describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
const res = await s.client.readResource({ uri: "supermemory://profile" })
expect(res.contents.length).toBeGreaterThan(0)
expect(res.contents[0].mimeType).toBe("text/plain")
expect(typeof res.contents[0].text).toBe("string")
expect(res.contents[0].text).toMatch(/# Active Space Profile/)
expect(res.contents[0].text).toMatch(/Space:/)
expect(res.contents[0].text).toMatch(
/Use `listSpaces` to find the relevant space key/,
)
})
it("reads the projects resource as JSON", async () => {
const res = await s.client.readResource({ uri: "supermemory://projects" })
it("reads all spaces in a compact human-readable format", async () => {
const res = await s.client.readResource({
uri: "supermemory://spaces",
})
const text = res.contents[0].text as string
const parsed = JSON.parse(text)
expect(Array.isArray(parsed.projects)).toBe(true)
expect(res.contents[0].mimeType).toBe("text/plain")
expect(text).toMatch(/# My Spaces/)
expect(text).toMatch(/Active:/)
expect(text).not.toMatch(/"containerTags":/)
})
it("gets the context prompt as a system message", async () => {
it("gets compact active-space context without prompt arguments", async () => {
const prompts = await s.client.listPrompts()
const contextPrompt = prompts.prompts.find(
(prompt) => prompt.name === "context",
)
expect(contextPrompt?.arguments ?? []).toHaveLength(0)
const res = await s.client.getPrompt({ name: "context", arguments: {} })
expect(res.messages.length).toBeGreaterThan(0)
const text = res.messages[0].content.text as string
expect(text).toMatch(/memory|context/i)
expect(text).toMatch(/# Supermemory Context/)
expect(text).toMatch(/Active space:/)
})
})

View file

@ -1,16 +1,90 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs"
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"
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"
export const MCP_RESOURCE =
process.env.SUPERMEMORY_MCP_RESOURCE ?? "https://mcp.supermemory.ai/mcp"
const credentialFile =
process.env.SUPERMEMORY_MCP_CREDENTIAL_FILE ??
fileURLToPath(new URL("../../../.context/mcp-oauth.env", import.meta.url))
function storedOAuthCredentials(): Record<string, string> {
if (!existsSync(credentialFile)) return {}
return Object.fromEntries(
readFileSync(credentialFile, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => {
const separator = line.indexOf("=")
const key = line.slice(0, separator)
const rawValue = line.slice(separator + 1)
return [key, JSON.parse(rawValue) as string]
}),
)
}
function persistOAuthCredentials(clientId: string, refreshToken: string): void {
mkdirSync(dirname(credentialFile), { recursive: true })
writeFileSync(
credentialFile,
[
`SUPERMEMORY_MCP_CLIENT_ID=${JSON.stringify(clientId)}`,
`SUPERMEMORY_MCP_REFRESH_TOKEN=${JSON.stringify(refreshToken)}`,
"",
].join("\n"),
{ mode: 0o600 },
)
chmodSync(credentialFile, 0o600)
}
// 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
const storedCredentials = storedOAuthCredentials()
export const OAUTH_REFRESH_TOKEN =
process.env.SUPERMEMORY_MCP_REFRESH_TOKEN ??
storedCredentials.SUPERMEMORY_MCP_REFRESH_TOKEN
export const OAUTH_CLIENT_ID =
process.env.SUPERMEMORY_MCP_CLIENT_ID ??
storedCredentials.SUPERMEMORY_MCP_CLIENT_ID
export const OAUTH_CREDENTIALS_AVAILABLE = Boolean(
OAUTH_REFRESH_TOKEN && OAUTH_CLIENT_ID,
)
let defaultOAuthAccessToken: Promise<string> | undefined
async function defaultBearerToken(): Promise<string> {
if (!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID) {
throw new Error("No OAuth test credentials configured")
}
defaultOAuthAccessToken ??= (async () => {
const { metadata } = await authServerMetadata()
const { status, body } = await exchangeRefreshToken(
metadata.token_endpoint,
OAUTH_REFRESH_TOKEN,
OAUTH_CLIENT_ID,
)
if (status !== 200 || !body.access_token) {
throw new Error(`OAuth refresh failed: ${JSON.stringify(body)}`)
}
return body.access_token
})()
return defaultOAuthAccessToken
}
export type AuthServerMetadata = {
authorization_endpoint: string
@ -60,9 +134,10 @@ export async function exchangeRefreshToken(
tokenEndpoint: string,
refreshToken: string,
clientId: string,
resource = MCP_RESOURCE,
): Promise<{
status: number
body: { access_token?: string; error?: string }
body: { access_token?: string; refresh_token?: string; error?: string }
}> {
const res = await fetch(tokenEndpoint, {
method: "POST",
@ -71,9 +146,23 @@ export async function exchangeRefreshToken(
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
resource,
}),
})
return { status: res.status, body: await res.json() }
const body = (await res.json()) as {
access_token?: string
refresh_token?: string
error?: string
}
if (
res.ok &&
body.refresh_token &&
OAUTH_CLIENT_ID &&
clientId === OAUTH_CLIENT_ID
) {
persistOAuthCredentials(clientId, body.refresh_token)
}
return { status: res.status, body }
}
export type CallResult = {
@ -94,12 +183,13 @@ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export type Session = { client: Client; close: () => Promise<void> }
export async function connect(
opts: { apiKey?: string; token?: string; containerTag?: string } = {},
opts: { token?: string; headers?: Record<string, string> } = {},
): Promise<Session> {
const bearerToken = opts.token ?? (await defaultBearerToken())
const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
Authorization: `Bearer ${bearerToken}`,
...opts.headers,
}
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: { headers },
@ -132,7 +222,7 @@ export async function recallUntil(
} = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "recall", {
const res = await callTool(client, "search_memory", {
query,
includeProfile: false,
...(containerTag ? { containerTag } : {}),
@ -143,44 +233,3 @@ export async function recallUntil(
}
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<string | null> {
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<boolean> {
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
}

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
API_KEY,
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
connect,
type Session,
@ -9,68 +9,177 @@ import {
textOf,
} from "./helpers"
// listMemories reads extracted memory entries, which appear only after the
// async ingestion pipeline finishes — poll like recallUntil does.
async function listUntil(
s: Session,
type AppView = {
view?: string
viewId?: string
id?: string
fileName?: string
containerTag?: string
writableTags?: string[]
}
async function waitForToolText(
session: Session,
name: string,
args: Record<string, unknown>,
needle: string,
{ tries = 18, delayMs = 5000 } = {},
tries: number,
delayMs: number,
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
// The marker document is the newest, so page 1 is enough.
const res = await callTool(s.client, "listMemories", { limit: 20 })
const txt = textOf(res)
if (txt.includes(needle)) return txt
for (let attempt = 0; attempt < tries; attempt++) {
const result = await callTool(session.client, name, args)
const text = textOf(result)
if (!result.isError && text.includes(needle)) return text
await sleep(delayMs)
}
return null
}
describe.skipIf(!API_KEY)("MCP — listMemories", () => {
let s: Session
const created: string[] = []
describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
"MCP - documents and memories",
() => {
let session: Session
const createdMemories: Array<{
content: string
containerTag: string
}> = []
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
for (const content of created) {
await callTool(s.client, "memory", {
beforeAll(async () => {
session = await connect()
})
afterAll(async () => {
for (const memory of createdMemories) {
await callTool(session.client, "add_memory", {
content: memory.content,
action: "forget",
containerTag: memory.containerTag,
}).catch(() => {})
}
await session?.close()
})
it("saves and reads a document, then lists a real extracted memory", async () => {
const marker = `docs-${randomUUID()}`
const content = `For E2E marker ${marker}, the user's preferred test fruit is dragonfruit.`
const launcher = await callTool(session.client, "guided-save", {
prefill: content,
})
expect(launcher.isError).toBeFalsy()
const launcherView = launcher.structuredContent as AppView
const containerTag = launcherView.writableTags?.[0]
expect(launcherView.view).toBe("save")
expect(launcherView.viewId).toBeTruthy()
expect(containerTag).toBeTruthy()
if (!launcherView.viewId || !containerTag) {
throw new Error("Guided save did not provide a writable space")
}
const saved = await callTool(session.client, "save-memory", {
content,
action: "forget",
}).catch(() => {})
}
await s?.close()
})
containerTag,
viewId: launcherView.viewId,
})
expect(saved.isError).toBeFalsy()
const savedView = saved.structuredContent as AppView
expect(savedView).toMatchObject({
view: "save-success",
containerTag,
})
expect(savedView.id).toBeTruthy()
if (!savedView.id) throw new Error("Save did not return a document ID")
createdMemories.push({ content, containerTag })
it("lists a saved memory without dumping document content", async () => {
const marker = `lm-${randomUUID()}`
const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.`
created.push(content)
const listedDocument = await waitForToolText(
session,
"listDocuments",
{ page: 1, limit: 50, containerTag },
`[${savedView.id}]`,
20,
1000,
)
expect(listedDocument, "saved document did not appear").not.toBeNull()
const save = await callTool(s.client, "memory", { content, action: "save" })
expect(save.isError).toBeFalsy()
const document = await waitForToolText(
session,
"getDocument",
{ documentId: savedView.id },
`Document ID: ${savedView.id}`,
20,
1000,
)
expect(document, "saved document could not be read").not.toBeNull()
const listing = await listUntil(s, marker)
expect(
listing,
`listMemories never returned marker ${marker}`,
).not.toBeNull()
// Header shape: "N memories across M documents (page X of Y, ...)"
expect(listing).toMatch(/memor(y|ies) across \d+ document/)
}, 120_000)
const memoriesResult = await callTool(session.client, "listMemories", {
page: 1,
limit: 10,
containerTag: "sm_project_default",
})
expect(memoriesResult.isError).toBeFalsy()
const memories = textOf(memoriesResult)
expect(memories).toMatch(/active memor(?:y|ies) \(page 1 of \d+/i)
it("paginates with a bounded page size", async () => {
const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 })
expect(res.isError).toBeFalsy()
const txt = textOf(res)
// With the memory saved above there is at least one document.
expect(txt).toMatch(/page 1 of \d+/)
}, 30_000)
const sourceDocumentId = memories.match(
/Source documents: ([^,\n]+)/,
)?.[1]
expect(sourceDocumentId).toBeTruthy()
if (!sourceDocumentId) {
throw new Error("Listed memory did not include a source document")
}
it("rejects an out-of-range limit", async () => {
const res = await callTool(s.client, "listMemories", { limit: 500 })
// Zod schema caps limit at 50 — the SDK surfaces this as a tool error.
expect(res.isError).toBeTruthy()
}, 30_000)
})
const sourceDocument = await callTool(session.client, "getDocument", {
documentId: sourceDocumentId,
})
expect(sourceDocument.isError).toBeFalsy()
expect(textOf(sourceDocument)).toContain(
`Document ID: ${sourceDocumentId}`,
)
}, 60_000)
it("uploads and reads a text document", async () => {
const marker = randomUUID()
const fileName = `mcp-e2e-${marker}.txt`
const fileContent = `E2E upload marker ${marker}.`
const launcher = await callTool(session.client, "upload-file")
expect(launcher.isError).toBeFalsy()
const launcherView = launcher.structuredContent as AppView
const containerTag = launcherView.writableTags?.[0]
expect(launcherView.view).toBe("upload")
expect(launcherView.viewId).toBeTruthy()
expect(containerTag).toBeTruthy()
if (!launcherView.viewId || !containerTag) {
throw new Error("Upload did not provide a writable space")
}
const uploaded = await callTool(session.client, "upload-file-submit", {
fileData: Buffer.from(fileContent).toString("base64"),
fileName,
mimeType: "text/plain",
containerTag,
viewId: launcherView.viewId,
})
expect(uploaded.isError).toBeFalsy()
const uploadedView = uploaded.structuredContent as AppView
expect(uploadedView).toMatchObject({
view: "upload-success",
fileName,
containerTag,
})
expect(uploadedView.id).toBeTruthy()
if (!uploadedView.id)
throw new Error("Upload did not return a document ID")
const document = await waitForToolText(
session,
"getDocument",
{ documentId: uploadedView.id },
`Document ID: ${uploadedView.id}`,
20,
1000,
)
expect(document, "uploaded document could not be read").not.toBeNull()
}, 30_000)
},
)

View file

@ -1,17 +1,15 @@
import { randomUUID } from "node:crypto"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
API_KEY,
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
connect,
forgetUntilForgotten,
recallUntil,
recallUntilAbsent,
type Session,
textOf,
} from "./helpers"
describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)("MCP — memory behaviors", () => {
let s: Session
const created: Array<{ content: string; containerTag?: string }> = []
@ -20,7 +18,7 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
})
afterAll(async () => {
for (const { content, containerTag } of created) {
await callTool(s.client, "memory", {
await callTool(s.client, "add_memory", {
content,
action: "forget",
...(containerTag ? { containerTag } : {}),
@ -34,62 +32,55 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
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" })
const save = await callTool(s.client, "add_memory", {
content,
action: "save",
})
expect(save.isError).toBeFalsy()
expect(textOf(save)).toMatch(/Saved memory/i)
expect(textOf(save)).toMatch(/Memory saved/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", {
const res = await callTool(s.client, "search_memory", {
query: "dragonfruit",
includeProfile: true,
})
expect(res.isError).toBeFalsy()
const txt = textOf(res)
expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
expect(txt).toMatch(/## (Profile|Recent context|Matching 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", {
const res = await callTool(s.client, "search_memory", {
query: `zzz-no-such-memory-${randomUUID()}`,
includeProfile: false,
})
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
expect(textOf(res)).toMatch(
/## Matching memories|No matching 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 () => {
it("forget accepts a saved-memory request before extraction completes", 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" })
await callTool(s.client, "add_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)
const forgotten = await callTool(s.client, "add_memory", {
content,
action: "forget",
})
expect(forgotten.isError).toBeFalsy()
expect(textOf(forgotten)).toMatch(/forgot|No matching memory found/i)
}, 120_000)
it("containerTag scopes memories (isolation)", async () => {
// Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
@ -99,7 +90,7 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
const content = `e2e scoping. token=${marker}. Project color is teal.`
created.push({ content, containerTag: tagA })
await callTool(s.client, "memory", {
await callTool(s.client, "add_memory", {
content,
action: "save",
containerTag: tagA,
@ -120,7 +111,7 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
}, 120_000)
it("returns an error result for a missing required argument", async () => {
const res = await callTool(s.client, "recall", {})
const res = await callTool(s.client, "search_memory", {})
expect(res.isError).toBe(true)
expect(textOf(res).length).toBeGreaterThan(0)
})

View file

@ -5,6 +5,7 @@ import {
callTool,
connect,
exchangeRefreshToken,
MCP_RESOURCE,
OAUTH_CLIENT_ID,
OAUTH_REFRESH_TOKEN,
registerClient,
@ -37,7 +38,7 @@ describe("MCP — OAuth protocol (no secrets)", () => {
// 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(status).toBe(200)
expect(body.client_id).toBeTruthy()
expect(body.grant_types).toContain("refresh_token")
})
@ -49,7 +50,7 @@ describe("MCP — OAuth protocol (no secrets)", () => {
"bogus_rt_for_e2e",
"bogus_client",
)
expect(status).toBe(401)
expect(status).toBe(400)
expect(body.error).toBe("invalid_grant")
})
@ -71,24 +72,33 @@ describe("MCP — OAuth protocol (no secrets)", () => {
)
})
it("redirects an unauthenticated authorize request to login", async () => {
it("presents login for an unauthenticated authorize request", async () => {
const { body: client } = await registerClient(meta.registration_endpoint)
expect(client.client_id).toBeTruthy()
const url = new URL(meta.authorization_endpoint)
url.search = new URLSearchParams({
response_type: "code",
client_id: "any",
client_id: client.client_id as string,
redirect_uri: "http://localhost:8765/callback",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
scope: "openid profile email offline_access",
resource: MCP_RESOURCE,
state: "xyz",
}).toString()
const res = await fetch(url, { redirect: "manual" })
expect(res.status).toBe(302)
expect(res.headers.get("location")).toMatch(/\/login/)
if (res.status === 302) {
expect(res.headers.get("location")).toMatch(/\/login/)
} else {
expect(res.status).toBe(200)
const body = (await res.json()) as { redirect?: boolean; url?: string }
expect(body.redirect).toBe(true)
expect(body.url).toMatch(/\/login/)
}
})
})
// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
// Tier D — real OAuth token through /mcp; needs a seeded refresh token.
describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
"MCP — real OAuth token round-trip",
() => {
@ -112,8 +122,8 @@ describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
await s?.close()
})
it("mints an OAuth access token that is not an sm_ API key", () => {
expect(accessToken.startsWith("sm_")).toBe(false)
it("mints a JWT access token for the MCP resource", () => {
expect(accessToken.split(".")).toHaveLength(3)
})
it("connects to /mcp with the OAuth token and resolves identity", async () => {

View file

@ -1,80 +0,0 @@
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<string, unknown> }
}
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
const SCOPE_TAG = "sm_e2e_root"
// 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)
})

View file

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

View file

@ -0,0 +1,75 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
connect,
type Session,
textOf,
} from "./helpers"
describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
"MCP - on-demand widget permissions",
() => {
let session: Session
beforeAll(async () => {
session = await connect()
})
afterAll(async () => {
await session?.close()
})
it("loads visible spaces and effective permissions on demand", async () => {
const result = await callTool(session.client, "select-space")
expect(result.isError).toBeFalsy()
const content = result.structuredContent as {
view?: string
containerTags?: Array<{ containerTag: string }>
assignedTags?: Array<{
containerTag: string
permission: "read" | "write"
}>
}
expect(content.view).toBe("picker")
expect(Array.isArray(content.containerTags)).toBe(true)
expect(content.assignedTags).toHaveLength(
content.containerTags?.length ?? 0,
)
expect(
content.assignedTags?.every((tag) =>
["read", "write"].includes(tag.permission),
),
).toBe(true)
})
it("shares the selected space across MCP transport sessions", async () => {
const picker = await callTool(session.client, "select-space")
const pickerContent = picker.structuredContent as {
containerTags?: Array<{ containerTag: string }>
}
const firstTag = pickerContent.containerTags?.[0]?.containerTag
expect(firstTag).toBeTruthy()
const result = await callTool(session.client, "set-active-tag", {
containerTag: firstTag,
})
expect(result.isError).toBeFalsy()
expect(result.structuredContent).toMatchObject({
view: "confirmation",
containerTag: firstTag,
})
const separateSession = await connect()
try {
const identity = await callTool(separateSession.client, "whoAmI")
expect(identity.isError).toBeFalsy()
expect(JSON.parse(textOf(identity))).toMatchObject({
activeSpace: firstTag,
})
} finally {
await separateSession.close()
}
})
},
)

View file

@ -1,95 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Memory Graph</title>
</head>
<body>
<div id="graph"></div>
<div id="loading">
<div class="spinner"></div>
<span>Loading memory graph...</span>
</div>
<div id="stats"></div>
<div id="popup">
<span id="popup-type"></span>
<div id="popup-title"></div>
<div id="popup-content"></div>
<div id="popup-meta"></div>
</div>
<div id="controls">
<button type="button" id="fit-btn" title="Fit to view">
<span>Fit</span>
<kbd>Z</kbd>
</button>
<button type="button" id="center-btn" title="Center graph">
<span>Center</span>
<kbd>C</kbd>
</button>
<div id="zoom-row">
<span id="zoom-display">100%</span>
<button type="button" id="zoom-out" title="Zoom out">&minus;</button>
<button type="button" id="zoom-in" title="Zoom in">+</button>
</div>
</div>
<div id="legend" class="collapsed">
<div id="legend-toggle">
<span>Legend</span>
<svg class="legend-chevron" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 2l4 3-4 3" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<div id="legend-body">
<div class="legend-group">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><rect x="1" y="1" width="12" height="12" rx="3" fill="var(--doc-fill)" stroke="var(--doc-stroke)" stroke-width="1.2"/></svg>
<span>Documents</span>
<span class="legend-count" id="legend-doc-count"></span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#3B73B8" stroke-width="1.2"/></svg>
<span>Memories</span>
<span class="legend-count" id="legend-mem-count"></span>
</div>
</div>
<div class="legend-divider"></div>
<div class="legend-group">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#10B981" stroke-width="1.5"/></svg>
<span>Recent</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#F59E0B" stroke-width="1.5"/></svg>
<span>Expiring</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#EF4444" stroke-width="1.5"/></svg>
<span>Forgotten</span>
</div>
</div>
<div class="legend-divider"></div>
<div class="legend-group">
<div class="legend-row">
<div class="legend-line" style="border-color: #FBBF24;"></div>
<span>Derives</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #A78BFA; border-width: 2px;"></div>
<span>Updates</span>
</div>
<div class="legend-row">
<div class="legend-line dashed" style="border-color: #38BDF8;"></div>
<span>Extends</span>
</div>
</div>
</div>
</div>
<script type="module" src="/src/ui/mcp-app.ts"></script>
</body>
</html>

View file

@ -1,34 +1,58 @@
{
"name": "supermemory-mcp",
"version": "4.0.0",
"type": "module",
"portless": { "name": "mcp.dev.supermemory", "script": "dev:app" },
"scripts": {
"build:ui": "vite build",
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"deploy": "vite build && wrangler deploy --minify",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"test:e2e": "vitest run"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
"@modelcontextprotocol/ext-apps": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"agents": "^0.3.5",
"hono": "^4.11.1",
"posthog-node": "^5.18.0",
"supermemory": "^4.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0",
"d3-force-3d": "^3.0.5",
"force-graph": "^1.49.0",
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
"name": "supermemory-mcp",
"version": "1.0.0",
"type": "module",
"portless": {
"name": "mcp.dev.supermemory",
"script": "dev:app"
},
"scripts": {
"build:widget": "vite build",
"build": "vite build",
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"dev:widget": "vite --config vite.config.dev.ts",
"studio": "vite --config vite.config.dev.ts --open /studio.html",
"deploy": "vite build && wrangler deploy --minify",
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.widget.json",
"test:unit": "vitest run src",
"test:e2e": "vitest run e2e",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/server": "2.0.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@supermemory/memory-graph": "^0.2.0",
"agents": "^0.20.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.11.1",
"jose": "^6.2.0",
"posthog-node": "^5.18.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"supermemory": "^4.0.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0",
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.1.13",
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
}

2456
apps/mcp/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,162 +0,0 @@
/**
* Authentication via API introspection
*
* This validates OAuth tokens and API keys by calling the main Supermemory API,
*/
export interface AuthUser {
userId: string
apiKey: string
email?: string
name?: string
}
/**
* Check if a token is an API key (starts with "sm_")
*/
export function isApiKey(token: string): boolean {
return token.startsWith("sm_")
}
/**
* Validate API key by calling the main API's session endpoint.
* Returns user info if the API key is valid.
*/
export async function validateApiKey(
apiKey: string,
apiUrl: string,
): Promise<AuthUser | null> {
try {
const sessionResponse = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!sessionResponse.ok) {
const responseText = await sessionResponse.text()
const status = sessionResponse.status
if (status === 401) {
console.error("API key validation failed: Invalid or expired API key")
} else if (status === 403) {
console.error(
"API key validation failed: User is blocked or access forbidden",
responseText,
)
} else if (status === 429) {
console.error("API key validation failed: Rate limit exceeded")
} else if (status >= 500) {
console.error(
"API key validation failed: Server error",
status,
responseText,
)
} else {
console.error("API key validation failed:", status, responseText)
}
return null
}
const sessionData = (await sessionResponse.json()) as {
user?: {
id?: string
email?: string
name?: string
}
session?: unknown
org?: unknown
error?: string
} | null
if (!sessionData?.user?.id) {
console.error("Missing user.id in session response:", sessionData)
return null
}
console.log("API key validated for user:", sessionData.user.id)
return {
userId: sessionData.user.id,
apiKey: apiKey,
email: sessionData.user.email,
name: sessionData.user.name,
}
} catch (error) {
console.error("API key validation error:", error)
return null
}
}
/**
* Validate OAuth token by calling the main API's MCP session endpoint.
* The main API validates the token via better-auth and returns user info + API key.
*/
export async function validateOAuthToken(
token: string,
apiUrl: string,
): Promise<AuthUser | null> {
try {
const sessionResponse = await fetch(`${apiUrl}/v3/mcp/session-with-key`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(30_000),
})
if (!sessionResponse.ok) {
const responseText = await sessionResponse.text()
const status = sessionResponse.status
if (status === 401) {
console.error("Token validation failed: Invalid or expired token")
} else if (status === 403) {
console.error(
"Token validation failed: User is blocked or access forbidden",
responseText,
)
} else if (status === 429) {
console.error("Token validation failed: Rate limit exceeded")
} else if (status >= 500) {
console.error(
"Token validation failed: Server error",
status,
responseText,
)
} else {
console.error("Token validation failed:", status, responseText)
}
return null
}
const sessionData = (await sessionResponse.json()) as {
userId?: string
apiKey?: string
email?: string
name?: string
error?: string
} | null
if (!sessionData?.userId || !sessionData?.apiKey) {
console.error(
"Missing userId or apiKey in session response:",
sessionData,
)
return null
}
console.log("OAuth validated, got API key for user:", sessionData.userId)
return {
userId: sessionData.userId,
apiKey: sessionData.apiKey,
email: sessionData.email,
name: sessionData.name,
}
} catch (error) {
console.error("Token validation error:", error)
return null
}
}

View file

@ -1,10 +1,18 @@
import { describe, expect, it } from "vitest"
import type { DocumentsApiResponse } from "./client"
import { formatMemoriesList } from "./format"
import type {
DocumentDetails,
DocumentsListResponse,
MemoryEntriesResponse,
} from "./server/client"
import {
formatDocument,
formatDocumentsList,
formatMemoryEntriesList,
} from "./server/format"
function makeResponse(
overrides: Partial<DocumentsApiResponse> = {},
): DocumentsApiResponse {
function makeDocumentsResponse(
overrides: Partial<DocumentsListResponse> = {},
): DocumentsListResponse {
return {
documents: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
@ -12,50 +20,51 @@ function makeResponse(
}
}
function makeEntry(memory: string, extra: Record<string, unknown> = {}) {
function makeMemoryResponse(
overrides: Partial<MemoryEntriesResponse> = {},
): MemoryEntriesResponse {
return {
memoryEntries: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
...overrides,
}
}
function makeMemory(memory: string, extra: Record<string, unknown> = {}) {
return {
id: `mem_${memory.slice(0, 8)}`,
memory,
spaceId: "space_1",
version: 1,
isLatest: true,
isForgotten: false,
createdAt: "2026-06-10T12:00:00Z",
updatedAt: "2026-06-10T12:00:00Z",
...extra,
}
}
describe("formatMemoriesList", () => {
it("reports an empty store", () => {
expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.")
})
it("reports an out-of-range page distinctly from an empty store", () => {
const result = formatMemoriesList(
makeResponse({
pagination: {
currentPage: 3,
limit: 10,
totalItems: 12,
totalPages: 2,
},
}),
describe("formatDocumentsList", () => {
it("reports an empty document store", () => {
expect(formatDocumentsList(makeDocumentsResponse())).toBe(
"No documents stored yet.",
)
expect(result).toBe("No documents on page 3 (2 pages total).")
})
it("groups memories under their source document with title, type, and date", () => {
const result = formatMemoriesList(
makeResponse({
it("formats document metadata and stable IDs without content", () => {
const result = formatDocumentsList(
makeDocumentsResponse({
documents: [
{
id: "doc_1",
connectionId: null,
createdAt: "2026-06-12T08:00:00Z",
customId: null,
metadata: null,
status: "done",
summary: "A compact summary.",
title: "Preferences",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("User prefers dark mode"),
makeEntry("User works in TypeScript"),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
@ -63,131 +72,148 @@ describe("formatMemoriesList", () => {
)
expect(result).toContain(
"2 memories across 1 document (page 1 of 1, 1 documents total), newest first.",
"1 document (page 1 of 1, 1 document total), newest first.",
)
expect(result).toContain('"Preferences" (text, 2026-06-12)')
expect(result).toContain("- User prefers dark mode")
expect(result).toContain("- User works in TypeScript")
expect(result).not.toContain("More available")
})
it("excludes forgotten and superseded memory entries", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Facts",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("Current fact"),
makeEntry("Forgotten fact", { isForgotten: true }),
makeEntry("Old version of a fact", { isLatest: false }),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain("- Current fact")
expect(result).not.toContain("Forgotten fact")
expect(result).not.toContain("Old version of a fact")
expect(result).toContain("1 memory across 1 document")
})
it("marks documents whose extraction has not produced memories yet", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Still processing",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain('- [doc_1] "Preferences" (text, done, 2026-06-12)')
expect(result).toContain("Summary: A compact summary.")
expect(result).toContain(
'"Still processing" (text, 2026-06-12) — no extracted memories yet',
"Use getDocument with a document ID to read its content.",
)
})
it("falls back to (untitled) for documents without a title", () => {
const result = formatMemoriesList(
makeResponse({
it("points to the next document page", () => {
const result = formatDocumentsList(
makeDocumentsResponse({
documents: [
{
id: "doc_1",
connectionId: null,
createdAt: "2026-06-12T08:00:00Z",
customId: null,
metadata: null,
status: "done",
summary: null,
title: null,
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [makeEntry("Some fact")],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain('"(untitled)" (text, 2026-06-12)')
})
it("flattens multi-line memories and truncates oversized ones", () => {
const longMemory = `start ${"x".repeat(600)}`
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Big",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("line one\nline two\ttabbed"),
makeEntry(longMemory),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain("- line one line two tabbed")
expect(result).toContain("… [truncated]")
const truncatedLine = result
.split("\n")
.find((line) => line.includes("[truncated]"))
expect(truncatedLine).toBeDefined()
expect((truncatedLine as string).length).toBeLessThan(600)
})
it("points at the next page when more documents exist", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Page one doc",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [makeEntry("A fact")],
},
],
pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 },
}),
)
expect(result).toContain("page 1 of 3, 3 documents total")
expect(result).toContain("More available — call listMemories with page: 2.")
expect(result).toContain('"(untitled)"')
expect(result).toContain(
"More available - call listDocuments with page: 2.",
)
})
})
describe("formatMemoryEntriesList", () => {
it("reports an empty memory store", () => {
expect(formatMemoryEntriesList(makeMemoryResponse())).toBe(
"No active memories stored yet.",
)
})
it("formats active memories independently of documents", () => {
const result = formatMemoryEntriesList(
makeMemoryResponse({
memoryEntries: [
makeMemory("User prefers dark mode", {
id: "mem_1",
version: 2,
documentIds: ["doc_1", "doc_2"],
history: [
{
id: "mem_old",
memory: "User sometimes uses dark mode",
version: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
}),
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain(
"1 active memory (page 1 of 1, 1 memory entry total), newest first.",
)
expect(result).toContain("- [mem_1] User prefers dark mode")
expect(result).toContain(
"version 2 | updated 2026-06-10 | 1 previous version",
)
expect(result).toContain("Source documents: doc_1, doc_2")
})
it("excludes forgotten and superseded entries", () => {
const result = formatMemoryEntriesList(
makeMemoryResponse({
memoryEntries: [
makeMemory("Current fact"),
makeMemory("Forgotten fact", { isForgotten: true }),
makeMemory("Old fact", { isLatest: false }),
],
pagination: { currentPage: 1, limit: 10, totalItems: 3, totalPages: 1 },
}),
)
expect(result).toContain("Current fact")
expect(result).not.toContain("Forgotten fact")
expect(result).not.toContain("Old fact")
})
it("flattens and truncates oversized memory text", () => {
const result = formatMemoryEntriesList(
makeMemoryResponse({
memoryEntries: [
makeMemory("line one\nline two"),
makeMemory(`start ${"x".repeat(600)}`),
],
pagination: { currentPage: 1, limit: 10, totalItems: 2, totalPages: 1 },
}),
)
expect(result).toContain("line one line two")
expect(result).toContain("... [truncated]")
})
})
describe("formatDocument", () => {
const document: DocumentDetails = {
id: "doc_1",
connectionId: null,
content: "Original input",
createdAt: "2026-06-12T08:00:00Z",
customId: null,
metadata: null,
ogImage: null,
raw: "Full extracted document text",
source: "text",
spatialPoint: null,
status: "done",
summary: "A compact summary.",
title: "Preferences",
type: "text",
updatedAt: "2026-06-12T09:00:00Z",
url: null,
}
it("returns document metadata, summary, and full available content", () => {
const result = formatDocument(document)
expect(result).toContain("# Preferences")
expect(result).toContain("Document ID: doc_1")
expect(result).toContain("## Summary\nA compact summary.")
expect(result).toContain("## Content\nFull extracted document text")
expect(result).not.toContain("Original input")
})
it("falls back to the original content when raw content is absent", () => {
const result = formatDocument({ ...document, raw: null })
expect(result).toContain("## Content\nOriginal input")
})
})

View file

@ -1,210 +0,0 @@
import type { DocumentsApiResponse } from "./client"
// Listing must stay lightweight: memory entries are extracted facts (short
// strings), never raw document content, so responses fit comfortably in
// client output limits even at the maximum page size.
const MAX_LIST_MEMORY_CHARS = 500
export function formatMemoriesList(response: DocumentsApiResponse): string {
const { documents, pagination } = response
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
if (documents.length === 0) {
return pagination.currentPage > 1
? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
: "No memories stored yet."
}
let memoryCount = 0
const blocks = documents.map((doc) => {
const activeEntries = doc.memoryEntries.filter(
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
)
const title = doc.title?.trim() || "(untitled)"
const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})`
if (activeEntries.length === 0) {
return `${header} — no extracted memories yet`
}
memoryCount += activeEntries.length
const lines = activeEntries.map((entry) => {
const text = entry.memory.replace(/\s+/g, " ").trim()
return `- ${
text.length > MAX_LIST_MEMORY_CHARS
? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} … [truncated]`
: text
}`
})
return [header, ...lines].join("\n")
})
const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.`
const parts = [header, "", blocks.join("\n\n")]
if (pagination.currentPage < pagination.totalPages) {
parts.push(
"",
`More available — call listMemories with page: ${pagination.currentPage + 1}.`,
)
}
return parts.join("\n")
}
export function formatMemories(
response: { results?: Array<Record<string, unknown>>; 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<string, unknown> | 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<string, unknown> | undefined | null) => {
if (!m) return ""
const tags = [
mime(m.mimeType as string | undefined),
m.source as string | undefined,
...temporal(m.temporalContext as Record<string, unknown> | undefined),
].filter(Boolean)
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
.filter(Boolean)
.join(" ")
}
const renderRelations = (
rels: Array<Record<string, unknown>> | undefined,
arrow: string,
root: string,
) => {
if (!rels?.length) return [] as string[]
const seen = new Set<string>()
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<string, unknown> | undefined)?.temporalContext as
| Record<string, unknown>
| 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<Record<string, unknown>> | 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<string, unknown> | null)}`.trimEnd(),
...renderDocs(
m.documents as Array<Record<string, unknown>> | undefined,
),
...text.split("\n").map((l: string) => ` ${l}`),
].join("\n")
}
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
const ctx = (m.context ?? {}) as Record<
string,
Array<Record<string, unknown>>
>
return [
`${prefix}${memory}`,
meta
? ` Source: ${meta}`
: day(m.updatedAt as string)
? ` Source: updated ${day(m.updatedAt as string)}`
: null,
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
]
.filter(Boolean)
.join("\n")
})
return [header, "", blocks.join("\n\n")].join("\n")
}

View file

@ -1,202 +0,0 @@
import { cors } from "hono/cors"
import { Hono, type Context } from "hono"
import { SupermemoryMCP } from "./server"
import { isApiKey, validateApiKey, validateOAuthToken } from "./auth"
import { initPosthog } from "./posthog"
import type { ContentfulStatusCode } from "hono/utils/http-status"
type Bindings = {
MCP_SERVER: DurableObjectNamespace
API_URL?: string
MCP_URL?: string
POSTHOG_API_KEY?: string
}
type Props = {
userId: string
apiKey: string
containerTag?: string
email?: string
name?: string
}
const app = new Hono<{ Bindings: Bindings }>()
const DEFAULT_API_URL = "https://api.supermemory.ai"
const DEFAULT_MCP_URL = "https://mcp.supermemory.ai"
const mcpBaseUrl = (c: Context<{ Bindings: Bindings }>) => {
if (c.env.MCP_URL) return c.env.MCP_URL.replace(/\/$/, "")
const host = c.req.header("x-forwarded-host") || c.req.header("host")
const proto = c.req.header("x-forwarded-proto") || "https"
return host ? `${proto}://${host}` : DEFAULT_MCP_URL
}
// CORS
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowHeaders: [
"Content-Type",
"Authorization",
"x-sm-project",
"Accept",
"Mcp-Session-Id",
"MCP-Protocol-Version",
"Last-Event-ID",
],
exposeHeaders: ["Mcp-Session-Id", "WWW-Authenticate"],
}),
)
app.use("*", async (c, next) => {
initPosthog(c.env.POSTHOG_API_KEY)
await next()
})
app.get("/", (c) => {
return c.json({
name: "supermemory-mcp",
version: "4.0.0",
description: "Give your AI a memory",
docs: "https://docs.supermemory.ai/mcp",
})
})
// MCP clients use this to discover the authorization server
const protectedResourceHandler = (c: Context<{ Bindings: Bindings }>) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
return c.json({
resource: `${mcpBaseUrl(c)}/mcp`,
authorization_servers: [apiUrl],
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
resource_documentation: "https://docs.supermemory.ai/mcp",
})
}
app.get("/.well-known/oauth-protected-resource", protectedResourceHandler)
app.get("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler)
// Proxy endpoint for MCP clients that don't follow the spec correctly
// Some clients look for oauth-authorization-server on the MCP server domain
// instead of following the authorization_servers array
app.get("/.well-known/oauth-authorization-server", async (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
try {
// Fetch the authorization server metadata from the main API
const response = await fetch(
`${apiUrl}/.well-known/oauth-authorization-server`,
{ signal: AbortSignal.timeout(30_000) },
)
if (!response.ok) {
return c.json(
{ error: "Failed to fetch authorization server metadata" },
{ status: response.status as ContentfulStatusCode },
)
}
const metadata = await response.json()
return c.json(metadata)
} catch (error) {
console.error("Error fetching OAuth authorization server metadata:", error)
return c.json({ error: "Internal server error" }, 500)
}
})
const mcpHandler = SupermemoryMCP.serve("/mcp", {
binding: "MCP_SERVER",
corsOptions: {
origin: "*",
methods: "GET, POST, DELETE, OPTIONS",
headers:
"Content-Type, Authorization, x-sm-project, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID",
},
})
const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
const authHeader = c.req.header("Authorization")
const token = authHeader?.replace(/^Bearer\s+/i, "")
const containerTag = c.req.header("x-sm-project")
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const resourceMetadataUrl = `${mcpBaseUrl(c)}/.well-known/oauth-protected-resource/mcp`
if (!token) {
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
})
}
let authUser: {
userId: string
apiKey: string
email?: string
name?: string
} | null = null
if (isApiKey(token)) {
console.log("Authenticating with API key")
authUser = await validateApiKey(token, apiUrl)
} else {
console.log("Authenticating with OAuth token")
authUser = await validateOAuthToken(token, apiUrl)
}
if (!authUser) {
const errorMessage = isApiKey(token)
? "Unauthorized: Invalid or expired API key"
: "Unauthorized: Invalid or expired token"
return new Response(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: errorMessage,
},
id: null,
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
},
)
}
// Create execution context with authenticated user props
const ctx = {
...c.executionCtx,
props: {
userId: authUser.userId,
apiKey: authUser.apiKey,
containerTag,
email: authUser.email,
name: authUser.name,
} satisfies Props,
} as ExecutionContext & { props: Props }
return mcpHandler.fetch(c.req.raw, c.env, ctx)
}
app.all("/mcp", handleMcpRequest)
app.all("/mcp/*", handleMcpRequest)
// Export the Durable Object class for Cloudflare Workers
export { SupermemoryMCP }
export { SpaceState } from "./server/space-state"
export default app

View file

@ -1,135 +0,0 @@
import { PostHog } from "posthog-node"
const MCP_SERVER_VERSION = "4.0.0"
/**
* PostHog singleton for analytics.
*/
let instance: PostHog | null = null
let initialized = false
/**
* Initialize PostHog with the provided API key.
*/
export function initPosthog(apiKey?: string): void {
if (initialized) return
initialized = true
if (!apiKey) {
return
}
instance = new PostHog(apiKey, {
host: "https://us.i.posthog.com",
})
}
function getInstance(): PostHog | null {
if (!initialized) {
console.warn(
"PostHog not initialized. Call initPosthog(apiKey) during worker startup.",
)
}
return instance
}
export async function memoryAdded(props: {
type: "note" | "link" | "file"
project_id?: string
content_length?: number
file_size?: number
file_type?: string
source?: string
userId: string
mcp_client_name?: string
mcp_client_version?: string
sessionId?: string
containerTag?: string
}): Promise<void> {
const client = getInstance()
if (!client) return
try {
client.capture({
distinctId: props.userId,
event: "memory_added",
properties: {
...props,
mcp_server_version: MCP_SERVER_VERSION,
},
})
} catch (error) {
console.error("PostHog tracking error:", error)
}
}
export async function memorySearch(props: {
query_length: number
results_count: number
search_duration_ms: number
container_tags_count?: number
source?: string
userId: string
mcp_client_name?: string
mcp_client_version?: string
sessionId?: string
containerTag?: string
}): Promise<void> {
const client = getInstance()
if (!client) return
try {
client.capture({
distinctId: props.userId,
event: "memory_search",
properties: {
...props,
mcp_server_version: MCP_SERVER_VERSION,
},
})
} catch (error) {
console.error("PostHog tracking error:", error)
}
}
export async function memoryForgot(props: {
userId: string
content_length?: number
source?: string
mcp_client_name?: string
mcp_client_version?: string
sessionId?: string
containerTag?: string
}): Promise<void> {
const client = getInstance()
if (!client) return
try {
client.capture({
distinctId: props.userId,
event: "memory_forgot",
properties: {
...props,
mcp_server_version: MCP_SERVER_VERSION,
},
})
} catch (error) {
console.error("PostHog tracking error:", error)
}
}
export async function shutdown(): Promise<void> {
if (instance) {
await instance.shutdown()
instance = null
initialized = false
}
}
export const posthog = {
init: initPosthog,
memoryAdded,
memorySearch,
memoryForgot,
shutdown,
}

View file

@ -1,873 +0,0 @@
import { McpAgent } from "agents/mcp"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
import { SupermemoryClient } from "./client"
import { formatMemories, formatMemoriesList } from "./format"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
type Env = {
MCP_SERVER: DurableObjectNamespace
API_URL?: string
POSTHOG_API_KEY?: string
}
type Props = {
userId: string
apiKey: string
containerTag?: string
email?: string
name?: string
}
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
const MAX_RECALL_CHARS = 200000
const READ_ONLY_TOOL_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const
const MEMORY_TOOL_ANNOTATIONS = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
} as const
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
private containerTagsLastFetchedAt: number | null = null
server = new McpServer({
name: "supermemory",
version: "4.0.0",
})
async init() {
const storedClientInfo = await this.ctx.storage.get<{
name: string
version?: string
}>("clientInfo")
if (storedClientInfo) {
this.clientInfo = storedClientInfo
}
initPosthog(this.env.POSTHOG_API_KEY)
// Hook MCP McpAgent to capture client info
this.server.server.oninitialized = async () => {
const clientVersion = this.server.server.getClientVersion()
if (clientVersion) {
this.clientInfo = {
name: clientVersion.name,
version: clientVersion.version,
}
await this.ctx.storage.put("clientInfo", this.clientInfo)
}
}
await this.refreshContainerTags()
const hasRootContainerTag = !!this.props?.containerTag
const containerTagField = {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.describe(this.getContainerTagDescription())
.optional(),
}
const memorySchema = z.object({
content: z
.string()
.max(200000, "Content exceeds maximum length of 200,000 characters")
.describe("The memory content to save or forget"),
action: z.enum(["save", "forget"]).optional().default("save"),
...(hasRootContainerTag ? {} : containerTagField),
})
const recallSchema = z.object({
query: z
.string()
.max(1000, "Query exceeds maximum length of 1,000 characters")
.describe("The search query to find relevant memories"),
includeProfile: z.boolean().optional().default(true),
...(hasRootContainerTag ? {} : containerTagField),
})
const listMemoriesSchema = z.object({
page: z
.number()
.int()
.min(1)
.optional()
.default(1)
.describe("Page number (1-based)"),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe(
"Documents per page; each document groups its extracted memories (default 10, max 50)",
),
...(hasRootContainerTag ? {} : containerTagField),
})
const contextPromptSchema = z.object({
includeRecent: z
.boolean()
.optional()
.default(true)
.describe("Include recent activity in the profile"),
...(hasRootContainerTag ? {} : containerTagField),
})
type ContextPromptArgs = z.infer<typeof contextPromptSchema>
type MemoryArgs = z.infer<typeof memorySchema>
type RecallArgs = z.infer<typeof recallSchema>
type ListMemoriesArgs = z.infer<typeof listMemoriesSchema>
// Register memory tool
this.server.registerTool(
"memory",
{
description:
"DO NOT USE ANY OTHER MEMORY TOOL ONLY USE THIS ONE. Save or forget information about the user. Use 'save' when user shares preferences, facts, or asks to remember something. Use 'forget' when information is outdated or user requests removal.",
inputSchema: memorySchema,
annotations: MEMORY_TOOL_ANNOTATIONS,
},
// @ts-expect-error - zod type inference issue with MCP SDK
(args: MemoryArgs) => this.handleMemory(args),
)
// Register recall tool
this.server.registerTool(
"recall",
{
description:
"DO NOT USE ANY OTHER RECALL TOOL ONLY USE THIS ONE. Search the user's memories. Returns relevant memories plus their profile summary.",
inputSchema: recallSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
// @ts-expect-error - zod type inference issue with MCP SDK
(args: RecallArgs) => this.handleRecall(args),
)
// Register listMemories tool
this.server.registerTool(
"listMemories",
{
description:
"Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file — e.g. before forgetting stale memories or to power a 'list everything' view. For finding memories relevant to a topic, use 'recall' instead.",
inputSchema: listMemoriesSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
// @ts-expect-error - zod type inference issue with MCP SDK
(args: ListMemoriesArgs) => this.handleListMemories(args),
)
// Register profile resource
this.server.registerResource(
"User Profile",
"supermemory://profile",
{},
async () => {
const client = this.getClient()
const profileResult = await client.getProfile()
const parts: string[] = ["# User Profile\n"]
if (profileResult.profile.static.length > 0) {
parts.push("## Stable Preferences")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n## Recent Activity")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
return {
contents: [
{
uri: "supermemory://profile",
mimeType: "text/plain",
text:
parts.length > 1
? parts.join("\n")
: "No profile yet. Start saving memories.",
},
],
}
},
)
// Register projects resource
this.server.registerResource(
"My Projects",
"supermemory://projects",
{},
async () => {
await this.ensureContainerTagsFresh()
const projects = this.cachedContainerTags
return {
contents: [
{
uri: "supermemory://projects",
mimeType: "application/json",
text: JSON.stringify({ projects }, null, 2),
},
],
}
},
)
// Register listProjects tool
this.server.registerTool(
"listProjects",
{
description:
"List all available projects for organizing memories. Use this to discover valid project names for memory/recall operations.",
inputSchema: z.object({
refresh: z
.boolean()
.optional()
.default(false)
.describe(
"Force refresh from the server (default: false; uses cache with TTL)",
),
}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: { refresh?: boolean }) => {
try {
if (args.refresh === true) {
await this.refreshContainerTags()
} else {
await this.ensureContainerTagsFresh()
}
const projects = this.cachedContainerTags
if (projects.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No projects found. Memories will use the default project.",
},
],
}
}
return {
content: [
{
type: "text" as const,
text: `Available projects:\n${projects.map((p) => `- ${p}`).join("\n")}`,
},
],
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred"
return {
content: [
{
type: "text" as const,
text: `Error listing projects: ${message}`,
},
],
isError: true,
}
}
},
)
// Register whoAmI tool
this.server.registerTool(
"whoAmI",
{
description: "Get the current logged-in user's information",
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
// @ts-expect-error - zod type inference issue with MCP SDK
async () => {
if (!this.props) {
return {
content: [
{
type: "text" as const,
text: "User not authenticated",
},
],
}
}
const clientInfo = await this.getClientInfo()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
userId: this.props.userId,
email: this.props.email,
name: this.props.name,
client: clientInfo,
sessionId: this.getMcpSessionId(),
}),
},
],
}
},
)
// Register memory-graph tool with MCP App UI
const memoryGraphResourceUri = "ui://memory-graph/mcp-app.html"
const memoryGraphSchema = z.object({
...(hasRootContainerTag ? {} : containerTagField),
})
type MemoryGraphArgs = z.infer<typeof memoryGraphSchema>
registerAppTool(
this.server,
"memory-graph",
{
title: "Memory Graph",
description:
"Visualize the user's memory graph as an interactive force-directed graph showing documents, memories, and their relationships.",
inputSchema: memoryGraphSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: { ui: { resourceUri: memoryGraphResourceUri } },
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: MemoryGraphArgs) => {
try {
const effectiveContainerTag =
(args as { containerTag?: string }).containerTag ||
this.props?.containerTag
const client = this.getClient(effectiveContainerTag)
const containerTags = effectiveContainerTag
? [effectiveContainerTag]
: undefined
const result = await client.getDocuments(containerTags, 1, 10)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
)
const textParts = [
`Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`,
]
if (effectiveContainerTag) {
textParts.push(`Project: ${effectiveContainerTag}`)
}
return {
content: [{ type: "text" as const, text: textParts.join(". ") }],
structuredContent: {
containerTag: effectiveContainerTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
},
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred"
return {
content: [
{
type: "text" as const,
text: `Error loading memory graph: ${message}`,
},
],
isError: true,
}
}
},
)
// App-only tool for the UI to fetch additional documents (pagination)
registerAppTool(
this.server,
"fetch-graph-data",
{
description: "Fetch documents with memories for graph display",
inputSchema: z.object({
containerTag: z.string().optional(),
page: z.number().optional().default(1),
limit: z.number().optional().default(10),
}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: {
ui: {
resourceUri: memoryGraphResourceUri,
visibility: ["app"],
},
},
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: {
containerTag?: string
page?: number
limit?: number
}) => {
try {
const effectiveContainerTag =
args.containerTag || this.props?.containerTag
const client = this.getClient(effectiveContainerTag)
const containerTags = effectiveContainerTag
? [effectiveContainerTag]
: undefined
const data = await client.getDocuments(
containerTags,
args.page,
args.limit,
)
return {
content: [{ type: "text" as const, text: JSON.stringify(data) }],
structuredContent: data,
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred"
return {
content: [
{
type: "text" as const,
text: `Error fetching graph data: ${message}`,
},
],
isError: true,
}
}
},
)
// Register HTML resource for the memory graph UI
registerAppResource(
this.server,
"Memory Graph UI",
memoryGraphResourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [
{
uri: memoryGraphResourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: mcpAppHtml as string,
},
],
}),
)
this.server.registerPrompt(
"context",
{
description:
"User profile and preferences for system context injection. Returns a formatted system message with user's stable preferences and recent activity.",
//argsSchema: contextPromptSchema.shape, TODO: commenting out for now as it will add more friction to the user
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: ContextPromptArgs) => {
try {
const { includeRecent = true } = args
const containerTag = (args as { containerTag?: string }).containerTag
const client = this.getClient(containerTag)
const profileResult = await client.getProfile()
const parts: string[] = []
parts.push(
"**Important:** Whenever the user shares informative facts, preferences, personal details, or any memory-worthy information, use the `memory` tool to save it to Supermemory. This helps maintain context across conversations.",
)
parts.push("")
if (
profileResult.profile.static.length > 0 ||
(includeRecent && profileResult.profile.dynamic.length > 0)
) {
parts.push("## User Context")
}
if (profileResult.profile.static.length > 0) {
parts.push("**Stable Preferences:**")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (includeRecent && profileResult.profile.dynamic.length > 0) {
parts.push("\n**Recent Activity:**")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
const contextText =
parts.length > 2
? parts.join("\n")
: "**Important:** Whenever the user shares informative facts, preferences, personal details, or any memory-worthy information, use the `memory` tool to save it to Supermemory. This helps maintain context across conversations.\n\nNo user profile available yet. Start saving memories to build context."
return {
messages: [
{
role: "user",
content: {
type: "text",
text: contextText,
},
},
],
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred"
console.error("Context prompt failed:", error)
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Error retrieving user context: ${message}`,
},
},
],
}
}
},
)
}
/**
* Get a SupermemoryClient instance configured with the API key
*/
private getClient(containerTag?: string): SupermemoryClient {
if (!this.props) {
throw new Error("Props not initialized")
}
const { apiKey, containerTag: mcpRootContainerTag } = this.props
if (!apiKey) {
throw new Error("Authentication required")
}
const apiUrl = this.env.API_URL || "https://api.supermemory.ai"
return new SupermemoryClient(
apiKey,
containerTag || mcpRootContainerTag,
apiUrl,
)
}
private async handleMemory(args: {
content: string
action?: "save" | "forget"
containerTag?: string
}) {
const { content, action = "save", containerTag } = args
const effectiveContainerTag = containerTag || this.props?.containerTag
try {
const client = this.getClient(effectiveContainerTag)
const clientInfo = await this.getClientInfo()
if (action === "forget") {
const result = await client.forgetMemory(content)
// Track forget event
posthog
.memoryForgot({
userId: this.props?.userId || "unknown",
content_length: content.length,
source: "mcp",
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: result.containerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
return {
content: [
{
type: "text" as const,
text: `${result.message} in container ${result.containerTag}`,
},
],
}
}
const result = await client.createMemory(content)
if (!this.cachedContainerTags.includes(result.containerTag)) {
await this.refreshContainerTags()
}
// Track memory added event
posthog
.memoryAdded({
type: "note",
project_id: result.containerTag,
content_length: content.length,
source: "mcp",
userId: this.props?.userId || "unknown",
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: result.containerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
return {
content: [
{
type: "text" as const,
text: `Saved memory (id: ${result.id}) in ${result.containerTag} project`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
console.error("Memory operation failed:", error)
return {
content: [
{
type: "text" as const,
text: `Error: ${message}`,
},
],
isError: true,
}
}
}
private async handleRecall(args: {
query: string
includeProfile?: boolean
containerTag?: string
}) {
const { query, includeProfile = true, containerTag } = args
try {
const client = this.getClient(containerTag)
const clientInfo = await this.getClientInfo()
const startTime = Date.now()
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
) {
parts.push("## User Profile")
if (profileResult.profile.static.length > 0) {
parts.push("**Stable facts:**")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n**Recent context:**")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
parts.push("")
}
}
parts.push("## Relevant Memories")
parts.push(
formatMemories(
{
results: searchResult.results as unknown as Array<
Record<string, unknown>
>,
total: searchResult.total,
},
{ includeScores: true, includeLegend: true },
),
)
const endTime = Date.now()
// Track search event
posthog
.memorySearch({
query_length: query.length,
results_count: searchResult.results.length,
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))
const text = parts.join("\n")
return {
content: [
{
type: "text" as const,
text:
text.length > MAX_RECALL_CHARS
? `${text.slice(0, MAX_RECALL_CHARS)}...`
: text,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
console.error("Recall operation failed:", error)
return {
content: [
{
type: "text" as const,
text: `Error: ${message}`,
},
],
isError: true,
}
}
}
private async handleListMemories(args: {
page?: number
limit?: number
containerTag?: string
}) {
const { page = 1, limit = 10, containerTag } = args
const effectiveContainerTag = containerTag || this.props?.containerTag
try {
const client = this.getClient(effectiveContainerTag)
const result = await client.getDocuments(
effectiveContainerTag ? [effectiveContainerTag] : undefined,
page,
limit,
)
return {
content: [
{
type: "text" as const,
text: formatMemoriesList(result),
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
console.error("List memories operation failed:", error)
return {
content: [
{
type: "text" as const,
text: `Error listing memories: ${message}`,
},
],
isError: true,
}
}
}
private async getClientInfo(): Promise<
{ name: string; version?: string } | undefined
> {
if (this.clientInfo) {
return this.clientInfo
}
const storedClientInfo = await this.ctx.storage.get<{
name: string
version?: string
}>("clientInfo")
if (storedClientInfo) {
this.clientInfo = storedClientInfo
return this.clientInfo
}
return undefined
}
private getMcpSessionId(): string {
return this.ctx.id.name || "unknown"
}
private async ensureContainerTagsFresh(): Promise<void> {
const now = Date.now()
const needsRefresh =
this.containerTagsLastFetchedAt === null ||
now - this.containerTagsLastFetchedAt > CONTAINER_TAGS_TTL_MS
if (needsRefresh) {
await this.refreshContainerTags()
}
}
private async refreshContainerTags(): Promise<void> {
try {
const client = this.getClient()
this.cachedContainerTags = await client.getProjects()
this.containerTagsLastFetchedAt = Date.now()
} catch (error) {
console.error("Failed to fetch container tags:", error)
}
}
private getContainerTagDescription(): string {
const baseDescription = "Optional project to scope memories"
if (this.cachedContainerTags.length === 0) {
return baseDescription
}
return `${baseDescription}. Available projects: ${this.cachedContainerTags.join(", ")}`
}
}

View file

@ -0,0 +1,169 @@
import type { McpServer, ServerContext } from "@modelcontextprotocol/server"
import { describe, expect, it, vi } from "vitest"
import { z } from "zod"
import {
createTrackedToolServer,
posthogEventForToolExecution,
type McpToolAnalytics,
} from "./analytics"
function testServer() {
let callback: ((...args: unknown[]) => unknown) | undefined
const registerTool = vi.fn(
(
_name: string,
_config: unknown,
handler: (...args: unknown[]) => unknown,
) => {
callback = handler
return {}
},
)
return {
server: { registerTool } as unknown as McpServer,
invoke(...args: unknown[]) {
if (!callback) throw new Error("Tool was not registered")
return callback(...args)
},
}
}
const context = {
mcpReq: { envelope: {} },
} as unknown as ServerContext
describe("MCP tool analytics", () => {
it("records sanitized completion metadata without tool content", async () => {
const harness = testServer()
const record = vi.fn()
const analytics: McpToolAnalytics = { record }
const server = createTrackedToolServer(harness.server, analytics, () => ({
name: "claude",
version: "1.2.3",
}))
server.registerTool(
"search_memory",
{
inputSchema: z.object({ query: z.string(), containerTag: z.string() }),
},
async () => ({
content: [{ type: "text" as const, text: "secret result" }],
}),
)
await harness.invoke(
{ query: "private query", containerTag: "private-space" },
context,
)
expect(record).toHaveBeenCalledOnce()
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "search_memory",
surface: "model_tool",
outcome: "success",
spaceExplicit: true,
client: { name: "claude", version: "1.2.3" },
}),
)
expect(JSON.stringify(record.mock.calls[0])).not.toContain("private query")
expect(JSON.stringify(record.mock.calls[0])).not.toContain("private-space")
expect(JSON.stringify(record.mock.calls[0])).not.toContain("secret result")
})
it("treats returned MCP errors as failed executions", async () => {
const harness = testServer()
const record = vi.fn()
const server = createTrackedToolServer(
harness.server,
{ record },
() => null,
)
server.registerTool(
"save-memory",
{ inputSchema: z.object({}) },
async () => ({
content: [{ type: "text" as const, text: "failed" }],
isError: true,
}),
)
await harness.invoke({}, context)
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
surface: "app_action",
outcome: "error",
errorType: "tool_result",
}),
)
})
it("records thrown error categories and preserves the rejection", async () => {
const harness = testServer()
const record = vi.fn()
const server = createTrackedToolServer(
harness.server,
{ record },
() => null,
)
server.registerTool(
"fetch-graph-data",
{ inputSchema: z.object({}) },
async () => {
throw new TypeError("sensitive failure")
},
)
await expect(harness.invoke({}, context)).rejects.toThrow(
"sensitive failure",
)
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
surface: "app_internal",
outcome: "error",
errorType: "TypeError",
}),
)
expect(JSON.stringify(record.mock.calls[0])).not.toContain(
"sensitive failure",
)
})
it("uses the existing user identity and company group", () => {
const event = posthogEventForToolExecution(
{
userId: "user_123",
organizationId: "org_123",
oauthClientId: "client_123",
},
{
toolName: "guided-save",
surface: "app_launcher",
outcome: "success",
durationMs: 42,
spaceExplicit: false,
},
)
expect(event).toEqual({
distinctId: "user_123",
event: "mcp_tool_executed",
groups: { company: "org_123" },
properties: {
app: "mcp",
tool_name: "guided-save",
outcome: "success",
duration_ms: 42,
mcp_runtime: "stateless",
mcp_surface: "app_launcher",
space_explicit: false,
oauth_client_id: "client_123",
},
})
})
})

View file

@ -0,0 +1,216 @@
import type { McpServer, ServerContext } from "@modelcontextprotocol/server"
import { PostHog } from "posthog-node"
import type { ActorContext, ServerEnv } from "./types"
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
export type McpToolSurface =
| "model_tool"
| "app_launcher"
| "app_action"
| "app_internal"
export type McpToolOutcome = "success" | "error"
export interface McpToolExecution {
toolName: string
surface: McpToolSurface
outcome: McpToolOutcome
durationMs: number
spaceExplicit: boolean
client?: { name: string; version?: string }
errorType?: string
}
export interface McpToolAnalytics {
record(execution: McpToolExecution): void
}
export type WaitUntil = (promise: Promise<unknown>) => void
type ClientInfoResolver = (
context: ServerContext,
) => { name: string; version?: string } | null
const TOOL_SURFACES: Record<string, McpToolSurface> = {
search_memory: "model_tool",
listDocuments: "model_tool",
getDocument: "model_tool",
listMemories: "model_tool",
listSpaces: "model_tool",
whoAmI: "model_tool",
add_memory: "model_tool",
"select-space": "app_launcher",
"memory-graph": "app_launcher",
"guided-save": "app_launcher",
"upload-file": "app_launcher",
"set-active-tag": "app_action",
"save-memory": "app_action",
"upload-file-submit": "app_action",
"fetch-graph-data": "app_internal",
}
let posthogConfig:
| {
apiKey: string
host: string
client: PostHog
}
| undefined
function posthogClient(apiKey: string, host: string): PostHog {
if (posthogConfig?.apiKey === apiKey && posthogConfig.host === host) {
return posthogConfig.client
}
const client = new PostHog(apiKey, {
host,
flushAt: 1,
flushInterval: 0,
})
posthogConfig = { apiKey, host, client }
return client
}
export function posthogEventForToolExecution(
actor: Pick<ActorContext, "userId" | "organizationId" | "oauthClientId">,
execution: McpToolExecution,
) {
return {
distinctId: actor.userId,
event: "mcp_tool_executed",
groups: { company: actor.organizationId },
properties: {
app: "mcp",
tool_name: execution.toolName,
outcome: execution.outcome,
duration_ms: execution.durationMs,
mcp_runtime: "stateless",
mcp_surface: execution.surface,
space_explicit: execution.spaceExplicit,
...(execution.client
? {
mcp_client_name: execution.client.name,
...(execution.client.version
? { mcp_client_version: execution.client.version }
: {}),
}
: {}),
...(actor.oauthClientId ? { oauth_client_id: actor.oauthClientId } : {}),
...(execution.errorType ? { error_type: execution.errorType } : {}),
},
}
}
export function createPosthogAnalytics(
env: ServerEnv,
actor: ActorContext,
waitUntil: WaitUntil,
): McpToolAnalytics {
const apiKey = env.POSTHOG_API_KEY
if (!apiKey) return { record: () => undefined }
const client = posthogClient(apiKey, env.POSTHOG_HOST || DEFAULT_POSTHOG_HOST)
return {
record(execution) {
try {
const capture = client
.captureImmediate(posthogEventForToolExecution(actor, execution))
.catch((error) => console.error("PostHog MCP tracking error:", error))
waitUntil(capture)
} catch (error) {
console.error("PostHog MCP tracking error:", error)
}
},
}
}
function spaceWasExplicit(value: unknown): boolean {
if (!value || typeof value !== "object") return false
const containerTag = Reflect.get(value, "containerTag")
return typeof containerTag === "string" && containerTag.trim().length > 0
}
function isErrorResult(value: unknown): boolean {
return (
!!value &&
typeof value === "object" &&
Reflect.get(value, "isError") === true
)
}
function thrownErrorType(error: unknown): string {
if (error instanceof Error && error.name) return error.name
if (error && typeof error === "object") {
const status = Reflect.get(error, "status")
if (typeof status === "number") return `http_${status}`
}
return "unknown"
}
function safeRecord(analytics: McpToolAnalytics, execution: McpToolExecution) {
try {
analytics.record(execution)
} catch (error) {
console.error("MCP analytics recording error:", error)
}
}
export function createTrackedToolServer(
server: McpServer,
analytics: McpToolAnalytics,
getClientInfo: ClientInfoResolver,
): Pick<McpServer, "registerTool"> {
const registerTool = ((
name: string,
config: unknown,
handler: (...args: unknown[]) => unknown,
) => {
const trackedHandler = async (...callbackArgs: unknown[]) => {
const startedAt = performance.now()
const input = callbackArgs.length > 1 ? callbackArgs[0] : undefined
const context = callbackArgs.at(-1) as ServerContext
const finish = (outcome: McpToolOutcome, errorType?: string) => {
let client: ReturnType<ClientInfoResolver> = null
try {
client = getClientInfo(context)
} catch {
// Client metadata is optional and must never affect a tool call.
}
safeRecord(analytics, {
toolName: name,
surface: TOOL_SURFACES[name] ?? "model_tool",
outcome,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
spaceExplicit: spaceWasExplicit(input),
...(client ? { client } : {}),
...(errorType ? { errorType } : {}),
})
}
try {
const result = await handler(...callbackArgs)
if (isErrorResult(result)) {
finish("error", "tool_result")
} else {
finish("success")
}
return result
} catch (error) {
finish("error", thrownErrorType(error))
throw error
}
}
return Reflect.apply(server.registerTool, server, [
name,
config,
trackedHandler,
])
}) as McpServer["registerTool"]
return { registerTool }
}

View file

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

View file

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

View file

@ -0,0 +1,123 @@
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
import { fetchSession, validateOAuthToken } from "./index"
const API_URL = "https://api.example.com"
const ISSUER = `${API_URL}/api/auth`
const MCP_RESOURCE = "https://mcp.example.com/mcp"
describe("MCP authentication", () => {
let privateKey: CryptoKey
let keySet: ReturnType<typeof createLocalJWKSet>
beforeAll(async () => {
const keys = await generateKeyPair("RS256")
privateKey = keys.privateKey
const publicJwk = await exportJWK(keys.publicKey)
publicJwk.kid = "test-key"
keySet = createLocalJWKSet({ keys: [publicJwk] })
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
async function signToken(
overrides: {
audience?: string
subject?: string
expiresIn?: string
organizationId?: string
} = {},
) {
let token = new SignJWT({
...(overrides.organizationId === ""
? {}
: { organization_id: overrides.organizationId ?? "org_test" }),
azp: "client_test",
scope: "openid profile",
})
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setIssuer(ISSUER)
.setAudience(overrides.audience ?? MCP_RESOURCE)
.setIssuedAt()
.setExpirationTime(overrides.expiresIn ?? "5m")
if (overrides.subject !== "") {
token = token.setSubject(overrides.subject ?? "user_test")
}
return token.sign(privateKey)
}
it("validates an MCP-audience OAuth token without an API request", async () => {
const fetchSpy = vi.fn()
vi.stubGlobal("fetch", fetchSpy)
const token = await signToken()
await expect(
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
).resolves.toEqual({
userId: "user_test",
organizationId: "org_test",
bearerToken: token,
oauthClientId: "client_test",
scopes: ["openid", "profile"],
expiresAt: expect.any(Number),
})
expect(fetchSpy).not.toHaveBeenCalled()
})
it("rejects a token without an organization boundary", async () => {
const token = await signToken({ organizationId: "" })
await expect(
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
).resolves.toBeNull()
})
it("rejects a token issued for a different audience", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
const token = await signToken({ audience: "https://api.example.com" })
await expect(
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
).resolves.toBeNull()
})
it("rejects expired tokens and tokens without a subject", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
const expired = await signToken({ expiresIn: "-1s" })
const noSubject = await signToken({ subject: "" })
await expect(
validateOAuthToken(expired, API_URL, MCP_RESOURCE, keySet),
).resolves.toBeNull()
await expect(
validateOAuthToken(noSubject, API_URL, MCP_RESOURCE, keySet),
).resolves.toBeNull()
})
it("rejects opaque API keys without an API request", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
const fetchSpy = vi.fn()
vi.stubGlobal("fetch", fetchSpy)
await expect(
validateOAuthToken("sm_test", API_URL, MCP_RESOURCE, keySet),
).resolves.toBeNull()
expect(fetchSpy).not.toHaveBeenCalled()
})
it("surfaces on-demand session failures to the calling tool", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 403 })),
)
await expect(fetchSession("token", API_URL)).rejects.toMatchObject({
status: 403,
})
})
})

View file

@ -0,0 +1,102 @@
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"
import type { SessionInfo } from "../../shared/types"
const FETCH_TIMEOUT_MS = 30_000
export interface AuthUser {
userId: string
organizationId: string
bearerToken: string
oauthClientId?: string
scopes: string[]
expiresAt?: number
}
const remoteJwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
function authIssuer(apiUrl: string): string {
return `${apiUrl.replace(/\/+$/, "")}/api/auth`
}
function getRemoteJwks(jwksUrl: string) {
let keySet = remoteJwks.get(jwksUrl)
if (!keySet) {
keySet = createRemoteJWKSet(new URL(jwksUrl))
remoteJwks.set(jwksUrl, keySet)
}
return keySet
}
export async function fetchSession(
bearerToken: string,
apiUrl: string,
): Promise<SessionInfo> {
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/v3/session`, {
method: "GET",
headers: { Authorization: `Bearer ${bearerToken}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
throw Object.assign(
new Error(`Session request failed with status ${response.status}`),
{ status: response.status },
)
}
const session = (await response.json()) as SessionInfo | null
if (!session?.user?.id) {
throw new Error("Missing user.id in session response")
}
return session
}
export async function validateOAuthToken(
token: string,
apiUrl: string,
audience: string,
keySet?: JWTVerifyGetKey,
): Promise<AuthUser | null> {
try {
const issuer = authIssuer(apiUrl)
const verifier = keySet ?? getRemoteJwks(`${issuer}/jwks`)
const { payload } = await jwtVerify(token, verifier, {
issuer,
audience,
})
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
return null
}
if (
typeof payload.organization_id !== "string" ||
payload.organization_id.length === 0
) {
return null
}
const rawScopes = payload.scope ?? payload.scopes
const scopes = Array.isArray(rawScopes)
? rawScopes.filter((scope): scope is string => typeof scope === "string")
: typeof rawScopes === "string"
? rawScopes.split(/\s+/).filter(Boolean)
: []
return {
userId: payload.sub,
organizationId: payload.organization_id,
bearerToken: token,
oauthClientId:
typeof payload.azp === "string"
? payload.azp
: typeof payload.client_id === "string"
? payload.client_id
: undefined,
scopes,
expiresAt: payload.exp,
}
} catch (error) {
console.error("OAuth token validation error:", error)
return null
}
}

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest"
import type { SessionInfo } from "../../shared/types"
import { effectiveContainerTagAccess } from "./rbac"
const baseSession: SessionInfo = {
user: { id: "user_test" },
accessType: "full",
scope: { type: "full", permission: "write" },
}
describe("effectiveContainerTagAccess", () => {
it("marks every visible tag writable for full access", () => {
expect(effectiveContainerTagAccess(["one", "two"], baseSession)).toEqual([
{ containerTag: "one", permission: "write" },
{ containerTag: "two", permission: "write" },
])
})
it("preserves restricted member permissions", () => {
const session: SessionInfo = {
...baseSession,
accessType: "restricted",
containerTags: [
{ containerTag: "one", permission: "read" },
{ containerTag: "two", permission: "write" },
],
}
expect(effectiveContainerTagAccess(["one", "two"], session)).toEqual([
{ containerTag: "one", permission: "read" },
{ containerTag: "two", permission: "write" },
])
})
it("makes client-scoped read access authoritative for widget choices", () => {
const session: SessionInfo = {
...baseSession,
scope: {
type: "scoped",
permission: "read",
tags: ["one"],
},
}
expect(effectiveContainerTagAccess(["one"], session)).toEqual([
{ containerTag: "one", permission: "read" },
])
})
})

View file

@ -0,0 +1,34 @@
import type { ContainerTagAccess, SessionInfo } from "../../shared/types"
export function effectiveContainerTagAccess(
containerTags: string[],
session: SessionInfo,
): ContainerTagAccess[] {
const memberAccess = new Map(
(session.containerTags ?? []).map((access) => [
access.containerTag,
access.permission,
]),
)
const scopedTags = new Set(
session.scope?.tags ?? (session.scope?.tag ? [session.scope.tag] : []),
)
return containerTags.map((containerTag) => {
let permission: ContainerTagAccess["permission"] = "write"
if (session.accessType === "restricted") {
permission = memberAccess.get(containerTag) ?? "read"
}
if (
session.scope?.type === "scoped" &&
(session.scope.permission === "read" ||
(scopedTags.size > 0 && !scopedTags.has(containerTag)))
) {
permission = "read"
}
return { containerTag, permission }
})
}

View file

@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { SupermemoryClient } from "."
describe("SupermemoryClient memory listing", () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it("calls the canonical memory-list endpoint with the selected space", async () => {
const responseBody = {
memoryEntries: [
{
id: "mem_1",
memory: "User prefers dark mode",
version: 1,
isLatest: true,
isForgotten: false,
createdAt: "2026-07-29T00:00:00.000Z",
updatedAt: "2026-07-29T00:00:00.000Z",
history: [],
documentIds: ["doc_1"],
},
],
pagination: {
currentPage: 2,
limit: 20,
totalItems: 21,
totalPages: 2,
},
}
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
vi.stubGlobal("fetch", fetchMock)
const client = new SupermemoryClient(
"oauth-token",
"snowcone_grande",
"https://api.example.com",
)
await expect(client.listMemoryEntries(2, 20)).resolves.toEqual(responseBody)
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe("https://api.example.com/v4/memories/list")
expect(init.method).toBe("POST")
expect(init.headers).toMatchObject({
Authorization: "Bearer oauth-token",
"Content-Type": "application/json",
"x-sm-source": "supermemory-mcp",
})
expect(JSON.parse(init.body as string)).toEqual({
containerTags: ["snowcone_grande"],
page: 2,
limit: 20,
sort: "createdAt",
order: "desc",
})
})
})

View file

@ -1,32 +1,87 @@
import Supermemory from "supermemory"
import type {
DocumentGetResponse,
DocumentListResponse as SdkDocumentListResponse,
} from "supermemory/resources/documents"
import type {
ContainerTag,
DocumentMemoryEntry,
DocumentsApiResponse,
DocumentWithMemories,
} from "../../shared/types"
const MAX_CHARS = 200000 // ~50k tokens (character-based limit)
const DEFAULT_PROJECT_ID = "sm_project_default"
const MAX_CHARS = 200000
export const DEFAULT_PROJECT_ID = "sm_project_default"
const FETCH_TIMEOUT_MS = 30_000
const MCP_SOURCE = "supermemory-mcp"
interface MemoryRichFields {
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
export type {
ContainerTag,
DocumentMemoryEntry,
DocumentWithMemories,
DocumentsApiResponse,
}
export type DocumentSummary = SdkDocumentListResponse["memories"][number]
export type DocumentDetails = DocumentGetResponse
export interface DocumentsListResponse {
documents: DocumentSummary[]
pagination: SdkDocumentListResponse["pagination"]
}
export interface MemoryEntryHistory {
id: string
memory: string
version: number
createdAt: string
updatedAt: string
parentMemoryId?: string | null
rootMemoryId?: string | null
isLatest?: boolean
isForgotten?: boolean
}
export interface MemoryEntry {
id: string
memory: string
version: number
isLatest: boolean
isForgotten: boolean
isStatic?: boolean
isInference?: boolean
createdAt: string
updatedAt: string
sourceCount?: number
documentIds?: string[]
history?: MemoryEntryHistory[]
}
export interface MemoryEntriesResponse {
memoryEntries: MemoryEntry[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
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[]
@ -34,19 +89,6 @@ export interface SearchResult {
timing: number
}
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 {
static: string[]
dynamic: string[]
@ -57,53 +99,6 @@ export interface ProfileResponse {
searchResults?: SearchResult
}
export interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental: boolean
documentCount?: number
}
// Documents API types
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
}
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export function getMemoryText(m: Memory): string {
return "memory" in m ? m.memory : m.chunk
}
@ -112,7 +107,6 @@ function limitByChars(text: string, maxChars = MAX_CHARS): string {
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
// Type for SDK search result item
interface SDKResult {
id: string
memory?: string
@ -120,11 +114,7 @@ interface SDKResult {
content?: string
similarity: number
title?: string
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
context?: string
}
export class SupermemoryClient {
@ -145,12 +135,12 @@ export class SupermemoryClient {
apiKey: bearerToken,
baseURL: apiUrl,
timeout: FETCH_TIMEOUT_MS,
defaultHeaders: { "x-sm-source": MCP_SOURCE },
})
this.hasExplicitContainerTag = Boolean(containerTag)
this.containerTag = containerTag || DEFAULT_PROJECT_ID
}
// Create memory using SDK
async createMemory(
content: string,
): Promise<{ id: string; status: string; containerTag: string }> {
@ -158,9 +148,7 @@ export class SupermemoryClient {
const result = await this.client.add({
content,
containerTag: this.containerTag,
metadata: {
sm_source: "mcp",
},
metadata: { sm_source: MCP_SOURCE },
})
return {
id: result.id,
@ -172,65 +160,53 @@ export class SupermemoryClient {
}
}
// Delete/forget memory - try exact match first, then semantic search
async forgetMemory(
content: string,
): Promise<{ success: boolean; message: string; containerTag: string }> {
try {
// Try exact content matching first
try {
const result = await this.client.memories.forget({
content: content,
content,
containerTag: this.containerTag,
})
return {
success: true,
message: `Successfully forgot memory (exact match) with ID: ${result.id}`,
containerTag: this.containerTag,
}
} catch (error: unknown) {
// If not 404, it's a real error - re-throw it
const status =
error && typeof error === "object" && "status" in error
? (error as Record<string, unknown>).status
: undefined
if (status !== 404) {
throw error
}
// Otherwise continue to semantic search fallback
if (status !== 404) throw error
}
// Fallback to semantic search if exact match fails
const SIMILARITY_THRESHOLD = 0.85 // High threshold - only very similar memories
const SIMILARITY_THRESHOLD = 0.85
const searchResult = await this.search(
content,
5,
SIMILARITY_THRESHOLD,
undefined,
this.containerTag,
)
if (searchResult.results.length === 0) {
return {
success: false,
message: `No matching memory found to forget. Tried exact match and semantic search with similarity threshold ${SIMILARITY_THRESHOLD}.`,
message: "No matching memory found to forget.",
containerTag: this.containerTag,
}
}
// Only actual memories (not chunks) can be forgotten
const memoryToDelete = searchResult.results.find((r) => "memory" in r)
if (!memoryToDelete) {
return {
success: false,
message:
"No matching memory found to forget (only document chunks matched in semantic search).",
message: "No matching memory found (only chunks matched).",
containerTag: this.containerTag,
}
}
// Delete using the ID from semantic search
await this.client.memories.forget({
id: memoryToDelete.id,
containerTag: this.containerTag,
@ -240,7 +216,7 @@ export class SupermemoryClient {
getMemoryText(memoryToDelete) || memoryToDelete.content || ""
return {
success: true,
message: `Forgot similar memory (semantic match, similarity: ${memoryToDelete.similarity.toFixed(2)}): "${limitByChars(memoryText, 100)}"`,
message: `Forgot similar memory (similarity: ${memoryToDelete.similarity.toFixed(2)}): "${limitByChars(memoryText, 100)}"`,
containerTag: this.containerTag,
}
} catch (error) {
@ -248,12 +224,10 @@ export class SupermemoryClient {
}
}
// Search memories using SDK
async search(
query: string,
limit = 10,
threshold?: number,
options?: SearchOptions,
containerTagOverride?: string,
): Promise<SearchResult> {
try {
@ -264,25 +238,19 @@ export class SupermemoryClient {
q: query,
limit,
...(containerTag ? { containerTag } : {}),
searchMode: options?.searchMode ?? "hybrid",
threshold, // Optional threshold parameter
rerank: options?.rerank,
rewriteQuery: options?.rewriteQuery,
include: options?.include,
searchMode: "hybrid",
threshold,
})
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(r.content || r.memory || r.chunk || "")
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
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 }
@ -290,17 +258,12 @@ export class SupermemoryClient {
return { ...base, memory: text }
})
return {
results,
total: result.total,
timing: result.timing,
}
return { results, total: result.total, timing: result.timing }
} catch (error) {
this.handleOperationError("Search request", error)
}
}
// Get user profile using SDK
async getProfile(query?: string): Promise<ProfileResponse> {
if (!this.hasExplicitContainerTag) {
return {
@ -327,16 +290,16 @@ 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 || "")
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
if (r.chunk && !r.memory) return { ...base, chunk: text }
return { ...base, memory: text }
}),
total: result.searchResults.total,
@ -350,15 +313,15 @@ export class SupermemoryClient {
}
}
// Get projects list
async getProjects(options?: { signal?: AbortSignal }): Promise<string[]> {
async listContainerTags(): Promise<ContainerTag[]> {
try {
const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS)
const response = await fetch(`${this.apiUrl}/v3/projects`, {
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS)
const response = await fetch(`${this.apiUrl}/v3/container-tags/list`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
"x-sm-source": MCP_SOURCE,
},
signal,
})
@ -367,23 +330,22 @@ export class SupermemoryClient {
if (response.status === 401) {
throw new Error("Authentication failed. Please re-authenticate.")
}
throw new Error(`Failed to fetch projects: ${response.statusText}`)
throw new Error(
`Failed to fetch container tags: ${response.statusText}`,
)
}
const data = (await response.json()) as {
projects: Project[]
}
return data.projects?.map((p) => p.containerTag) || []
const data = (await response.json()) as ContainerTag[]
return Array.isArray(data) ? data : []
} catch (error) {
this.handleError(error)
}
}
// Fetch documents with their memory entries
async getDocuments(
containerTags?: string[],
page = 1,
limit = 10,
limit = 200,
options?: { signal?: AbortSignal },
): Promise<DocumentsApiResponse> {
try {
@ -393,6 +355,7 @@ export class SupermemoryClient {
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
"x-sm-source": MCP_SOURCE,
},
body: JSON.stringify({
page,
@ -414,6 +377,108 @@ export class SupermemoryClient {
}
}
async listDocuments(page = 1, limit = 50): Promise<DocumentsListResponse> {
try {
const result = await this.client.documents.list({
containerTags: [this.containerTag],
page,
limit,
sort: "createdAt",
order: "desc",
includeContent: false,
})
return {
documents: result.memories ?? [],
pagination: result.pagination,
}
} catch (error) {
this.handleError(error)
}
}
async getDocument(id: string): Promise<DocumentDetails> {
try {
return await this.client.documents.get(id)
} catch (error) {
this.handleError(error)
}
}
async listMemoryEntries(
page = 1,
limit = 50,
): Promise<MemoryEntriesResponse> {
try {
const response = await fetch(`${this.apiUrl}/v4/memories/list`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
"x-sm-source": MCP_SOURCE,
},
body: JSON.stringify({
containerTags: [this.containerTag],
page,
limit,
sort: "createdAt",
order: "desc",
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
const message = await response.text()
throw Object.assign(
new Error(message || "Failed to fetch memory entries"),
{ status: response.status },
)
}
return (await response.json()) as MemoryEntriesResponse
} catch (error) {
this.handleError(error)
}
}
async uploadFile(
fileData: ArrayBuffer,
fileName: string,
mimeType: string,
containerTag?: string,
): Promise<{ id: string; status: string }> {
try {
const formData = new FormData()
const blob = new Blob([fileData], { type: mimeType })
formData.append("file", blob, fileName)
if (containerTag) {
formData.append("containerTags", containerTag)
}
formData.append("metadata", JSON.stringify({ sm_source: MCP_SOURCE }))
const response = await fetch(`${this.apiUrl}/v3/documents/file`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"x-sm-source": MCP_SOURCE,
},
body: formData,
})
if (!response.ok) {
const text = await response.text()
throw Object.assign(new Error(text || "Upload failed"), {
status: response.status,
})
}
const result = (await response.json()) as { id: string; status: string }
return result
} catch (error) {
this.handleError(error)
}
}
private handleError(error: unknown): never {
// Handle request timeout (AbortSignal.timeout or explicit abort)
if (
@ -429,13 +494,10 @@ export class SupermemoryClient {
error.message.includes("fetch") ||
error.message.includes("network")
) {
throw new Error(
"Network error. Please check your connection and try again.",
)
throw new Error("Network error. Please check your connection.")
}
}
// Handle HTTP status errors from SDK/fetch
if (error && typeof error === "object" && "status" in error) {
const status = (error as { status: number }).status
const message =
@ -444,9 +506,7 @@ export class SupermemoryClient {
switch (status) {
case 400:
case 422:
throw new Error(
message || "Invalid request parameters. Please check your input.",
)
throw new Error(message || "Invalid request. Check your input.")
case 401:
throw new Error("Authentication failed. Please re-authenticate.")
case 402:
@ -457,27 +517,18 @@ export class SupermemoryClient {
"Access forbidden. Your account may be restricted or blocked.",
)
case 404:
throw new Error("Memory not found. It may have been deleted.")
throw new Error("Not found.")
case 429:
throw new Error(
"Rate limit exceeded. Please wait a moment and try again.",
)
throw new Error("Rate limit exceeded. Please wait and try again.")
default:
if (status >= 500) {
throw new Error(
"Server error. The service may be temporarily unavailable. Please try again later.",
)
throw new Error("Server error. Please try again later.")
}
}
}
// Re-throw Error instances as-is
if (error instanceof Error) {
throw error
}
// Wrap unknown errors
throw new Error(`An unexpected error occurred: ${String(error)}`)
if (error instanceof Error) throw error
throw new Error(`Unexpected error: ${String(error)}`)
}
private handleOperationError(operation: string, error: unknown): never {

View file

@ -0,0 +1,13 @@
import { z } from "zod"
export const containerTagSchema = z
.string()
.min(1, "Container tag is required")
.max(128, "Container tag exceeds maximum length")
.describe("Space key returned by listSpaces")
export const optionalContainerTagSchema = containerTagSchema
.optional()
.describe(
"Space key to use for this call. If the user names a space, call listSpaces to resolve its key and pass it here. Omit only when the user means the active space.",
)

View file

@ -0,0 +1,170 @@
import type {
DocumentDetails,
DocumentsListResponse,
MemoryEntriesResponse,
} from "./client"
const MAX_LIST_FIELD_CHARS = 500
const MAX_DOCUMENT_CONTENT_CHARS = 200_000
function compactText(value: string, maxChars = MAX_LIST_FIELD_CHARS): string {
const text = value.replace(/\s+/g, " ").trim()
return text.length > maxChars
? `${text.slice(0, maxChars)} ... [truncated]`
: text
}
function day(value: string | null | undefined): string {
return value?.slice(0, 10) ?? ""
}
function paginationSummary(
currentPage: number,
totalPages: number,
totalItems: number,
singularItemName: string,
pluralItemName: string,
): string {
const itemName = totalItems === 1 ? singularItemName : pluralItemName
return `page ${currentPage} of ${totalPages}, ${totalItems} ${itemName} total`
}
export function formatDocumentsList(response: DocumentsListResponse): string {
const { documents, pagination } = response
if (documents.length === 0) {
return pagination.currentPage > 1
? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
: "No documents stored yet."
}
const blocks = documents.map((document) => {
const title = document.title?.trim() || "(untitled)"
const lines = [
`- [${document.id}] "${title}" (${document.type}, ${document.status}, ${day(document.createdAt)})`,
]
if (document.summary?.trim()) {
lines.push(` Summary: ${compactText(document.summary)}`)
}
return lines.join("\n")
})
const parts = [
`${documents.length} document${documents.length === 1 ? "" : "s"} (${paginationSummary(
pagination.currentPage,
pagination.totalPages,
pagination.totalItems,
"document",
"documents",
)}), newest first.`,
"",
blocks.join("\n\n"),
"",
"Use getDocument with a document ID to read its content.",
]
if (pagination.currentPage < pagination.totalPages) {
parts.push(
`More available - call listDocuments with page: ${pagination.currentPage + 1}.`,
)
}
return parts.join("\n")
}
export function formatMemoryEntriesList(
response: MemoryEntriesResponse,
): string {
const { memoryEntries, pagination } = response
const activeEntries = memoryEntries.filter(
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
)
if (activeEntries.length === 0) {
return pagination.currentPage > 1
? `No active memories on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
: "No active memories stored yet."
}
const blocks = activeEntries.map((entry) => {
const lines = [`- [${entry.id}] ${compactText(entry.memory)}`]
const details = [
`version ${entry.version}`,
`updated ${day(entry.updatedAt)}`,
]
if (entry.history && entry.history.length > 0) {
details.push(
`${entry.history.length} previous ${
entry.history.length === 1 ? "version" : "versions"
}`,
)
}
lines.push(` ${details.join(" | ")}`)
if (entry.documentIds && entry.documentIds.length > 0) {
lines.push(` Source documents: ${entry.documentIds.join(", ")}`)
}
return lines.join("\n")
})
const parts = [
`${activeEntries.length} active memor${activeEntries.length === 1 ? "y" : "ies"} (${paginationSummary(
pagination.currentPage,
pagination.totalPages,
pagination.totalItems,
"memory entry",
"memory entries",
)}), newest first.`,
"",
blocks.join("\n\n"),
]
if (pagination.currentPage < pagination.totalPages) {
parts.push(
"",
`More available - call listMemories with page: ${pagination.currentPage + 1}.`,
)
}
return parts.join("\n")
}
function documentContent(document: DocumentDetails): string | null {
if (typeof document.raw === "string" && document.raw.trim()) {
return document.raw
}
if (document.raw !== null && document.raw !== undefined) {
return JSON.stringify(document.raw, null, 2)
}
if (document.content?.trim()) return document.content
return null
}
export function formatDocument(document: DocumentDetails): string {
const title = document.title?.trim() || "(untitled)"
const parts = [
`# ${title}`,
`Document ID: ${document.id}`,
`Type: ${document.type}`,
`Status: ${document.status}`,
`Created: ${document.createdAt}`,
`Updated: ${document.updatedAt}`,
]
if (document.url) parts.push(`URL: ${document.url}`)
if (document.summary?.trim()) {
parts.push("", "## Summary", compactText(document.summary, 4_000))
}
const content = documentContent(document)
if (content) {
const truncated =
content.length > MAX_DOCUMENT_CONTENT_CHARS
? `${content.slice(0, MAX_DOCUMENT_CONTENT_CHARS)}\n\n[Document content truncated]`
: content
parts.push("", "## Content", truncated)
} else {
parts.push("", "No document content is available.")
}
return parts.join("\n")
}

View file

@ -0,0 +1,211 @@
import type { AuthInfo } from "@modelcontextprotocol/server"
import { createMcpHandler } from "agents/mcp/server"
import { Hono, type Context } from "hono"
import { cors } from "hono/cors"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { validateOAuthToken, type AuthUser } from "./auth"
import { SupermemoryMCP } from "./legacy-protocol-state"
import { createSupermemoryServer } from "./server"
import type { ActorContext, ServerEnv } from "./types"
import { SpaceState } from "./space-state"
type Bindings = ServerEnv
const app = new Hono<{ Bindings: Bindings }>()
const DEFAULT_API_URL = "https://api.supermemory.ai"
const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp"
const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource/mcp"
const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [
"app.supermemory.ai",
"mcp.supermemory.ai",
"mcp.dev.supermemory.ai",
"mcp.dev.supermemory",
"claude.ai",
"chatgpt.com",
"chat.openai.com",
"gemini.google.com",
"grok.com",
"x.ai",
"t3.chat",
"localhost",
"127.0.0.1",
"[::1]",
]
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
exposeHeaders: ["WWW-Authenticate"],
}),
)
app.get("/", (c) => {
return c.json({
name: "supermemory-mcp",
version: "1.0.0",
description: "Supermemory MCP - AI memory for teams",
docs: "https://supermemory.ai/docs/supermemory-mcp/mcp",
})
})
function resourceMetadata(c: Context<{ Bindings: Bindings }>) {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
return c.json({
resource: mcpResource,
authorization_servers: [`${apiUrl.replace(/\/+$/, "")}/api/auth`],
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
resource_documentation: "https://supermemory.ai/docs/supermemory-mcp/mcp",
})
}
app.get("/.well-known/oauth-protected-resource", resourceMetadata)
app.get(PROTECTED_RESOURCE_METADATA_PATH, resourceMetadata)
app.get("/.well-known/oauth-authorization-server", async (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
try {
const response = await fetch(
`${apiUrl}/.well-known/oauth-authorization-server`,
)
if (!response.ok) {
return c.json(
{ error: "Failed to fetch authorization server metadata" },
{ status: response.status as ContentfulStatusCode },
)
}
return c.json(await response.json())
} catch (error) {
console.error("Error fetching OAuth metadata:", error)
return c.json({ error: "Internal server error" }, 500)
}
})
function allowedOriginHostnames(env: Bindings): string[] {
const configured =
env.ALLOWED_MCP_ORIGIN_HOSTNAMES?.split(",")
.map((hostname) => hostname.trim().toLowerCase())
.filter(Boolean) ?? []
return [...new Set([...DEFAULT_ALLOWED_ORIGIN_HOSTNAMES, ...configured])]
}
function authInfoFor(
authUser: AuthUser,
resource: string,
): AuthInfo | undefined {
if (!authUser.oauthClientId) return undefined
return {
token: authUser.bearerToken,
clientId: authUser.oauthClientId,
scopes: authUser.scopes,
expiresAt: authUser.expiresAt,
resource: new URL(resource),
extra: {
userId: authUser.userId,
organizationId: authUser.organizationId,
},
}
}
function unauthorizedResponse(
resourceMetadataUrl: string,
invalidToken = false,
): Response {
if (!invalidToken) {
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
})
}
return Response.json(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Invalid or expired token",
},
id: null,
},
{
status: 401,
headers: {
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Allow-Origin": "*",
},
},
)
}
async function handleMcpRequest(
c: Context<{ Bindings: Bindings }>,
rewritePath?: string,
) {
const authHeader = c.req.header("Authorization")
const token = authHeader?.replace(/^Bearer\s+/i, "").trim()
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
const reqProto = c.req.header("x-forwarded-proto") || "https"
const resourceMetadataUrl = reqHost
? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}`
: PROTECTED_RESOURCE_METADATA_PATH
if (!token) return unauthorizedResponse(resourceMetadataUrl)
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
const actor: ActorContext = {
userId: authUser.userId,
organizationId: authUser.organizationId,
bearerToken: authUser.bearerToken,
oauthClientId: authUser.oauthClientId,
}
const request = rewritePath
? new Request(new URL(rewritePath, c.req.url).toString(), c.req.raw)
: c.req.raw
const handler = createMcpHandler(
() =>
createSupermemoryServer(c.env, actor, (promise) =>
c.executionCtx.waitUntil(promise),
),
{
route: "/mcp",
legacy: "stateless",
corsOptions: false,
allowedOriginHostnames: allowedOriginHostnames(c.env),
onerror: (error) => console.error("MCP request error:", error),
},
)
return handler.fetch(request, {
authInfo: authInfoFor(authUser, mcpResource),
})
}
app.all("/", (c) => handleMcpRequest(c, "/mcp"))
app.all("/mcp", (c) => handleMcpRequest(c))
app.all("/mcp/", (c) => handleMcpRequest(c, "/mcp"))
export { SpaceState, SupermemoryMCP }
export type { ActorContext, ServerEnv }
export default app

View file

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

View file

@ -0,0 +1,115 @@
import type { McpServer } from "@modelcontextprotocol/server"
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
import {
compactDescription,
formatFactSection,
formatSpaceRow,
sortSpaces,
spaceDisplayName,
spaceMetadata,
} from "../space-presentation"
const CONTEXT_FACT_LIMIT = 8
const RECENT_SPACE_LIMIT = 3
export function registerContextPrompt(
server: McpServer,
getClient: (tag?: string) => SupermemoryClient,
resolveContainerTag: () => Promise<string | undefined>,
) {
server.registerPrompt(
"context",
{
description: "Attach compact context for the active space",
},
async () => {
try {
const selectedTag = await resolveContainerTag()
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
const [profileResult, spaces] = await Promise.all([
getClient(activeKey).getProfile(),
getClient().listContainerTags(),
])
const activeSpace = spaces.find(
(space) => space.containerTag === activeKey,
)
const activeLabel = spaceDisplayName(activeSpace, activeKey)
const fallback = selectedTag ? "" : " (default)"
const parts: string[] = [
"# Supermemory Context",
`Active space: ${activeLabel} [${activeKey}]${fallback}`,
]
if (activeSpace) {
const metadata = spaceMetadata(activeSpace)
if (metadata) parts.push(metadata)
const description = compactDescription(activeSpace.description)
if (description) parts.push(description)
}
parts.push(
"",
...formatFactSection(
"Stable Context",
profileResult.profile.static,
CONTEXT_FACT_LIMIT,
),
...formatFactSection(
"Recent Context",
profileResult.profile.dynamic,
CONTEXT_FACT_LIMIT,
),
)
if (
profileResult.profile.static.length === 0 &&
profileResult.profile.dynamic.length === 0
) {
parts.push("No profile facts are available for this space yet.")
}
const recentSpaces = sortSpaces(spaces, activeKey)
.filter((space) => space.containerTag !== activeKey)
.slice(0, RECENT_SPACE_LIMIT)
if (recentSpaces.length > 0) {
parts.push(
"",
"## Recently Active Spaces",
...recentSpaces.map((space) =>
formatSpaceRow(space, activeKey, 100),
),
)
}
parts.push(
"",
"Use a space key with space-aware tools when the user asks about another space. Keep space contexts separate unless the user asks to combine them.",
)
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: parts.join("\n"),
},
},
],
}
} catch {
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: "Unable to load user context.",
},
},
],
}
}
},
)
}

View file

@ -0,0 +1,46 @@
import type { McpServer } from "@modelcontextprotocol/server"
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
import {
formatSpaceRow,
sortSpaces,
spaceDisplayName,
} from "../space-presentation"
export function registerContainerTagsResource(
server: McpServer,
getClient: () => SupermemoryClient,
resolveContainerTag: () => Promise<string | undefined>,
) {
server.registerResource("My Spaces", "supermemory://spaces", {}, async () => {
const client = getClient()
const [containerTags, selectedTag] = await Promise.all([
client.listContainerTags(),
resolveContainerTag(),
])
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
const activeSpace = containerTags.find(
(space) => space.containerTag === activeKey,
)
const rows = sortSpaces(containerTags, activeKey).map((space) =>
formatSpaceRow(space, activeKey),
)
const activeLabel = spaceDisplayName(activeSpace, activeKey)
const fallback = selectedTag ? "" : " (default)"
const text = [
"# My Spaces",
`${containerTags.length} available · Active: ${activeLabel} [${activeKey}]${fallback}`,
"",
...rows,
].join("\n")
return {
contents: [
{
uri: "supermemory://spaces",
mimeType: "text/plain",
text,
},
],
}
})
}

View file

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

View file

@ -0,0 +1,45 @@
import type { McpServer } from "@modelcontextprotocol/server"
import supermemoryAppHtml from "../../../dist/src/widget/index.html"
import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
import { APP_RESOURCE_MIME_TYPE } from "../app-metadata"
const CSP_DOMAINS = [
"https://esm.sh",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
] as const
const RESOURCE_UI_META = {
prefersBorder: true,
csp: {
resourceDomains: [...CSP_DOMAINS],
connectDomains: [...CSP_DOMAINS],
},
}
export function registerWidgetResource(server: McpServer) {
server.registerResource(
"Supermemory MCP UI",
SUPERMEMORY_RESOURCE_URI,
// Listing-level metadata: hosts use this when discovering resources
// before invoking the read callback. Mirrors the read response below
// so prefetch/connect-time decisions match what the host will get.
{
mimeType: APP_RESOURCE_MIME_TYPE,
_meta: { ui: RESOURCE_UI_META },
},
// Read response: per spec, content-item `_meta.ui` takes precedence
// over the listing-level value. Set both to the same object so behavior
// is consistent regardless of which path the host inspects.
async () => ({
contents: [
{
uri: SUPERMEMORY_RESOURCE_URI,
mimeType: APP_RESOURCE_MIME_TYPE,
text: supermemoryAppHtml,
_meta: { ui: RESOURCE_UI_META },
},
],
}),
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest"
import { optionalContainerTagSchema } from "./container-tag"
import { resolveContainerTag, spaceStateName } from "./space"
describe("space application state", () => {
it("keys active state by organization and user without collisions", () => {
expect(
spaceStateName({
organizationId: "org:one",
userId: "user:two",
}),
).not.toBe(
spaceStateName({
organizationId: "org",
userId: "one:user:two",
}),
)
})
it("uses an explicit tool argument without reading active state", async () => {
const getActive = vi.fn().mockResolvedValue("active")
await expect(resolveContainerTag("explicit", getActive)).resolves.toBe(
"explicit",
)
expect(getActive).not.toHaveBeenCalled()
})
it("falls back to durable active state and then the client default", async () => {
await expect(
resolveContainerTag(undefined, vi.fn().mockResolvedValue("active")),
).resolves.toBe("active")
await expect(
resolveContainerTag(undefined, vi.fn().mockResolvedValue(undefined)),
).resolves.toBeUndefined()
})
it("tells the model how to route explicit space requests", () => {
expect(optionalContainerTagSchema.description).toContain(
"If the user names a space",
)
expect(optionalContainerTagSchema.description).toContain("listSpaces")
expect(optionalContainerTagSchema.description).toContain("active space")
})
})

View file

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

View file

@ -0,0 +1,50 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
content: z
.string()
.max(200000, "Content exceeds maximum length")
.describe("The memory content to save or forget"),
action: z.enum(["save", "forget"]).optional().default("save"),
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"add_memory",
{
description:
"Add (save) or forget a memory in the user's ACTIVE space. Defaults to 'save'. The target space is the one the user selected via select-space; pass containerTag only to override it. Use 'forget' when information is outdated or the user asks to remove it.",
inputSchema,
annotations: MEMORY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
if (args.action === "forget") {
const result = await client.forgetMemory(args.content)
return {
content: [{ type: "text" as const, text: result.message }],
}
}
const result = await client.createMemory(args.content)
return {
content: [
{
type: "text" as const,
text: `Memory saved (ID: ${result.id}, space: ${result.containerTag})`,
},
],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,14 @@
/** Tool safety hints for hosts like ChatGPT that surface annotation metadata. */
export const READ_ONLY_TOOL_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const
export const MEMORY_TOOL_ANNOTATIONS = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
} as const

View file

@ -0,0 +1,40 @@
import { z } from "zod"
import { appToolMeta } from "../app-metadata"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"fetch-graph-data",
{
description: "Fetch documents with memories for graph display",
inputSchema: z.object({
containerTag: optionalContainerTagSchema,
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: appToolMeta(["app"]),
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const data = await client.getDocuments(
containerTags,
args.page,
args.limit,
)
return {
content: [{ type: "text" as const, text: JSON.stringify(data) }],
structuredContent: data,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,37 @@
import { z } from "zod"
import { formatDocument } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
documentId: z
.string()
.min(1, "Document ID is required")
.max(255, "Document ID exceeds maximum length")
.describe("Document ID returned by listDocuments or a memory result"),
})
deps.server.registerTool(
"getDocument",
{
title: "Get Document",
description:
"Read one stored document by ID, including its summary and available content. Use listDocuments in the intended space to discover document IDs.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const client = deps.getClient()
const document = await client.getDocument(args.documentId)
return {
content: [{ type: "text" as const, text: formatDocument(document) }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,54 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"guided-save",
{
title: "Add Memory",
description: "Save information to memory with an interactive form.",
inputSchema: z.object({
prefill: z.string().optional().describe("Optional content to prefill"),
}),
_meta: appToolMeta(),
},
async (args) => {
try {
const { prefill } = args
const viewId = crypto.randomUUID()
const [activeTag, tags, session] = await Promise.all([
deps.getActiveContainerTag(),
deps.getClient().listContainerTags(),
deps.getSession(),
])
const writableTags = effectiveContainerTagAccess(
tags.map((tag) => tag.containerTag),
session,
)
.filter((access) => access.permission === "write")
.map((access) => access.containerTag)
const sc: ViewMessage = {
view: "save",
viewId,
activeTag,
writableTags,
prefill,
}
return {
content: [
{ type: "text" as const, text: "Opening memory save form..." },
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,34 @@
import * as addMemory from "./add-memory"
import * as fetchGraphData from "./fetch-graph-data"
import * as getDocument from "./get-document"
import * as guidedSave from "./guided-save"
import * as listContainerTags from "./list-container-tags"
import * as listDocuments from "./list-documents"
import * as listMemories from "./list-memories"
import * as memoryGraph from "./memory-graph"
import * as saveMemory from "./save-memory"
import * as searchMemory from "./search-memory"
import * as selectSpace from "./select-space"
import * as setActiveTag from "./set-active-tag"
import type { ToolDeps } from "./types"
import * as uploadFile from "./upload-file"
import * as uploadFileSubmit from "./upload-file-submit"
import * as whoAmI from "./who-am-i"
export function registerAllTools(deps: ToolDeps) {
searchMemory.register(deps)
listDocuments.register(deps)
getDocument.register(deps)
listMemories.register(deps)
listContainerTags.register(deps)
whoAmI.register(deps)
selectSpace.register(deps)
setActiveTag.register(deps)
memoryGraph.register(deps)
fetchGraphData.register(deps)
addMemory.register(deps)
guidedSave.register(deps)
saveMemory.register(deps)
uploadFile.register(deps)
uploadFileSubmit.register(deps)
}

View file

@ -0,0 +1,49 @@
import { z } from "zod"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"listSpaces",
{
description:
"List the spaces available to the user. Returns each space's name, key, emoji, document/memory counts, and last activity. Use this first to resolve a named space before calling a space-aware tool, or when the user asks which space may contain something. The list is auto-filtered to spaces the user can access.",
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async () => {
try {
const tags = await deps.getClient().listContainerTags()
if (tags.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No spaces found.",
},
],
}
}
const lines = tags.map((t) => {
const display = t.emoji ? `${t.emoji} ${t.name}` : t.name
const counts = `(${t.documentCount} docs, ${t.memoryCount} memories)`
return `- ${display} [${t.containerTag}] ${counts}`
})
return {
content: [
{
type: "text" as const,
text: `Available spaces:\n${lines.join("\n")}`,
},
],
structuredContent: { containerTags: tags },
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,53 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { formatDocumentsList } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
page: z
.number()
.int()
.min(1)
.optional()
.default(1)
.describe("Page number (1-based)"),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe("Documents per page (default 10, max 50)"),
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"listDocuments",
{
title: "List Documents",
description:
"List documents in one space with their IDs, titles, types, processing status, dates, and summaries. This does not return full document content; use getDocument with an ID from this result to read one document. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const data = await client.listDocuments(
args.page ?? 1,
args.limit ?? 10,
)
return {
content: [{ type: "text" as const, text: formatDocumentsList(data) }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,55 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { formatMemoryEntriesList } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
page: z
.number()
.int()
.min(1)
.optional()
.default(1)
.describe("Page number (1-based)"),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe("Memory entries per page (default 10, max 50)"),
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"listMemories",
{
title: "List Memories",
description:
"List the latest extracted memory entries in one space, including stable memory IDs, version information, and source document IDs. This lists memories directly, not documents. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space. Use search_memory instead for semantic recall.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const data = await client.listMemoryEntries(
args.page ?? 1,
args.limit ?? 10,
)
return {
content: [
{ type: "text" as const, text: formatMemoryEntriesList(data) },
],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,57 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appToolMeta } from "../app-metadata"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"memory-graph",
{
title: "Memory Graph",
description:
"Render the space's memory graph directly as an interactive MCP App. This tool is the final visualization; do not create another graph, file, or artifact unless the user explicitly asks for one. When the user names a space, resolve it with listSpaces and pass containerTag.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: appToolMeta(),
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const result = await client.getDocuments(containerTags, 1, 200)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
)
const sc: ViewMessage = {
view: "graph",
containerTag: effectiveTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
}
return {
content: [
{
type: "text" as const,
text: `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
},
],
structuredContent: sc,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

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

View file

@ -0,0 +1,71 @@
import { z } from "zod"
import { getMemoryText } from "../client"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
query: z
.string()
.max(1000, "Query exceeds maximum length")
.describe("The search query to find relevant memories"),
includeProfile: z.boolean().optional().default(true),
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"search_memory",
{
description:
"Search memories in one space with a natural-language query. Returns relevant memories plus that space's profile summary. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const parts: string[] = []
if (args.includeProfile !== false) {
const profileResult = await client.getProfile(args.query)
if (profileResult.profile.static.length > 0) {
parts.push("## Profile")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n## Recent context")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
}
const searchResult = await client.search(args.query)
if (searchResult.results.length > 0) {
parts.push("\n## Matching memories")
for (const result of searchResult.results) {
const text = getMemoryText(result)
const similarity = (result.similarity * 100).toFixed(0)
parts.push(`- [${similarity}%] ${text}`)
}
} else {
parts.push("\nNo matching memories found.")
}
return {
content: [{ type: "text" as const, text: parts.join("\n") }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,54 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"select-space",
{
title: "Select Space",
description:
"Choose the active Supermemory space. Shows available spaces as interactive cards.",
inputSchema: z.object({}),
_meta: appToolMeta(),
},
async () => {
try {
const viewId = crypto.randomUUID()
const client = deps.getClient()
const [tags, session, activeTag] = await Promise.all([
client.listContainerTags(),
deps.getSession(),
deps.getActiveContainerTag(),
])
const assignedTags = effectiveContainerTagAccess(
tags.map((tag) => tag.containerTag),
session,
)
const sc: ViewMessage = {
view: "picker",
viewId,
containerTags: tags,
activeTag,
assignedTags,
}
return {
content: [
{
type: "text" as const,
text: `${tags.length} spaces available. Select one to set your active context.`,
},
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,49 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"set-active-tag",
{
description: "Set the active Supermemory space for this account",
inputSchema: z.object({
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
_meta: appToolMeta(["app"]),
},
async (args) => {
const { containerTag } = args
try {
const viewId = args.viewId ?? crypto.randomUUID()
const tags = await deps.getClient().listContainerTags()
if (!tags.some((tag) => tag.containerTag === containerTag)) {
return deps.errorResult(
new Error(`No access to container tag '${containerTag}'.`),
)
}
await deps.setActiveContainerTag(containerTag)
const sc: ViewMessage = {
view: "confirmation",
viewId,
containerTag,
}
return {
content: [
{
type: "text" as const,
text: `Active space set to ${containerTag}`,
},
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,32 @@
import type { McpServer, ServerContext } from "@modelcontextprotocol/server"
import type { SessionInfo } from "../../shared/types"
import type { SupermemoryClient } from "../client"
import type { ActorContext } from "../types"
// Dependencies passed to every tool's register() function.
// Keep this surface small — tools should read this rather than reach into the agent.
export interface ToolDeps {
server: Pick<McpServer, "registerTool">
actor: ActorContext
getClient: (containerTag?: string) => SupermemoryClient
getSession: () => Promise<SessionInfo>
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
getActiveContainerTag: () => Promise<string | undefined>
setActiveContainerTag: (containerTag: string) => Promise<void>
getClientInfo: (
context: ServerContext,
) => { name: string; version?: string } | null
errorResult: (error: unknown) => {
content: { type: "text"; text: string }[]
isError: true
}
}
export function errorResult(error: unknown) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true as const,
}
}

View file

@ -0,0 +1,63 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"upload-file-submit",
{
description: "Submit a file upload",
inputSchema: z.object({
fileData: z.string().describe("Base64-encoded file content"),
fileName: z.string(),
mimeType: z.string(),
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
annotations: MEMORY_TOOL_ANNOTATIONS,
_meta: appToolMeta(["app"]),
},
async (args) => {
try {
const viewId = args.viewId ?? crypto.randomUUID()
const binaryString = atob(args.fileData)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
const client = deps.getClient(args.containerTag)
const result = await client.uploadFile(
bytes.buffer as ArrayBuffer,
args.fileName,
args.mimeType,
args.containerTag,
)
const sc: ViewMessage = {
view: "upload-success",
viewId,
id: result.id,
fileName: args.fileName,
containerTag: args.containerTag,
}
return {
content: [
{
type: "text" as const,
text: `File uploaded: ${args.fileName}${result.id}`,
},
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,50 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"upload-file",
{
title: "Upload File",
description: "Upload a file (PDF, text, image, video) to memory.",
inputSchema: z.object({}),
_meta: appToolMeta(),
},
async () => {
try {
const viewId = crypto.randomUUID()
const [activeTag, tags, session] = await Promise.all([
deps.getActiveContainerTag(),
deps.getClient().listContainerTags(),
deps.getSession(),
])
const writableTags = effectiveContainerTagAccess(
tags.map((tag) => tag.containerTag),
session,
)
.filter((access) => access.permission === "write")
.map((access) => access.containerTag)
const sc: ViewMessage = {
view: "upload",
viewId,
activeTag,
writableTags,
}
return {
content: [
{ type: "text" as const, text: "Opening file upload form..." },
],
structuredContent: sc,
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,48 @@
import { z } from "zod"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"whoAmI",
{
description: "Get current user info, role, and space context",
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (_args, context) => {
try {
const [session, activeTag] = await Promise.all([
deps.getSession(),
deps.getActiveContainerTag(),
])
const client = deps.getClientInfo(context)
const sessionId = context.sessionId
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
userId: session.user.id,
email: session.user.email,
name: session.user.name,
role: session.role ?? "unknown",
accessType: session.accessType ?? "full",
activeSpace: activeTag ?? null,
assignedSpaces:
session.accessType === "restricted"
? session.containerTags
: null,
scope: session.scope,
...(client ? { client } : {}),
...(sessionId ? { sessionId } : {}),
}),
},
],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,17 @@
import type { SpaceState } from "./space-state"
export interface ActorContext {
userId: string
organizationId: string
bearerToken: string
oauthClientId?: string
}
export interface ServerEnv {
SPACE_STATE: DurableObjectNamespace<SpaceState>
API_URL?: string
MCP_RESOURCE?: string
ALLOWED_MCP_ORIGIN_HOSTNAMES?: string
POSTHOG_API_KEY?: string
POSTHOG_HOST?: string
}

View file

@ -0,0 +1,133 @@
// Shared types — imported by both server tools and widget views.
// Single source of truth for the server↔widget contract.
export interface ContainerTagAccess {
containerTag: string
permission: "read" | "write"
}
export interface SessionScope {
type: "full" | "scoped"
permission?: "read" | "write"
tag?: string
tags?: string[]
rateLimit?: number
expires?: string
}
export interface SessionInfo {
user: {
id: string
email?: string
name?: string
}
role?: string
accessType?: "full" | "restricted"
containerTags?: ContainerTagAccess[] | null
scope?: SessionScope
}
export interface ContainerTag {
id: string
name: string
containerTag: string
description?: string | null
visibility?: string | null
createdAt: string
updatedAt: string
isExperimental: boolean
emoji?: string
isNova: boolean
documentCount: number
memoryCount: number
lastActivityAt: string | null
}
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
memoryRelations?: Record<string, string>
createdAt: string
updatedAt: string
}
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
// Adding a new view here is a compile error in App.tsx until the case is handled.
type ViewMessagePayload =
| {
view: "picker"
containerTags: ContainerTag[]
activeTag?: string | null
assignedTags?: ContainerTagAccess[] | null
}
| { view: "confirmation"; containerTag: string }
| {
view: "save"
activeTag?: string | null
writableTags: string[]
prefill?: string
}
| { view: "save-success"; id: string; containerTag: string }
| {
view: "upload"
activeTag?: string | null
writableTags: string[]
}
| {
view: "upload-success"
id: string
fileName: string
containerTag: string
}
| {
view: "graph"
documents: DocumentWithMemories[]
totalCount: number
containerTag?: string
}
export type ViewMessage = ViewMessagePayload & {
/**
* Stable identity for one rendered widget instance.
*
* The host may remount the iframe when a conversation is revisited. The
* widget uses this id to restore a completed local view without treating UI
* state as the source of truth for the underlying Supermemory write.
*/
viewId?: string
}
export type ViewName = ViewMessage["view"]
// Hosts cache MCP UI resources by URI, so bump this when shipping a new widget bundle.
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v3.html"

View file

@ -1,46 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 600px;
min-height: 600px;
overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
}
:root {
--bg: #0f1419;
--bg-secondary: #1a1f29;
--text: #e2e8f0;
--text-muted: #94a3b8;
--border: #2a2f36;
--accent: #3b73b8;
--hex-fill: #0d2034;
--doc-fill: #1b1f24;
--doc-stroke: #2a2f36;
--doc-inner: #13161a;
}
[data-theme="light"] {
--bg: #ffffff;
--bg-secondary: #f8fafc;
--text: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--accent: #2563eb;
--hex-fill: #e8f0fe;
--doc-fill: #f1f5f9;
--doc-stroke: #cbd5e1;
--doc-inner: #e2e8f0;
}
body {
background: var(--bg);
color: var(--text);
}

View file

@ -1,303 +0,0 @@
#graph {
width: 100%;
height: 600px;
border-radius: 12px;
overflow: hidden;
background-image: radial-gradient(
circle,
var(--text-muted) 0.5px,
transparent 0.5px
);
background-size: 16px 16px;
}
/* Loading */
#loading {
display: flex;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
align-items: center;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
z-index: 10;
}
#loading .spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Stats badge */
#stats {
position: fixed;
top: 12px;
left: 12px;
font-size: 12px;
color: var(--text-muted);
z-index: 10;
padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
/* Popup */
#popup {
display: none;
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 220px;
max-width: 360px;
max-height: 300px;
overflow-y: auto;
}
#popup-type {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 6px;
margin-bottom: 8px;
}
#popup-type.document {
background: rgba(59, 115, 184, 0.15);
color: #3b73b8;
}
#popup-type.memory {
background: rgba(59, 115, 184, 0.15);
color: #3b73b8;
}
#popup-type.forgotten {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
#popup-type.latest {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
#popup-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 6px;
color: var(--text);
word-wrap: break-word;
line-height: 1.4;
}
#popup-content {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
word-wrap: break-word;
line-height: 1.5;
}
#popup-meta {
font-size: 11px;
color: var(--text-muted);
opacity: 0.7;
}
/* Controls - vertical stack, bottom left, above legend */
#controls {
position: fixed;
bottom: 72px;
left: 16px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 15;
}
#controls > button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 9999px;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: 8px 12px;
white-space: nowrap;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
transition: opacity 0.15s;
min-width: 0;
}
#controls > button:hover {
opacity: 0.85;
}
#controls > button kbd {
font-family: inherit;
font-size: 10px;
font-weight: 500;
background: var(--border);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
}
#zoom-row {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 9999px;
padding: 6px 10px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
#zoom-row button {
width: 20px;
height: 20px;
padding: 0;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
transition: opacity 0.15s;
}
#zoom-row button:hover {
opacity: 0.85;
}
#zoom-display {
font-size: 12px;
color: var(--text-muted);
min-width: 36px;
text-align: center;
user-select: none;
padding: 0 4px;
}
/* Legend - bottom left, single expandable card, 214px wide */
#legend {
position: fixed;
bottom: 16px;
left: 16px;
width: 214px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
z-index: 20;
font-size: 12px;
color: var(--text-muted);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
#legend-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
user-select: none;
font-weight: 600;
font-size: 12px;
color: var(--text);
}
.legend-chevron {
transition: transform 0.2s;
transform: rotate(90deg);
}
#legend.collapsed .legend-chevron {
transform: rotate(0deg);
}
#legend-body {
padding: 0 12px 12px;
overflow: hidden;
max-height: 400px;
transition:
max-height 0.25s ease,
padding 0.25s ease,
opacity 0.2s;
opacity: 1;
}
#legend.collapsed #legend-body {
max-height: 0;
padding: 0 12px;
opacity: 0;
}
.legend-group {
padding: 4px 0;
}
.legend-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.legend-count {
margin-left: auto;
font-size: 11px;
opacity: 0.7;
}
.legend-line {
width: 18px;
height: 0;
border-top: 1.5px solid;
flex-shrink: 0;
}
.legend-line.dashed {
border-top-style: dashed;
}

View file

@ -1,934 +0,0 @@
/**
* Memory Graph MCP App - Interactive force-directed graph visualization
*/
import {
App,
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import ForceGraph, { type LinkObject, type NodeObject } from "force-graph"
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceRadial,
} from "d3-force-3d"
import "./global.css"
import "./mcp-app.css"
// =============================================================================
// Types
// =============================================================================
interface GraphApiMemory {
id: string
memory: string
isStatic: boolean
spaceId: string
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
rootMemoryId: string | null
createdAt: string
updatedAt: string
relation?: "updates" | "extends" | "derives" | null
memoryRelations?: Record<string, "updates" | "extends" | "derives"> | null
}
interface GraphApiDocument {
id: string
title: string | null
summary: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: GraphApiMemory[]
}
interface ToolResultData {
containerTag?: string
documents: GraphApiDocument[]
totalCount: number
}
interface MemoryNode extends NodeObject {
id: string
nodeType: "memory"
memory: string
documentId: string
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
version: number
parentMemoryId: string | null
createdAt: string
borderColor: string
}
interface DocumentNode extends NodeObject {
id: string
nodeType: "document"
title: string
summary: string | null
docType: string
createdAt: string
memoryCount: number
}
type GraphNode = MemoryNode | DocumentNode
interface GraphLink extends LinkObject {
source: string | GraphNode
target: string | GraphNode
edgeType: "derives" | "updates" | "extends"
}
// =============================================================================
// Constants
// =============================================================================
const MEMORY_BORDER = {
forgotten: "#EF4444",
expiring: "#F59E0B",
recent: "#10B981",
default: "#3B73B8",
}
const EDGE_COLORS = {
dark: { derives: "#FBBF24", updates: "#A78BFA", extends: "#38BDF8" },
light: { derives: "#FBBF24", updates: "#A78BFA", extends: "#38BDF8" },
}
const EDGE_OPACITY: Record<string, number> = {
derives: 0.4,
updates: 0.7,
extends: 0.55,
}
const EDGE_WIDTH: Record<string, number> = {
derives: 1.2,
updates: 2,
extends: 1.5,
}
// Node sizes
const MEM_RADIUS = 12
const DOC_SIZE = 28
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const CLUSTER_SPREAD = 120
// =============================================================================
// State
// =============================================================================
let isDark = true
let selectedNode: GraphNode | null = null
let hoveredNode: GraphNode | null = null
// =============================================================================
// DOM References (elements are guaranteed to exist in mcp-app.html)
// =============================================================================
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const container = document.getElementById("graph")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popup = document.getElementById("popup")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupType = document.getElementById("popup-type")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupTitle = document.getElementById("popup-title")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupContent = document.getElementById("popup-content")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupMeta = document.getElementById("popup-meta")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const loadingEl = document.getElementById("loading")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const statsEl = document.getElementById("stats")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomInBtn = document.getElementById("zoom-in")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomOutBtn = document.getElementById("zoom-out")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const fitBtn = document.getElementById("fit-btn")!
// =============================================================================
// Helpers
// =============================================================================
function getMemoryBorderColor(mem: GraphApiMemory): string {
if (mem.isForgotten) return MEMORY_BORDER.forgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return MEMORY_BORDER.recent
return MEMORY_BORDER.default
}
/** Simple hash to get deterministic initial positions from doc ID */
function hashCode(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
}
return h
}
function initialPosition(id: string, spread: number): { x: number; y: number } {
const h = hashCode(id)
const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2
const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }
}
function transformData(data: ToolResultData): {
nodes: GraphNode[]
links: GraphLink[]
} {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
const SPREAD = 50
// Pre-populate all node IDs so edge targets are always resolvable
// regardless of iteration order.
const nodeIds = new Set<string>()
for (const doc of data.documents) {
nodeIds.add(doc.id)
for (const mem of doc.memoryEntries) nodeIds.add(mem.id)
}
for (const doc of data.documents) {
const pos = initialPosition(doc.id, SPREAD)
nodes.push({
id: doc.id,
nodeType: "document",
title: doc.title || "Untitled",
summary: doc.summary,
docType: doc.type,
createdAt: doc.createdAt,
memoryCount: doc.memoryEntries.length,
x: pos.x,
y: pos.y,
} as DocumentNode)
const memCount = doc.memoryEntries.length
for (let i = 0; i < memCount; i++) {
// biome-ignore lint/style/noNonNullAssertion: index is always valid within loop bounds
const mem = doc.memoryEntries[i]!
const angle = (i / memCount) * 2 * Math.PI
nodes.push({
id: mem.id,
nodeType: "memory",
memory: mem.memory,
documentId: doc.id,
isLatest: mem.isLatest,
isForgotten: mem.isForgotten,
forgetAfter: mem.forgetAfter,
version: mem.version,
parentMemoryId: mem.parentMemoryId,
createdAt: mem.createdAt,
borderColor: getMemoryBorderColor(mem),
x: pos.x + Math.cos(angle) * CLUSTER_SPREAD,
y: pos.y + Math.sin(angle) * CLUSTER_SPREAD,
} as MemoryNode)
// Derives link (doc -> memory)
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
// Memory-to-memory relation edges from backend data.
// Uses memoryRelations as primary source, falls back to parentMemoryId.
// Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts
let relations: Record<string, string> = {}
if (
// Defensive: data comes from structuredContent cast, may be unexpected type
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
relations = { [mem.parentMemoryId]: "updates" }
}
for (const [targetId, relationType] of Object.entries(relations)) {
if (!nodeIds.has(targetId)) continue
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
links.push({ source: targetId, target: mem.id, edgeType })
}
}
}
return { nodes, links }
}
// =============================================================================
// Drawing
// =============================================================================
function hexPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
) {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
ctx.lineTo(x + radius * Math.cos(angle), y + radius * Math.sin(angle))
}
ctx.closePath()
}
function lightenColor(hex: string, amount: number): string {
const h = hex.replace("#", "")
if (h.length !== 6) return hex
const r = Math.min(
255,
Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount),
)
const g = Math.min(
255,
Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount),
)
const b = Math.min(
255,
Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount),
)
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
screenSize: number,
borderColor: string,
isHovered: boolean,
isSelected: boolean,
isForgotten: boolean,
isLatest: boolean,
) {
const accent = isDark ? "#3B73B8" : "#2563eb"
const memFill = isDark ? "#0D2034" : "#E8F0FE"
// Dot mode at very small screen sizes
if (screenSize < 8) {
const r = Math.max(2, screenSize * 0.45)
// Glow halo
ctx.save()
ctx.globalAlpha = 0.25
ctx.beginPath()
ctx.arc(x, y, r * 2.5, 0, Math.PI * 2)
ctx.fillStyle = borderColor
ctx.fill()
ctx.restore()
// Dot
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fillStyle = memFill
ctx.fill()
ctx.strokeStyle = borderColor
ctx.lineWidth = 1.5
ctx.stroke()
return
}
// Superseded (non-latest) memory: dimmed with dashed border
if (!isLatest && !isSelected && !isHovered) {
ctx.save()
ctx.globalAlpha = 0.5
hexPath(ctx, x, y, radius)
ctx.fillStyle = memFill
ctx.fill()
ctx.strokeStyle = borderColor
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.stroke()
ctx.setLineDash([])
// Strikethrough
const sr = radius * 0.55
ctx.beginPath()
ctx.moveTo(x - sr, y - sr)
ctx.lineTo(x + sr, y + sr)
ctx.strokeStyle = isDark ? "#94a3b8" : "#64748b"
ctx.lineWidth = 1.5
ctx.stroke()
ctx.restore()
return
}
// Shadow for hover/selected
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = isSelected ? accent : isDark ? "#3B73B8" : "#2563eb"
ctx.shadowBlur = isSelected ? 18 : 12
hexPath(ctx, x, y, radius)
ctx.fillStyle = memFill
ctx.fill()
ctx.restore()
// Dashed glow ring
ctx.save()
const scale = isSelected ? 1.15 : 1.1
hexPath(ctx, x, y, radius * scale)
ctx.strokeStyle = accent
ctx.lineWidth = isSelected ? 2 : 1.5
ctx.globalAlpha = isSelected ? 0.8 : 0.5
ctx.setLineDash(isSelected ? [3, 3] : [4, 4])
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
// Main hexagon
hexPath(ctx, x, y, radius)
ctx.fillStyle = isHovered ? (isDark ? "#112840" : "#dbeafe") : memFill
ctx.fill()
ctx.strokeStyle = isSelected ? accent : borderColor
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
// Forgotten X icon
if (isForgotten && radius > 7) {
const iconR = radius * 0.3
ctx.save()
ctx.lineCap = "round"
ctx.beginPath()
ctx.moveTo(x - iconR, y - iconR)
ctx.lineTo(x + iconR, y + iconR)
ctx.moveTo(x + iconR, y - iconR)
ctx.lineTo(x - iconR, y + iconR)
ctx.strokeStyle = MEMORY_BORDER.forgotten
ctx.lineWidth = Math.max(1.5, radius / 10)
ctx.stroke()
ctx.restore()
}
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
screenSize: number,
isHovered: boolean,
isSelected: boolean,
) {
const accent = isDark ? "#3B73B8" : "#2563eb"
const docFill = isDark ? "#1B1F24" : "#F1F5F9"
const docStroke = isDark ? "#2A2F36" : "#CBD5E1"
const half = size / 2
const cornerR = Math.round(8 * (size / 50))
// Dot mode at very small screen sizes
if (screenSize < 8) {
const s = Math.max(3, screenSize)
ctx.fillStyle = docFill
ctx.fillRect(x - s / 2, y - s / 2, s, s)
return
}
// Shadow for hover/selected
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = accent
ctx.shadowBlur = isSelected ? 16 : 10
ctx.beginPath()
ctx.roundRect(x - half, y - half, size, size, cornerR)
ctx.fillStyle = docFill
ctx.fill()
ctx.restore()
// Dashed glow ring
ctx.save()
const scale = isSelected ? 1.15 : 1.1
const gh = (size * scale) / 2
ctx.beginPath()
ctx.roundRect(
x - gh,
y - gh,
size * scale,
size * scale,
Math.round(8 * ((size * scale) / 50)),
)
ctx.strokeStyle = accent
ctx.lineWidth = isSelected ? 2 : 1.5
ctx.globalAlpha = isSelected ? 0.8 : 0.5
ctx.setLineDash(isSelected ? [3, 3] : [4, 4])
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
// Outer rect with gradient
ctx.beginPath()
ctx.roundRect(x - half, y - half, size, size, cornerR)
const gradient = ctx.createLinearGradient(
x - half,
y - half,
x + half,
y + half,
)
gradient.addColorStop(0, docFill)
gradient.addColorStop(1, lightenColor(docFill, 0.08))
ctx.fillStyle = gradient
ctx.fill()
ctx.strokeStyle = isSelected || isHovered ? accent : docStroke
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 1.5 : 1
ctx.stroke()
// Inner area
const innerSize = size * 0.72
const innerHalf = innerSize / 2
const innerCornerR = Math.round(6 * (size / 50))
ctx.beginPath()
ctx.roundRect(
x - innerHalf,
y - innerHalf,
innerSize,
innerSize,
innerCornerR,
)
ctx.fillStyle = isDark ? "#13161A" : "#E2E8F0"
ctx.fill()
// Document icon (page with fold)
const iconS = size * 0.35
const iconColor = isDark ? "#3B73B8" : "#2563eb"
const w = iconS * 0.7
const h = iconS * 0.85
const fold = iconS * 0.2
const ix = x - w / 2
const iy = y - h / 2
ctx.save()
ctx.strokeStyle = iconColor
ctx.lineWidth = Math.max(1, iconS / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.beginPath()
ctx.moveTo(ix, iy)
ctx.lineTo(ix + w - fold, iy)
ctx.lineTo(ix + w, iy + fold)
ctx.lineTo(ix + w, iy + h)
ctx.lineTo(ix, iy + h)
ctx.closePath()
ctx.stroke()
ctx.beginPath()
ctx.moveTo(ix + w - fold, iy)
ctx.lineTo(ix + w - fold, iy + fold)
ctx.lineTo(ix + w, iy + fold)
ctx.stroke()
ctx.restore()
}
// =============================================================================
// Force Graph Setup
// =============================================================================
const graph = new ForceGraph<GraphNode, GraphLink>(container)
.nodeId("id")
.nodeCanvasObject(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
const x = node.x!
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
const y = node.y!
const isHovered = hoveredNode?.id === node.id
const isSelected = selectedNode?.id === node.id
// Dim non-connected nodes when something is selected
if (selectedNode && !isSelected && !isHovered) {
ctx.globalAlpha = 0.3
}
if (node.nodeType === "memory") {
const mem = node as MemoryNode
const screenSize = MEM_RADIUS * 2 * globalScale
drawMemoryNode(
ctx,
x,
y,
MEM_RADIUS,
screenSize,
mem.borderColor,
isHovered,
isSelected,
mem.isForgotten,
mem.isLatest,
)
} else {
const screenSize = DOC_SIZE * globalScale
drawDocumentNode(ctx, x, y, DOC_SIZE, screenSize, isHovered, isSelected)
}
ctx.globalAlpha = 1
},
)
.nodeCanvasObjectMode(() => "replace")
.nodePointerAreaPaint(
(node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
node.x!,
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
node.y!,
node.nodeType === "document" ? DOC_SIZE / 2 + 1 : MEM_RADIUS + 1,
0,
Math.PI * 2,
)
ctx.fill()
},
)
.linkCanvasObject(
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
const source = link.source as GraphNode
const target = link.target as GraphNode
if (!source.x || !source.y || !target.x || !target.y) return
const { edgeType } = link
const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
const color = palette[edgeType] || palette.derives
const width = EDGE_WIDTH[edgeType] || 1.2
const opacity = EDGE_OPACITY[edgeType] || 0.4
const isDimmed = !!selectedNode
// Culling: extends edges at very low zoom
if (edgeType === "extends" && globalScale < 0.08) return
const dimFactor = isDimmed ? 0.3 : 1
const isExtends = edgeType === "extends"
// Glow pass (behind main edge)
if (!isDimmed) {
ctx.save()
ctx.globalAlpha = edgeType === "updates" ? opacity * 0.4 : opacity * 0.3
ctx.strokeStyle = color
ctx.lineWidth = edgeType === "updates" ? width + 2 : width + 1.5
if (isExtends) ctx.setLineDash([6, 4])
ctx.beginPath()
ctx.moveTo(source.x, source.y)
ctx.lineTo(target.x, target.y)
ctx.stroke()
if (isExtends) ctx.setLineDash([])
ctx.restore()
}
// Main edge
ctx.save()
ctx.globalAlpha = opacity * dimFactor
ctx.strokeStyle = color
ctx.lineWidth = width
if (isExtends) ctx.setLineDash([6, 4])
ctx.beginPath()
ctx.moveTo(source.x, source.y)
ctx.lineTo(target.x, target.y)
ctx.stroke()
if (isExtends) ctx.setLineDash([])
ctx.restore()
// Arrowhead for updates edges
if (edgeType === "updates") {
const arrowSize = Math.max(6, 8 * globalScale)
const angle = Math.atan2(target.y - source.y, target.x - source.x)
ctx.save()
ctx.globalAlpha = opacity * 0.6 * dimFactor
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(target.x, target.y)
ctx.lineTo(
target.x - arrowSize * Math.cos(angle - Math.PI / 6),
target.y - arrowSize * Math.sin(angle - Math.PI / 6),
)
ctx.lineTo(
target.x - arrowSize * Math.cos(angle + Math.PI / 6),
target.y - arrowSize * Math.sin(angle + Math.PI / 6),
)
ctx.closePath()
ctx.fill()
ctx.restore()
}
},
)
.linkCanvasObjectMode(() => "replace")
.onNodeHover((node: GraphNode | null) => {
hoveredNode = node
container.style.cursor = node ? "pointer" : "default"
})
.onNodeClick(handleNodeClick)
.onBackgroundClick(() => hidePopup())
.d3Force(
"charge",
forceManyBody().strength((node: GraphNode) =>
node.nodeType === "document" ? -15 : -200,
),
)
.d3Force(
"link",
forceLink()
.distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80))
.strength((l: GraphLink) => {
if (l.edgeType === "derives") return 0.8
if (l.edgeType === "updates") return 1.0
return 0.15 // extends
}),
)
.d3Force("collide", forceCollide(18))
.d3Force("center", forceCenter())
.d3Force("bound", forceRadial(60).strength(0.3))
.d3VelocityDecay(0.4)
.warmupTicks(50)
.cooldownTime(3000)
// =============================================================================
// Resize
// =============================================================================
function handleResize() {
const { width, height } = container.getBoundingClientRect()
graph.width(width).height(height)
}
window.addEventListener("resize", handleResize)
handleResize()
// =============================================================================
// Popup
// =============================================================================
function handleNodeClick(node: GraphNode, event: MouseEvent) {
if (selectedNode?.id === node.id) {
hidePopup()
return
}
selectedNode = node
showPopup(node, event.clientX, event.clientY)
}
function showPopup(node: GraphNode, x: number, y: number) {
if (node.nodeType === "document") {
const doc = node as DocumentNode
popupType.textContent = "Document"
popupType.className = "document"
popupTitle.textContent = doc.title
popupContent.textContent = doc.summary || "No summary available"
popupMeta.textContent = `${doc.memoryCount} memories \u00b7 ${doc.docType} \u00b7 ${new Date(doc.createdAt).toLocaleDateString()}`
} else {
const mem = node as MemoryNode
const typeLabel = mem.isForgotten
? "Forgotten"
: mem.isLatest
? "Latest"
: `v${mem.version}`
popupType.textContent = typeLabel
popupType.className = `memory${mem.isForgotten ? " forgotten" : mem.isLatest ? " latest" : ""}`
popupTitle.textContent =
mem.memory.length > 120 ? `${mem.memory.slice(0, 120)}...` : mem.memory
popupContent.textContent = mem.memory.length > 120 ? mem.memory : ""
const statusParts: string[] = [`Version ${mem.version}`]
if (mem.isForgotten) statusParts.push("Forgotten")
else if (mem.forgetAfter)
statusParts.push(
`Expires ${new Date(mem.forgetAfter).toLocaleDateString()}`,
)
statusParts.push(new Date(mem.createdAt).toLocaleDateString())
popupMeta.textContent = statusParts.join(" \u00b7 ")
}
popup.style.display = "block"
// Smart quadrant positioning (right > left > below > above)
const rect = popup.getBoundingClientRect()
const gap = 24
const vw = window.innerWidth
const vh = window.innerHeight
let left: number
let top: number
// Try right
if (x + gap + rect.width < vw - 8) {
left = x + gap
} else if (x - gap - rect.width > 8) {
// Try left
left = x - gap - rect.width
} else {
// Fallback center
left = Math.max(8, (vw - rect.width) / 2)
}
if (y - rect.height / 2 > 8 && y + rect.height / 2 < vh - 8) {
top = y - rect.height / 2
} else if (y + gap + rect.height < vh - 8) {
top = y + gap
} else {
top = y - gap - rect.height
}
popup.style.left = `${Math.max(8, left)}px`
popup.style.top = `${Math.max(8, top)}px`
}
function hidePopup() {
popup.style.display = "none"
selectedNode = null
}
// =============================================================================
// Controls
// =============================================================================
const ZOOM_FACTOR = 1.3
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const centerBtn = document.getElementById("center-btn")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomDisplay = document.getElementById("zoom-display")!
zoomInBtn.addEventListener("click", () =>
graph.zoom(graph.zoom() * ZOOM_FACTOR, 200),
)
zoomOutBtn.addEventListener("click", () =>
graph.zoom(graph.zoom() / ZOOM_FACTOR, 200),
)
fitBtn.addEventListener("click", () => graph.zoomToFit(400, 40))
centerBtn.addEventListener("click", () => graph.centerAt(0, 0, 400))
// Update zoom display
graph.onZoom(({ k }) => {
zoomDisplay.textContent = `${Math.round(k * 100)}%`
})
document.addEventListener("keydown", (e) => {
const tag = (e.target as HTMLElement).tagName
if (tag === "INPUT" || tag === "TEXTAREA") return
switch (e.key) {
case "Escape":
hidePopup()
break
case "z":
case "Z":
graph.zoomToFit(400, 40)
break
case "c":
case "C":
graph.centerAt(0, 0, 400)
break
case "+":
case "=":
graph.zoom(graph.zoom() * ZOOM_FACTOR, 200)
break
case "-":
case "_":
graph.zoom(graph.zoom() / ZOOM_FACTOR, 200)
break
}
})
// Legend toggle
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const legendEl = document.getElementById("legend")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const legendToggle = document.getElementById("legend-toggle")!
legendToggle.addEventListener("click", () =>
legendEl.classList.toggle("collapsed"),
)
// =============================================================================
// Theme
// =============================================================================
function applyTheme(theme: "light" | "dark") {
isDark = theme === "dark"
document.documentElement.setAttribute("data-theme", theme)
graph.backgroundColor(isDark ? "#0f1419" : "#ffffff")
}
// Detect system theme
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)")
applyTheme(prefersDark.matches ? "dark" : "light")
prefersDark.addEventListener("change", (e) =>
applyTheme(e.matches ? "dark" : "light"),
)
// =============================================================================
// MCP App SDK
// =============================================================================
const app = new App({ name: "Memory Graph", version: "1.0.0" })
app.ontoolinput = () => {
loadingEl.style.display = "flex"
statsEl.textContent = "Loading graph data..."
}
app.ontoolresult = (result: CallToolResult) => {
loadingEl.style.display = "none"
if (result.isError) {
statsEl.textContent = "Error loading graph"
return
}
const data = result.structuredContent as unknown as ToolResultData
if (!data?.documents) {
statsEl.textContent = "No graph data available"
return
}
const { nodes, links } = transformData(data)
const memCount = nodes.filter((n) => n.nodeType === "memory").length
const docCount = nodes.filter((n) => n.nodeType === "document").length
statsEl.textContent = `${docCount} docs \u00b7 ${memCount} memories \u00b7 ${links.length} connections`
// Update legend counts
const docCountEl = document.getElementById("legend-doc-count")
const memCountEl = document.getElementById("legend-mem-count")
if (docCountEl) docCountEl.textContent = String(docCount)
if (memCountEl) memCountEl.textContent = String(memCount)
graph.graphData({ nodes, links })
// Fit to view after layout stabilizes
setTimeout(() => graph.zoomToFit(400, 40), 600)
}
app.ontoolcancelled = () => {
loadingEl.style.display = "none"
statsEl.textContent = "Cancelled"
}
function handleHostContext(ctx: McpUiHostContext) {
if (ctx.theme) {
applyDocumentTheme(ctx.theme)
applyTheme(ctx.theme)
}
if (ctx.styles?.variables) {
applyHostStyleVariables(ctx.styles.variables)
}
if (ctx.styles?.css?.fonts) {
applyHostFonts(ctx.styles.css.fonts)
}
if (ctx.safeAreaInsets) {
const { top, right, bottom, left } = ctx.safeAreaInsets
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
}
}
app.onhostcontextchanged = handleHostContext
app.onteardown = async () => ({})
app.onerror = console.error
// Connect to host
app.connect().then(() => {
const ctx = app.getHostContext()
if (ctx) handleHostContext(ctx)
})

159
apps/mcp/src/widget/App.tsx Normal file
View file

@ -0,0 +1,159 @@
import { type ReactNode, useEffect } from "react"
import type { ViewMessage } from "../shared/types"
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
import { useLog } from "./hooks/useLog"
import { useViewState } from "./hooks/useViewState"
import { Confirmation } from "./views/Confirmation"
import { ErrorView } from "./views/Error"
import { Graph } from "./views/Graph"
import { Loading } from "./views/Loading"
import { Picker } from "./views/Picker"
import { Save } from "./views/Save"
import { Success } from "./views/Success"
import { Upload } from "./views/Upload"
export function App() {
useApplyHostTheme()
const log = useLog()
const { state, setView, setError } = useViewState()
useEffect(() => {
if (state.kind === "view") {
log("info", `[app] view → ${state.message.view}`)
} else if (state.kind === "error") {
log("error", `[app] error: ${state.message}`)
}
}, [state, log])
if (state.kind === "loading") {
return (
<WidgetShell>
<Loading />
</WidgetShell>
)
}
if (state.kind === "error") {
return (
<WidgetShell>
<ErrorView message={state.message} />
</WidgetShell>
)
}
if (state.kind === "raw") {
return (
<WidgetShell>
<ErrorView message="Received unrecognized response from server. Try again." />
</WidgetShell>
)
}
const isGraphView = state.message.view === "graph"
return (
<WidgetShell immersive={isGraphView}>
{renderView(state.message, setView, setError)}
</WidgetShell>
)
}
export function WidgetShell({
children,
immersive = false,
}: {
children: ReactNode
immersive?: boolean
}) {
const shellClassName = immersive
? "mcp-widget-shell mcp-widget-shell-graph"
: "mcp-widget-shell"
return (
<div className={shellClassName}>
<header className="mcp-widget-brand">
<span aria-hidden className="mcp-widget-brand-mark">
<svg aria-hidden="true" viewBox="0 0 314 256">
<path
d="M313.728 100.982H197.297V0H159.68V109.567C159.68 121.205 164.284 132.381 172.466 140.615L267.535 236.283L294.134 209.517L223.917 138.858H313.75V101.004L313.728 100.982Z"
fill="currentColor"
/>
<path
d="M19.616 46.5043L89.8323 117.163H0V155.017H116.431V255.999H154.048V146.432C154.048 134.795 149.444 123.618 141.262 115.384L46.2144 19.7383L19.616 46.5043Z"
fill="currentColor"
/>
</svg>
</span>
<span className="mcp-widget-brand-copy">
<span className="mcp-widget-brand-name">supermemory</span>
<span className="mcp-widget-brand-mode">MCP</span>
</span>
</header>
<main className="mcp-widget-content">{children}</main>
</div>
)
}
function renderView(
msg: ViewMessage,
setView: (m: ViewMessage) => void,
setError: (m: string) => void,
) {
switch (msg.view) {
case "picker":
return (
<Picker
activeTag={msg.activeTag}
assignedTags={msg.assignedTags}
containerTags={msg.containerTags}
onAdvance={setView}
onError={setError}
viewId={msg.viewId}
/>
)
case "save":
return (
<Save
activeTag={msg.activeTag}
onAdvance={setView}
onError={setError}
prefill={msg.prefill}
viewId={msg.viewId}
writableTags={msg.writableTags}
/>
)
case "upload":
return (
<Upload
activeTag={msg.activeTag}
onAdvance={setView}
onError={setError}
viewId={msg.viewId}
writableTags={msg.writableTags}
/>
)
case "graph":
return (
<Graph
containerTag={msg.containerTag}
documents={msg.documents}
totalCount={msg.totalCount}
/>
)
case "confirmation":
return <Confirmation containerTag={msg.containerTag} />
case "save-success":
return <Success containerTag={msg.containerTag} kind="save" />
case "upload-success":
return (
<Success
containerTag={msg.containerTag}
fileName={msg.fileName}
kind="upload"
/>
)
default: {
const exhaustive: never = msg
return (
<ErrorView message={`Unhandled view: ${JSON.stringify(exhaustive)}`} />
)
}
}
}

View file

@ -0,0 +1,72 @@
import {
Component,
type ContextType,
type ErrorInfo,
type ReactNode,
} from "react"
import { Button, Stack } from "./design/ui"
import { McpAppContext } from "./McpAppProvider"
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static contextType = McpAppContext
declare context: ContextType<typeof McpAppContext>
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
try {
const report = this.context?.app?.sendLog({
level: "error",
logger: "ErrorBoundary",
data: `${error.name}: ${error.message}\n${info.componentStack ?? ""}`,
})
if (report) {
void report.catch(() => {
console.error("[ErrorBoundary]", error, info)
})
} else {
console.error("[ErrorBoundary]", error, info)
}
} catch {
console.error("[ErrorBoundary]", error, info)
}
}
private handleReload = () => this.setState({ error: null })
render() {
if (!this.state.error) return this.props.children
return (
<Stack
align="center"
className="px-6 py-10 max-w-md mx-auto text-center"
gap="md"
>
<div className="text-3xl text-error"></div>
<Stack gap="xs">
<div className="text-sm font-medium text-text-primary">
Something went wrong
</div>
<div className="text-xs text-text-muted break-words font-mono">
{this.state.error.message}
</div>
</Stack>
<Button onClick={this.handleReload} size="sm" variant="secondary">
Try again
</Button>
</Stack>
)
}
}

View file

@ -0,0 +1,157 @@
import type {
App as McpApp,
McpUiHostContext,
} from "@modelcontextprotocol/ext-apps"
import { useApp as useMcpApp } from "@modelcontextprotocol/ext-apps/react"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import {
createContext,
type ReactNode,
useCallback,
useMemo,
useState,
} from "react"
import type { ViewMessage } from "../shared/types"
import { loadViewCheckpoint, saveViewCheckpoint } from "./lib/viewCheckpoint"
export type ViewState =
| { kind: "loading" }
| { kind: "view"; message: ViewMessage }
| { kind: "error"; message: string }
| { kind: "raw"; structuredContent: unknown }
export interface McpAppContextValue {
app: McpApp | null
hostContext: McpUiHostContext | null
isConnected: boolean
state: ViewState
setView: (message: ViewMessage) => void
setError: (message: string) => void
}
export const McpAppContext = createContext<McpAppContextValue | null>(null)
function safeLog(
app: McpApp,
level: "debug" | "info" | "warning" | "error",
message: string,
) {
try {
void app.sendLog({ level, data: message }).catch(() => {
// Host logging is optional.
})
} catch {
// The transport may not be ready yet.
}
}
function initialViewState(): ViewState {
const checkpoint = loadViewCheckpoint()
return checkpoint
? { kind: "view", message: checkpoint }
: { kind: "loading" }
}
export function McpAppProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ViewState>(initialViewState)
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null)
const { app, isConnected, error } = useMcpApp({
appInfo: { name: "Supermemory MCP", version: "1.0.0" },
capabilities: {},
strict: true,
onAppCreated: (createdApp) => {
createdApp.ontoolinput = (input: unknown) => {
const name =
typeof input === "object" && input !== null && "name" in input
? String((input as { name: unknown }).name)
: "?"
safeLog(createdApp, "info", `[host] ontoolinput: ${name}`)
setState({ kind: "loading" })
}
createdApp.ontoolinputpartial = () => setState({ kind: "loading" })
createdApp.ontoolcancelled = () => {
safeLog(createdApp, "info", "[host] ontoolcancelled")
setState({ kind: "loading" })
}
createdApp.ontoolresult = (result: CallToolResult) => {
const structuredContent = (result as { structuredContent?: unknown })
.structuredContent
if (!structuredContent || typeof structuredContent !== "object") {
safeLog(
createdApp,
"warning",
"[host] ontoolresult: no structuredContent",
)
setState({ kind: "raw", structuredContent })
return
}
if ("view" in structuredContent) {
const message = structuredContent as ViewMessage
safeLog(
createdApp,
"info",
`[host] ontoolresult: view=${message.view}`,
)
const checkpoint = loadViewCheckpoint(message.viewId)
setState({ kind: "view", message: checkpoint ?? message })
return
}
safeLog(
createdApp,
"warning",
"[host] ontoolresult: structuredContent without view",
)
setState({ kind: "raw", structuredContent })
}
createdApp.onhostcontextchanged = (next) => {
setHostContext(createdApp.getHostContext() ?? next)
}
createdApp.onerror = (nextError: unknown) => {
safeLog(createdApp, "error", `[host] onerror: ${String(nextError)}`)
setState({ kind: "error", message: String(nextError) })
}
},
})
const setView = useCallback((message: ViewMessage) => {
saveViewCheckpoint(message)
setState({ kind: "view", message })
}, [])
const setError = useCallback((message: string) => {
setState({ kind: "error", message })
}, [])
const value = useMemo<McpAppContextValue>(
() => ({
app,
hostContext: hostContext ?? app?.getHostContext() ?? null,
isConnected,
state: error ? { kind: "error", message: error.message } : state,
setView,
setError,
}),
[app, error, hostContext, isConnected, setError, setView, state],
)
return (
<McpAppContext.Provider value={value}>{children}</McpAppContext.Provider>
)
}
const previewValue: McpAppContextValue = {
app: null,
hostContext: null,
isConnected: false,
state: { kind: "loading" },
setView: () => {},
setError: () => {},
}
export function McpAppPreviewProvider({ children }: { children: ReactNode }) {
return (
<McpAppContext.Provider value={previewValue}>
{children}
</McpAppContext.Provider>
)
}

View file

@ -0,0 +1,12 @@
interface Props {
permission: string
}
export function PermissionBadge({ permission }: Props) {
const isWrite = permission === "write"
return (
<span className="inline-flex items-center rounded-full bg-bg-muted px-2 py-0.5 text-[10px] font-medium text-text-muted">
{isWrite ? "Read / write" : "Read only"}
</span>
)
}

View file

@ -0,0 +1,49 @@
import type { ContainerTag, ContainerTagAccess } from "../../shared/types"
import { cn } from "../design/lib/cn"
import { formatTagLabel } from "../lib/formatTag"
import { PermissionBadge } from "./PermissionBadge"
interface Props {
containerTag: ContainerTag
active: boolean
access?: ContainerTagAccess
onClick: (containerTag: string) => void
}
export function SpaceCard({ containerTag, active, access, onClick }: Props) {
const name = containerTag.name || formatTagLabel(containerTag.containerTag)
const docs = containerTag.documentCount
const mems = containerTag.memoryCount
const meta =
docs > 0 || mems > 0
? `${docs} doc${docs === 1 ? "" : "s"} · ${mems} ${mems === 1 ? "memory" : "memories"}`
: "No memories yet"
return (
<button
className="space-card"
data-active={active}
onClick={() => onClick(containerTag.containerTag)}
type="button"
>
<span className="space-card-inner">
<span className="space-card-main">
<span
className={cn(
"space-card-title truncate text-sm font-medium",
active ? "text-accent" : "text-text-primary",
)}
>
{name}
</span>
<span className="space-card-meta text-[11px] leading-normal text-text-muted">
{meta}
</span>
</span>
<span className="space-card-trailing">
{access ? <PermissionBadge permission={access.permission} /> : null}
</span>
</span>
</button>
)
}

View file

@ -0,0 +1,11 @@
import { formatTagLabel } from "../lib/formatTag"
// A calm space pill: a small accent dot + the space name on a neutral surface.
export function SpaceChip({ containerTag }: { containerTag: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-bg-muted px-2.5 py-1 text-(length:--text-xs) font-medium text-text-primary">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-accent" />
{formatTagLabel(containerTag)}
</span>
)
}

View file

@ -0,0 +1,5 @@
import { Loader2 } from "../lib/icons"
export function Spinner() {
return <Loader2 className="size-5 animate-spin text-accent" />
}

View file

@ -0,0 +1,361 @@
@import "tailwindcss";
@import "./tokens.css";
@custom-variant dark (&:is([data-theme="dark"] *));
/* Map design tokens to Tailwind utilities so class names like `bg-bg-primary`,
* `text-text-primary`, `border-border-accent`, `text-xs`, `shadow-md` resolve
* from one source of truth. */
@theme inline {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-muted: var(--bg-muted);
--color-bg-elevated: var(--bg-elevated);
--color-bg-overlay: var(--bg-overlay);
--color-bg-panel: var(--bg-panel);
--color-bg-control: var(--bg-control);
--color-bg-control-hover: var(--bg-control-hover);
--color-border: var(--border);
--color-border-muted: var(--border-muted);
--color-border-control: var(--border-control);
--color-border-accent: var(--border-accent);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-muted: var(--text-muted);
--color-text-inverse: var(--text-inverse);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-accent-hover: var(--accent-hover);
--color-accent-muted: var(--accent-muted);
--color-success: var(--success);
--color-success-muted: var(--success-muted);
--color-error: var(--error);
--color-error-muted: var(--error-muted);
--color-warning: var(--warning);
--color-warning-muted: var(--warning-muted);
--color-info: var(--info);
--color-info-muted: var(--info-muted);
--color-danger: var(--danger);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-display: var(--font-display);
--font-brand: var(--font-brand);
--text-xs: var(--text-xs);
--text-sm: var(--text-sm);
--text-base: var(--text-base);
--text-lg: var(--text-lg);
--text-xl: var(--text-xl);
--text-2xl: var(--text-2xl);
--text-3xl: var(--text-3xl);
--leading-tight: var(--leading-tight);
--leading-normal: var(--leading-normal);
--leading-relaxed: var(--leading-relaxed);
--radius-none: 0px;
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-full: var(--radius-full);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
}
/* Excalidraw pattern: NO heights on root containers. The MCP host owns the
* iframe height; ext-apps' App.autoResize observes documentElement and reports
* content size back so the host right-sizes the iframe. Any height here (vh, %,
* etc.) breaks that feedback loop.
*
* The reset lives INSIDE @layer base so Tailwind's @layer utilities win an
* unlayered universal padding:0 would override every padding utility. */
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
border-color: var(--border);
}
body {
background: var(--app-bg);
color: var(--text-primary);
font-family: var(--font-sans);
letter-spacing: -0.01em;
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
}
}
#app {
position: relative;
}
/* ── Shell ────────────────────────────────────────────────────────────────── */
.mcp-widget-shell {
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
min-height: 220px;
overflow: hidden;
background: var(--widget-shell-bg);
color: var(--text-primary);
}
.mcp-widget-shell-graph {
min-height: 420px;
background: var(--widget-shell-graph-bg);
}
.mcp-widget-content {
position: relative;
z-index: 1;
}
.mcp-widget-shell-graph .mcp-widget-content {
flex: 1;
min-height: inherit;
}
/* ── Brand header — compact inline wordmark with a hairline divider ───────── */
.mcp-widget-brand {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) var(--page-header-px) 0;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-muted);
}
.mcp-widget-brand-mark {
display: grid;
width: 18px;
height: 18px;
place-items: center;
color: var(--accent);
}
.mcp-widget-brand-mark svg {
width: 15px;
height: 12px;
}
.mcp-widget-brand-copy {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.mcp-widget-brand-name {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
}
.mcp-widget-brand-mode {
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
}
/* ── Space picker — a framed, scrollable list of rows ─────────────────────── */
.space-picker-grid {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid var(--card-border);
border-radius: var(--radius-lg);
background: var(--card-bg);
box-shadow: var(--card-shadow);
/* Hide scrollbar chrome — the framed list keeps a stable height. */
scrollbar-width: none;
}
.space-picker-grid::-webkit-scrollbar {
display: none;
}
.space-card {
position: relative;
display: block;
width: 100%;
padding: var(--space-3) var(--space-3);
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
transition: background-color 0.15s ease;
}
.space-card:not(:last-child) {
border-bottom: 1px solid var(--border-muted);
}
.space-card:hover {
background: var(--card-bg-hover);
}
.space-card:focus-visible {
outline: none;
background: var(--card-bg-hover);
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
/* Active space is marked by an accent-tinted row + accent-colored name
* (see SpaceCard) no rail, no check, so the trailing badge stays aligned. */
.space-card[data-active="true"] {
background: var(--card-active-bg);
}
.space-card-inner {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.space-card-main {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.space-card-title {
min-width: 0;
}
.space-card-trailing {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
/* ── Loading — draw-in wordmark, no glow ──────────────────────────────────── */
.mcp-widget-content:has(.mcp-widget-loading) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.mcp-widget-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.super-loader {
display: inline-flex;
min-width: calc(var(--super-loader-size, 42px) + 10px);
align-items: center;
gap: var(--space-2);
color: var(--accent);
}
.super-loader-mark {
width: calc(var(--super-loader-size, 42px) * 0.5);
height: calc(var(--super-loader-size, 42px) * 0.5);
flex-shrink: 0;
overflow: visible;
}
.super-loader-path {
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0.2;
animation: super-loader-draw 0.9s ease-in-out infinite alternate;
}
.super-loader-path-left {
animation-delay: 0.18s;
}
.super-loader-label {
color: var(--text-muted);
font-size: calc(var(--super-loader-size, 42px) * 0.25);
font-weight: 500;
white-space: nowrap;
}
@keyframes super-loader-draw {
from {
stroke-dashoffset: 1;
opacity: 0.2;
}
to {
stroke-dashoffset: 0;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.super-loader-path {
stroke-dashoffset: 0;
opacity: 0.7;
animation: none;
}
}
/* ── Graph canvas ─────────────────────────────────────────────────────────── */
/* The graph needs a definite height in both modes.
*
* Inline: derive height from the (stable) width via aspect-ratio width is
* fixed by the chat column, so this doesn't feed the autoResize loop.
*
* Fullscreen: the host expands the iframe, so 100dvh = the fullscreen height.
* We stay IN FLOW (no position:fixed) taking the element out of flow collapses
* the document, autoResize shrinks the iframe, and a fixed inset:0 box in a
* collapsed iframe ends up with zero height. Keeping it in flow with an explicit
* height avoids that. */
.graph-view {
width: 100%;
aspect-ratio: 16 / 9;
/* Never collapse to 0 during a resize a 0-size frame makes the package
* re-fit (a camera animation that reads as a re-layout). */
min-height: 420px;
position: relative;
background: transparent;
}
.graph-view.fullscreen {
aspect-ratio: auto;
height: 100dvh;
}

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,419 @@
/* Design tokens for the Supermemory MCP widget.
*
* The widget renders inside a host chat surface (Cursor, Claude, ChatGPT). It
* should read as a native extension of the Supermemory console, whose language
* is: flat cool-neutral surfaces, a signature recessed inset-shadow on inputs,
* one restrained blue accent, and DM Sans. Decorative surfaces stay flat; the
* light primary action alone uses a subtle tonal CSS texture.
*
* The host applies `data-theme="light" | "dark"` on the document via ext-apps'
* `applyDocumentTheme`; Tailwind's `dark:` variant is wired in globals.css via
* `@custom-variant dark (&:is([data-theme="dark"] *))`. When no host is present
* (standalone / Studio) we fall back to the OS preference.
*/
/* ── Light (default) ──────────────────────────────────────────────────────── */
:root,
[data-theme="light"] {
color-scheme: light;
/* Accent one restrained blue. Deeper than the dark accent so it stays
* legible as text/border on white. */
--accent: #2e6fe6;
--accent-foreground: #ffffff;
--accent-hover: #2560d0;
--accent-muted: rgba(46, 111, 230, 0.1);
--accent-ring: rgba(46, 111, 230, 0.35);
--button-primary-bg: var(--accent);
--button-primary-hover: var(--accent-hover);
--button-primary-active: #1f55bb;
--button-primary-text: #ffffff;
--button-primary-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(31, 85, 187, 0.2);
--button-primary-texture:
radial-gradient(
90% 180% at 20% -40%,
rgba(255, 255, 255, 0.28) 0%,
rgba(255, 255, 255, 0) 62%
),
radial-gradient(
70% 150% at 62% 0%,
rgba(133, 183, 255, 0.18) 0%,
rgba(133, 183, 255, 0) 68%
),
radial-gradient(
85% 180% at 100% 120%,
rgba(12, 63, 165, 0.34) 0%,
rgba(12, 63, 165, 0) 64%
);
/* Surfaces quiet neutral layers. Depth comes from the near-white canvas,
* a soft shell, and white cards rather than a large drop shadow. */
--app-bg: #fbfbfa;
--widget-shell-bg: #f6f6f4;
--widget-shell-graph-bg: #f6f6f4;
--bg-primary: #f6f6f4;
--bg-secondary: #fbfbfa;
--bg-muted: #f0f0ed;
--bg-elevated: #ffffff;
--bg-overlay: rgba(251, 251, 250, 0.82);
--bg-panel: #ffffff;
--bg-control: #ffffff;
--bg-control-hover: #f5f5f2;
--border: #e3e3df;
--border-muted: #ececea;
--border-control: #d9d9d4;
--border-accent: rgba(46, 111, 230, 0.45);
--card-bg: #ffffff;
--card-bg-hover: #fafaf8;
--card-border: #e1e1dd;
--card-border-hover: #d2d2cd;
--card-active-bg: #f3f3f0;
--card-active-border: rgba(46, 111, 230, 0.4);
--card-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(23, 23, 23, 0.04),
0 5px 14px rgba(23, 23, 23, 0.025);
--card-shadow-hover:
inset 0 1px 0 rgba(255, 255, 255, 0.95), 0 1px 2px rgba(23, 23, 23, 0.05),
0 7px 18px rgba(23, 23, 23, 0.04);
--panel-bg: #ffffff;
--panel-border: #dfdfdb;
--panel-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(23, 23, 23, 0.04),
0 6px 16px rgba(23, 23, 23, 0.03);
/* Signature recessed shadow subtle in light so fields read as inset
* without going muddy. */
--shadow-inset: inset 0 1px 2px rgba(23, 23, 23, 0.055);
--shadow-inset-strong: inset 1px 1px 3px rgba(23, 23, 23, 0.075);
--text-primary: #171717;
--text-secondary: #5f5f5f;
--text-muted: #737373;
--text-inverse: #ffffff;
--success: #15935a;
--success-muted: rgba(21, 147, 90, 0.12);
--error: #d64545;
--error-muted: rgba(214, 69, 69, 0.12);
--warning: #b7791f;
--warning-muted: rgba(183, 121, 31, 0.12);
--info: #2e6fe6;
--info-muted: rgba(46, 111, 230, 0.12);
--danger: #d64545;
/* Spacing scale (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Typography — DM Sans throughout, mirroring the console. */
--font-sans:
"DM Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font-sans);
--font-brand: var(--font-sans);
--font-mono:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--leading-tight: 1.25;
--leading-normal: 1.4;
--leading-relaxed: 1.55;
/* Unified component heights */
--height-xs: 28px;
--height-sm: 34px;
--height-md: 38px;
--height-lg: 44px;
/* Icon sizes */
--icon-xs: 14px;
--icon-sm: 16px;
--icon-md: 20px;
--icon-lg: 24px;
/* Radius — a touch rounder than the console for a consumer feel. */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--border-width: 1px;
/* Page layout — widget iframes are narrow, so use compact values. */
--page-header-px: var(--space-4);
--page-header-py: var(--space-4);
--page-title-size: var(--text-lg);
--page-title-weight: var(--font-semibold);
--page-title-mt: var(--space-4);
/* Panel shadow family (also exposed to Tailwind as shadow-sm/md/lg). */
--shadow-sm: 0 1px 2px rgba(23, 23, 23, 0.05);
--shadow-md:
0 1px 2px rgba(23, 23, 23, 0.04), 0 6px 16px rgba(23, 23, 23, 0.045);
--shadow-lg:
0 2px 6px rgba(23, 23, 23, 0.06), 0 14px 32px rgba(23, 23, 23, 0.08);
/* Host-provided iframe dimensions; fallback for standalone dev. */
--host-height: 100vh;
--host-width: 100vw;
/* Memory graph canvas read by @supermemory/memory-graph's theme.
* Neutral fills, one accent, semantic (not decorative) relation edges. */
--graph-bg: transparent;
--graph-doc-fill: #f3f3f0;
--graph-doc-stroke: #d9d9d4;
--graph-doc-inner: #ffffff;
--graph-mem-fill: #edf3fc;
--graph-mem-fill-hover: #e2ecfb;
--graph-mem-stroke: #2e6fe6;
--graph-accent: #2e6fe6;
--graph-text-primary: #171717;
--graph-text-secondary: #5f5f5f;
--graph-text-muted: #737373;
--graph-edge-derives: #b8b8b3;
--graph-edge-updates: #2e6fe6;
--graph-edge-extends: #0e9488;
--graph-mem-border-forgotten: #d64545;
--graph-mem-border-expiring: #b7791f;
--graph-mem-border-recent: #15935a;
--graph-glow: rgba(46, 111, 230, 0.35);
--graph-icon: #737373;
--graph-popover-bg: #ffffff;
--graph-popover-border: #e1e1dd;
--graph-popover-text-primary: #171717;
--graph-popover-text-secondary: #5f5f5f;
--graph-popover-text-muted: #737373;
--graph-control-bg: #ffffff;
--graph-control-border: #e1e1dd;
}
/* ── Dark ─────────────────────────────────────────────────────────────────── */
[data-theme="dark"] {
color-scheme: dark;
--accent: #4ba0fa;
--accent-foreground: #0a0f16;
--accent-hover: #66b0ff;
--accent-muted: rgba(75, 160, 250, 0.14);
--accent-ring: rgba(75, 160, 250, 0.4);
--button-primary-bg: #0d121a;
--button-primary-hover: #121820;
--button-primary-active: #0a0e14;
--button-primary-text: #fafafa;
--button-primary-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(0, 0, 0, 0.1);
--button-primary-texture: none;
--app-bg: #080c11;
--widget-shell-bg: #0b1119;
--widget-shell-graph-bg: #0b1119;
--bg-primary: #0b1119;
--bg-secondary: #0e1620;
--bg-muted: #17212f;
--bg-elevated: #101822;
--bg-overlay: rgba(11, 17, 25, 0.8);
--bg-panel: #101822;
--bg-control: #0d141d;
--bg-control-hover: #131d2a;
--border: #1f2b3b;
--border-muted: #172230;
--border-control: #263141;
--border-accent: rgba(75, 160, 250, 0.4);
--card-bg: #101822;
--card-bg-hover: #131d2a;
--card-border: #1f2b3b;
--card-border-hover: #2b3a4e;
--card-active-bg: rgba(75, 160, 250, 0.1);
--card-active-border: rgba(75, 160, 250, 0.45);
--card-shadow: none;
--card-shadow-hover: none;
--panel-bg: #101822;
--panel-border: #1f2b3b;
--panel-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.4),
0 14px 34px rgba(0, 0, 0, 0.35);
/* Signature inset — the console's recessed emboss. */
--shadow-inset: inset 2px 2px 5px rgba(4, 8, 13, 0.55);
--shadow-inset-strong: inset 2.4px 2.4px 4.5px rgba(6, 10, 16, 0.7);
--text-primary: #eef2f7;
--text-secondary: #a9b4c2;
--text-muted: #6f7d8f;
--text-inverse: #0b1119;
--success: #34d399;
--success-muted: rgba(52, 211, 153, 0.14);
--error: #f87171;
--error-muted: rgba(248, 113, 113, 0.14);
--warning: #fbbf24;
--warning-muted: rgba(251, 191, 36, 0.14);
--info: #4ba0fa;
--info-muted: rgba(75, 160, 250, 0.14);
--danger: #f87171;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 6px 18px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.5);
--graph-bg: transparent;
--graph-doc-fill: #131d2a;
--graph-doc-stroke: #2b3a4e;
--graph-doc-inner: #0d141d;
--graph-mem-fill: #122437;
--graph-mem-fill-hover: #172d44;
--graph-mem-stroke: #4ba0fa;
--graph-accent: #4ba0fa;
--graph-text-primary: #eef2f7;
--graph-text-secondary: #a9b4c2;
--graph-text-muted: #6f7d8f;
--graph-edge-derives: #5b6675;
--graph-edge-updates: #4ba0fa;
--graph-edge-extends: #2dd4bf;
--graph-mem-border-forgotten: #f87171;
--graph-mem-border-expiring: #fbbf24;
--graph-mem-border-recent: #34d399;
--graph-glow: rgba(75, 160, 250, 0.45);
--graph-icon: #6f7d8f;
--graph-popover-bg: #101822;
--graph-popover-border: #1f2b3b;
--graph-popover-text-primary: #eef2f7;
--graph-popover-text-secondary: #a9b4c2;
--graph-popover-text-muted: #6f7d8f;
--graph-control-bg: #101822;
--graph-control-border: #1f2b3b;
}
/* ── Standalone / no host: follow the OS preference ───────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not([data-theme="dark"]) {
color-scheme: dark;
--accent: #4ba0fa;
--accent-foreground: #0a0f16;
--accent-hover: #66b0ff;
--accent-muted: rgba(75, 160, 250, 0.14);
--accent-ring: rgba(75, 160, 250, 0.4);
--button-primary-bg: #0d121a;
--button-primary-hover: #121820;
--button-primary-active: #0a0e14;
--button-primary-text: #fafafa;
--button-primary-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(0, 0, 0, 0.1);
--button-primary-texture: none;
--app-bg: #080c11;
--widget-shell-bg: #0b1119;
--widget-shell-graph-bg: #0b1119;
--bg-primary: #0b1119;
--bg-secondary: #0e1620;
--bg-muted: #17212f;
--bg-elevated: #101822;
--bg-overlay: rgba(11, 17, 25, 0.8);
--bg-panel: #101822;
--bg-control: #0d141d;
--bg-control-hover: #131d2a;
--border: #1f2b3b;
--border-muted: #172230;
--border-control: #263141;
--border-accent: rgba(75, 160, 250, 0.4);
--card-bg: #101822;
--card-bg-hover: #131d2a;
--card-border: #1f2b3b;
--card-border-hover: #2b3a4e;
--card-active-bg: rgba(75, 160, 250, 0.1);
--card-active-border: rgba(75, 160, 250, 0.45);
--card-shadow: none;
--card-shadow-hover: none;
--panel-bg: #101822;
--panel-border: #1f2b3b;
--panel-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.4),
0 14px 34px rgba(0, 0, 0, 0.35);
--shadow-inset: inset 2px 2px 5px rgba(4, 8, 13, 0.55);
--shadow-inset-strong: inset 2.4px 2.4px 4.5px rgba(6, 10, 16, 0.7);
--text-primary: #eef2f7;
--text-secondary: #a9b4c2;
--text-muted: #6f7d8f;
--text-inverse: #0b1119;
--success: #34d399;
--success-muted: rgba(52, 211, 153, 0.14);
--error: #f87171;
--error-muted: rgba(248, 113, 113, 0.14);
--warning: #fbbf24;
--warning-muted: rgba(251, 191, 36, 0.14);
--info: #4ba0fa;
--info-muted: rgba(75, 160, 250, 0.14);
--danger: #f87171;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 6px 18px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.5);
--graph-bg: transparent;
--graph-doc-fill: #131d2a;
--graph-doc-stroke: #2b3a4e;
--graph-doc-inner: #0d141d;
--graph-mem-fill: #122437;
--graph-mem-fill-hover: #172d44;
--graph-mem-stroke: #4ba0fa;
--graph-accent: #4ba0fa;
--graph-text-primary: #eef2f7;
--graph-text-secondary: #a9b4c2;
--graph-text-muted: #6f7d8f;
--graph-edge-derives: #5b6675;
--graph-edge-updates: #4ba0fa;
--graph-edge-extends: #2dd4bf;
--graph-mem-border-forgotten: #f87171;
--graph-mem-border-expiring: #fbbf24;
--graph-mem-border-recent: #34d399;
--graph-glow: rgba(75, 160, 250, 0.45);
--graph-icon: #6f7d8f;
--graph-popover-bg: #101822;
--graph-popover-border: #1f2b3b;
--graph-popover-text-primary: #eef2f7;
--graph-popover-text-secondary: #a9b4c2;
--graph-popover-text-muted: #6f7d8f;
--graph-control-bg: #101822;
--graph-control-border: #1f2b3b;
}
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react"
import { cn } from "../lib/cn"
// Simplified ActionGroup for widget: no resize observer / popover collapse.
// Widget iframes are always narrow and we never overflow more than a couple
// of buttons. Just a horizontal flex row matching console-v2's inline path.
export function ActionGroup({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn("flex items-center gap-(--space-3)", className)}>
{children}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { cva, type VariantProps } from "class-variance-authority"
import type { HTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Mirrors console-v2's Badge: mono-font, soft pastel surfaces.
const badgeVariants = cva(
[
"inline-flex items-center px-[var(--space-2)] py-0.5",
"text-[length:var(--text-xs)] font-medium font-[family-name:var(--font-mono)]",
"rounded-[var(--radius-sm)]",
].join(" "),
{
variants: {
variant: {
success: "bg-[var(--success-muted)] text-[var(--success)]",
error: "bg-[var(--error-muted)] text-[var(--error)]",
warning: "bg-[var(--warning-muted)] text-[var(--warning)]",
info: "bg-[var(--info-muted)] text-[var(--info)]",
neutral: "bg-[var(--bg-muted)] text-[var(--text-secondary)]",
accent: "bg-[var(--accent-muted)] text-[var(--accent)]",
},
},
defaultVariants: {
variant: "neutral",
},
},
)
export interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span
className={cn(badgeVariants({ variant, className }))}
data-slot="badge"
{...props}
/>
)
}
export { badgeVariants }

View file

@ -0,0 +1,116 @@
import { cva, type VariantProps } from "class-variance-authority"
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from "react"
import { Loader2 } from "../../lib/icons"
import { cn } from "../lib/cn"
// Token-driven, theme-aware pill controls. Primary stays high contrast while
// matching each theme's surface family; secondary remains a neutral,
// input-like surface.
const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2",
"font-semibold whitespace-nowrap",
"rounded-full",
"transition-colors duration-150 cursor-pointer",
"disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-primary)]",
"[&_svg:not([class*='size-'])]:size-4 shrink-0",
].join(" "),
{
variants: {
variant: {
primary: [
"bg-[var(--button-primary-bg)] text-[var(--button-primary-text)]",
"[background-image:var(--button-primary-texture)]",
"shadow-[var(--button-primary-shadow)]",
"hover:bg-[var(--button-primary-hover)] active:bg-[var(--button-primary-active)]",
].join(" "),
secondary: [
"bg-[var(--bg-control)] text-[var(--text-primary)]",
"border border-[var(--border-control)]",
"hover:bg-[var(--bg-control-hover)] hover:border-[var(--card-border-hover)]",
].join(" "),
ghost: [
"text-[var(--text-secondary)]",
"hover:bg-[var(--bg-muted)] hover:text-[var(--text-primary)]",
].join(" "),
danger: [
"bg-[var(--error-muted)] text-[var(--error)]",
"hover:bg-[var(--error-muted)] hover:brightness-95",
].join(" "),
},
size: {
sm: "h-9 px-4 text-[13px]",
icon: "size-8 p-0",
},
},
defaultVariants: {
variant: "primary",
size: "sm",
},
},
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
shortcut?: ReactNode
brandFont?: boolean
iconLeft?: ReactNode
iconRight?: ReactNode
loading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
shortcut,
brandFont = true,
iconLeft,
iconRight,
loading = false,
disabled,
children,
...props
},
ref,
) => {
const isDisabled = disabled || loading
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
disabled={isDisabled}
ref={ref}
style={brandFont ? { fontFamily: "var(--font-brand)" } : undefined}
type="button"
{...props}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : iconLeft}
{children}
{shortcut && !loading ? (
<span
className={cn(
"text-[0.8em]",
variant === "primary" || variant === "danger"
? "opacity-60"
: variant === "ghost"
? "text-[var(--text-muted)] bg-[var(--bg-muted)] rounded-[var(--radius-sm)] px-1"
: "text-[var(--text-muted)]",
)}
>
{shortcut}
</span>
) : null}
{!loading && iconRight}
</button>
)
},
)
Button.displayName = "Button"
export { buttonVariants }

View file

@ -0,0 +1,65 @@
import { cva, type VariantProps } from "class-variance-authority"
import {
type ButtonHTMLAttributes,
forwardRef,
type HTMLAttributes,
} from "react"
import { cn } from "../lib/cn"
const cardStyles = cva(
[
"group relative text-left",
"rounded-xl border border-[var(--card-border)] bg-[var(--card-bg)] p-4 shadow-[var(--card-shadow)]",
"transition-colors duration-150",
].join(" "),
{
variants: {
variant: {
default: "",
interactive:
"cursor-pointer hover:border-[var(--card-border-hover)] hover:bg-[var(--card-bg-hover)] hover:shadow-[var(--card-shadow-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
active:
"cursor-pointer border-[var(--card-active-border)] bg-[var(--card-active-bg)] hover:border-[var(--card-active-border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
},
},
defaultVariants: { variant: "default" },
},
)
type CardVariantProps = VariantProps<typeof cardStyles>
type DivProps = HTMLAttributes<HTMLDivElement> &
CardVariantProps & { as?: "div" }
type ButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> &
CardVariantProps & { as: "button" }
export type CardProps = DivProps | ButtonElementProps
export const Card = forwardRef<HTMLElement, CardProps>(
({ className, variant, as = "div", children, ...props }, ref) => {
const cls = cn(cardStyles({ variant }), className)
if (as === "button") {
return (
<button
className={cls}
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
>
{children}
</button>
)
}
return (
<div
className={cls}
ref={ref as React.Ref<HTMLDivElement>}
{...(props as HTMLAttributes<HTMLDivElement>)}
>
{children}
</div>
)
},
)
Card.displayName = "Card"

View file

@ -0,0 +1,35 @@
import { cva, type VariantProps } from "class-variance-authority"
import { type ButtonHTMLAttributes, forwardRef } from "react"
import { cn } from "../lib/cn"
const chipStyles = cva(
"inline-flex items-center px-[var(--space-3)] h-[var(--height-xs)] rounded-full text-[length:var(--text-xs)] font-medium border transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
{
variants: {
selected: {
true: "bg-accent-muted border-accent text-accent",
false:
"bg-bg-elevated border-[var(--card-border)] text-text-secondary hover:border-[var(--card-border-hover)] hover:bg-bg-control-hover",
},
},
defaultVariants: { selected: false },
},
)
export interface ChipProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof chipStyles> {}
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
({ className, selected, children, ...props }, ref) => (
<button
className={cn(chipStyles({ selected }), className)}
ref={ref}
type="button"
{...props}
>
{children}
</button>
),
)
Chip.displayName = "Chip"

View file

@ -0,0 +1,29 @@
import type { ReactNode } from "react"
import { cn } from "../lib/cn"
interface Props {
label?: ReactNode
hint?: ReactNode
error?: string | null
className?: string
children: ReactNode
}
export function Field({ label, hint, error, className, children }: Props) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label ? (
// biome-ignore lint/a11y/noLabelWithoutControl: generic field wrapper where children provide the input
<label className="text-[11px] font-semibold uppercase tracking-[0.06em] text-text-muted">
{label}
</label>
) : null}
{children}
{error ? (
<span className="text-xs text-error">{error}</span>
) : hint ? (
<span className="text-xs text-text-muted">{hint}</span>
) : null}
</div>
)
}

View file

@ -0,0 +1,99 @@
import type { ChangeEvent, DragEvent, ReactNode } from "react"
import { useCallback, useState } from "react"
import { Upload } from "../../lib/icons"
import { cn } from "../lib/cn"
// Mirrors console-v2's FileUpload: label-based drop area, two-line copy,
// muted icon, --space-12 vertical padding, accent ring on drag-over.
//
// Simpler than console-v2's because the widget uses a single accept string
// and a single-file flow today (multiple={false}). MIME/extension partition
// is left to the caller via the existing `accept` HTML attribute.
interface FileUploadProps {
accept: string
onFile: (file: File) => void
title?: string
description?: string
icon?: ReactNode
disabled?: boolean
className?: string
}
export function FileUpload({
accept,
onFile,
title = "Drop a file here or click to browse",
description,
icon,
disabled,
className,
}: FileUploadProps) {
const [dragOver, setDragOver] = useState(false)
const handleDrop = useCallback(
(e: DragEvent<HTMLLabelElement>) => {
e.preventDefault()
if (disabled) return
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) onFile(file)
},
[disabled, onFile],
)
const handleInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const file = e.target.files?.[0]
if (file) onFile(file)
e.target.value = ""
},
[disabled, onFile],
)
return (
<label
className={cn(
"flex min-h-[180px] flex-col items-center justify-center gap-(--space-3)",
"py-(--space-12) px-(--space-6)",
"border border-dashed rounded-(--radius-lg)",
"bg-[var(--bg-control)] cursor-pointer shadow-[var(--shadow-inset)] transition-colors",
dragOver
? "border-accent bg-[var(--accent-muted)]"
: "border-[var(--border-control)] hover:border-[var(--card-border-hover)] hover:bg-[var(--bg-control-hover)]",
disabled && "pointer-events-none opacity-50",
className,
)}
onDragEnter={(e) => {
e.preventDefault()
if (!disabled) setDragOver(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setDragOver(false)
}}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{icon ?? <Upload className="size-8 text-text-muted" />}
<div className="flex flex-col gap-(--space-1) text-center">
<p className="text-(length:--text-sm) font-medium text-text-primary">
{title}
</p>
{description ? (
<p className="text-(length:--text-xs) text-text-muted">
{description}
</p>
) : null}
</div>
<input
accept={accept}
className="hidden"
disabled={disabled}
onChange={handleInput}
type="file"
/>
</label>
)
}

View file

@ -0,0 +1,58 @@
import { cva, type VariantProps } from "class-variance-authority"
import { forwardRef, type InputHTMLAttributes } from "react"
import { cn } from "../lib/cn"
// A recessed field: solid control surface, hairline border, and the console's
// signature inset shadow. Focus is a clean accent ring — no glow.
const inputVariants = cva(
[
"flex w-full",
"bg-[var(--bg-control)] text-[var(--text-primary)]",
"border border-[var(--border-control)]",
"rounded-[var(--radius-lg)]",
"shadow-[var(--shadow-inset)]",
"placeholder:text-[var(--text-muted)]",
"transition-colors",
"hover:border-[var(--card-border-hover)]",
"focus-visible:outline-none focus-visible:border-[var(--border-accent)] focus-visible:shadow-[var(--shadow-inset),0_0_0_2px_var(--accent-ring)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--error)]",
"file:border-0 file:bg-transparent file:text-[length:var(--text-sm)] file:font-medium",
].join(" "),
{
variants: {
inputSize: {
sm: "h-[var(--height-sm)] px-[var(--space-3)] text-[length:var(--text-xs)]",
md: "h-[var(--height-md)] px-[var(--space-4)] text-[length:var(--text-sm)]",
lg: "h-[var(--height-lg)] px-[var(--space-4)] text-[length:var(--text-base)]",
},
},
defaultVariants: {
inputSize: "md",
},
},
)
export interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size">,
VariantProps<typeof inputVariants> {
size?: "sm" | "md" | "lg"
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, size, inputSize, type, ...props }, ref) => {
const resolvedSize = size ?? inputSize ?? "md"
return (
<input
className={cn(inputVariants({ inputSize: resolvedSize, className }))}
data-slot="input"
ref={ref}
type={type}
{...props}
/>
)
},
)
Input.displayName = "Input"
export { inputVariants }

View file

@ -0,0 +1,53 @@
import { memo, type ReactNode } from "react"
import { cn } from "../lib/cn"
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
className?: string
}
// Mirrors console-v2's PageHeader: brand-font title at `--page-title-size`,
// optional description below, right-aligned actions, consistent horizontal
// page padding so body content can align with `px-(--page-header-px)`.
export const PageHeader = memo(function PageHeader({
title,
description,
actions,
children,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"px-(--page-header-px) pt-(--page-title-mt) pb-(--page-header-py)",
className,
)}
data-slot="page-header"
>
<div className="flex items-center justify-between gap-(--space-4)">
<div className="flex flex-col gap-(--space-1) min-w-0">
<h1
className="text-(length:--page-title-size) font-(--page-title-weight) text-text-primary truncate"
style={{ fontFamily: "var(--font-brand)" }}
>
{title}
</h1>
{description ? (
<p className="text-(length:--text-sm) text-text-secondary">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex items-center gap-(--space-3) shrink-0">
{actions}
</div>
) : null}
</div>
{children}
</div>
)
})

View file

@ -0,0 +1,35 @@
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { type ComponentPropsWithoutRef, forwardRef } from "react"
import { cn } from "../lib/cn"
// Ported from console-v2 shared/popover.tsx — same surface treatment.
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
className={cn(
"z-50 w-72 p-(--space-4)",
"bg-bg-elevated border border-border",
"rounded-(--radius-lg)",
"shadow-lg backdrop-blur-xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
data-slot="popover-content"
ref={ref}
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = "PopoverContent"
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -0,0 +1,160 @@
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { ChevronDown, Search } from "../../lib/icons"
import { cn } from "../lib/cn"
import { Button } from "./Button"
import { Popover, PopoverContent, PopoverTrigger } from "./Popover"
import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip"
export interface SpaceOption {
value: string
label: string
description?: string
icon?: ReactNode
}
interface SpaceSelectProps {
value: string | null
onValueChange: (value: string) => void
options: SpaceOption[]
placeholder?: string
emptyText?: string
searchPlaceholder?: string
disabled?: boolean
className?: string
}
// Ports console-v2's ChipSelect/ContainerTagSelect: a Popover-anchored,
// searchable list. Scales to hundreds of spaces where chips can't.
export function SpaceSelect({
value,
onValueChange,
options,
placeholder = "Select space",
emptyText = "No spaces",
searchPlaceholder = "Search spaces…",
disabled = false,
className,
}: SpaceSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const selected = value ? options.find((o) => o.value === value) : null
const triggerLabel = selected?.label ?? placeholder
useEffect(() => {
if (!open) setQuery("")
}, [open])
const filtered = useMemo(() => {
if (!query.trim()) return options
const q = query.trim().toLowerCase()
return options.filter(
(o) =>
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().includes(q) ||
(o.description?.toLowerCase().includes(q) ?? false),
)
}, [options, query])
return (
<Tooltip>
<Popover onOpenChange={setOpen} open={open}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
brandFont={false}
className={cn(
"space-select-trigger w-full justify-between normal-case tracking-normal font-medium",
"shadow-[var(--shadow-inset)]",
"data-[state=open]:border-[var(--border-accent)]",
className,
)}
disabled={disabled}
iconRight={<ChevronDown className="size-3 text-text-muted" />}
size="sm"
type="button"
variant="secondary"
>
<span
className={cn(
"flex-1 truncate text-left",
!selected && "text-text-muted",
)}
>
{triggerLabel}
</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) min-w-[260px] p-(--space-1)"
>
<div className="flex items-center gap-(--space-2) border-b border-border-muted px-(--space-2) py-(--space-2) mb-(--space-1)">
<Search className="size-4 text-text-muted shrink-0" />
<input
className="w-full bg-transparent text-(length:--text-sm) text-text-primary placeholder:text-text-muted focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
value={query}
/>
</div>
<div className="flex max-h-[300px] flex-col overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-(--space-3) py-(--space-3) text-(length:--text-xs) text-text-muted italic">
{query ? "No matches" : emptyText}
</div>
) : (
filtered.map((option) => {
const isSelected = option.value === value
return (
<button
className={cn(
"flex items-start gap-(--space-2)",
"px-(--space-3) py-(--space-2)",
"rounded-(--radius-md)",
"text-left cursor-pointer transition-colors",
"hover:bg-bg-muted",
"focus-visible:outline-none focus-visible:bg-bg-muted",
isSelected && "bg-accent-muted",
)}
key={option.value}
onClick={() => {
onValueChange(option.value)
setOpen(false)
}}
type="button"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-(--space-2)">
{option.icon ?? null}
<span
className={cn(
"text-(length:--text-sm) truncate",
isSelected
? "font-medium text-accent"
: "text-text-primary",
)}
>
{option.label}
</span>
</span>
{option.description ? (
<span className="text-(length:--text-xs) text-text-muted truncate">
{option.description}
</span>
) : null}
</span>
</button>
)
})
)}
</div>
</PopoverContent>
</Popover>
{selected ? (
<TooltipContent side="top">{selected.value}</TooltipContent>
) : null}
</Tooltip>
)
}

View file

@ -0,0 +1,50 @@
import { cva, type VariantProps } from "class-variance-authority"
import { forwardRef, type HTMLAttributes } from "react"
import { cn } from "../lib/cn"
const stackStyles = cva("flex", {
variants: {
direction: {
row: "flex-row",
column: "flex-col",
rowWrap: "flex-row flex-wrap",
},
gap: {
none: "gap-0",
xs: "gap-1",
sm: "gap-2",
md: "gap-3",
lg: "gap-4",
xl: "gap-6",
},
align: {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
},
justify: {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
around: "justify-around",
},
},
defaultVariants: { direction: "column", gap: "md", align: "stretch" },
})
export interface StackProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof stackStyles> {}
export const Stack = forwardRef<HTMLDivElement, StackProps>(
({ className, direction, gap, align, justify, ...props }, ref) => (
<div
className={cn(stackStyles({ direction, gap, align, justify }), className)}
ref={ref}
{...props}
/>
),
)
Stack.displayName = "Stack"

View file

@ -0,0 +1,35 @@
import { forwardRef, type TextareaHTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Multi-line counterpart to Input — same recessed field, inset shadow, and clean
// accent focus ring.
const textAreaClass = [
"flex w-full",
"bg-[var(--bg-control)] text-[var(--text-primary)]",
"border border-[var(--border-control)]",
"rounded-[var(--radius-lg)]",
"shadow-[var(--shadow-inset)]",
"px-[var(--space-3)] py-[var(--space-3)]",
"text-[length:var(--text-sm)] leading-normal font-sans",
"placeholder:text-[var(--text-muted)]",
"transition-colors resize-y",
"hover:border-[var(--card-border-hover)]",
"focus-visible:outline-none focus-visible:border-[var(--border-accent)] focus-visible:shadow-[var(--shadow-inset),0_0_0_2px_var(--accent-ring)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--error)]",
].join(" ")
export interface TextAreaProps
extends TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, ...props }, ref) => (
<textarea
className={cn(textAreaClass, className)}
data-slot="textarea"
ref={ref}
{...props}
/>
),
)
TextArea.displayName = "TextArea"

View file

@ -0,0 +1,46 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { type ComponentPropsWithoutRef, forwardRef } from "react"
import { cn } from "../lib/cn"
// Ported from console-v2 shared/tooltip.tsx.
const TooltipProvider = TooltipPrimitive.Provider
function Tooltip({
delayDuration = 300,
...props
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Root> & {
delayDuration?: number
}) {
return (
<TooltipProvider delayDuration={delayDuration}>
<TooltipPrimitive.Root {...props} />
</TooltipProvider>
)
}
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
"z-50 px-(--space-2) py-(--space-1)",
"text-(length:--text-xs)",
"bg-text-primary text-bg-elevated",
"rounded-(--radius-md)",
"shadow-md",
className,
)}
data-slot="tooltip-content"
ref={ref}
sideOffset={sideOffset}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = "TooltipContent"
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

Some files were not shown because too many files have changed in this diff Show more