mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
migrate mcp app to stateless sdk v2
This commit is contained in:
parent
9523e563dd
commit
f85366c847
54 changed files with 2207 additions and 951 deletions
|
|
@ -1,24 +1,33 @@
|
|||
# Supermemory MCP Server 4.0
|
||||
# Supermemory MCP Server
|
||||
|
||||
A standalone MCP (Model Context Protocol) server for Supermemory that gives AI assistants persistent memory across conversations. Built on Cloudflare Workers with Durable Objects for scalable, persistent connections.
|
||||
The Supermemory MCP server gives authenticated AI clients access to a user's
|
||||
memories, profile, workspaces, and interactive MCP Apps.
|
||||
|
||||
## Features
|
||||
## Runtime Model
|
||||
|
||||
- **Authentication** - OAuth 2.1 with dynamic client registration
|
||||
- **Persistent Memory** - Save and recall information across sessions
|
||||
- **User Profiles** - Auto-generated profiles from stored memories
|
||||
- **Project Scoping** - Organize memories by project with `x-sm-project` header
|
||||
- **Analytics** - PostHog integration for usage tracking
|
||||
- MCP SDK v2 with a fresh `McpServer` for every HTTP request
|
||||
- Modern MCP `2026-07-28` plus stateless compatibility for 2025 clients
|
||||
- OAuth token validation on every request
|
||||
- No MCP protocol session or protocol Durable Object
|
||||
- Active workspace stored as application state in a dedicated Durable Object
|
||||
- Workspace state keyed by authenticated `organizationId + userId`
|
||||
|
||||
## Setup
|
||||
The workspace used by an operation resolves in this order:
|
||||
|
||||
### Server URL
|
||||
1. An explicit `containerTag` tool or prompt argument
|
||||
2. The account's durable active workspace
|
||||
3. The Supermemory client default, `sm_project_default`
|
||||
|
||||
An explicit override applies only to that call. It does not mutate the active
|
||||
workspace.
|
||||
|
||||
## Server URL
|
||||
|
||||
```text
|
||||
https://mcp.supermemory.ai/mcp
|
||||
```
|
||||
|
||||
Add to your MCP client config (Claude, Cursor, Windsurf, VS Code, etc.):
|
||||
Example client configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -30,223 +39,103 @@ Add to your MCP client config (Claude, Cursor, Windsurf, VS Code, etc.):
|
|||
}
|
||||
```
|
||||
|
||||
The server requires OAuth authentication. Your MCP client will automatically discover the authorization server via `/.well-known/oauth-protected-resource` and prompt you to authenticate.
|
||||
|
||||
### Project Scoping (Optional)
|
||||
|
||||
To scope all operations to a specific project, add the `x-sm-project` header:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"supermemory": {
|
||||
"url": "https://mcp.supermemory.ai/mcp",
|
||||
"headers": {
|
||||
"x-sm-project": "your-project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
The client discovers the OAuth authorization server through
|
||||
`/.well-known/oauth-protected-resource/mcp`.
|
||||
|
||||
## Tools
|
||||
|
||||
### `memory`
|
||||
### Model-visible tools
|
||||
|
||||
Save or forget information about the user.
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `search_memory` | Search memories and optionally include profile context |
|
||||
| `listMemories` | List extracted memories grouped by source document |
|
||||
| `listSpaces` | List workspaces visible to the authenticated account |
|
||||
| `whoAmI` | Return identity, access, client, and active-workspace context |
|
||||
| `add_memory` | Save or forget a memory |
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "User prefers dark mode and uses TypeScript",
|
||||
"action": "save",
|
||||
"containerTag": "optional-project-tag"
|
||||
}
|
||||
```
|
||||
### MCP App launchers
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `content` | string | Yes | The memory content to save or forget |
|
||||
| `action` | `"save"` \| `"forget"` | No | Default: `"save"` |
|
||||
| `containerTag` | string | No | Project tag to scope the memory |
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `select-workspace` | Open the interactive workspace picker |
|
||||
| `memory-graph` | Open the interactive memory graph |
|
||||
| `guided-save` | Open the guided memory form |
|
||||
| `upload-file` | Open the file upload form |
|
||||
|
||||
### `recall`
|
||||
### App-only tools
|
||||
|
||||
Search memories and get user profile.
|
||||
These tools are available to the embedded MCP App and hidden from the model.
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "What are the user's programming preferences?",
|
||||
"includeProfile": true,
|
||||
"containerTag": "optional-project-tag"
|
||||
}
|
||||
```
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `set-active-tag` | Persist the selected active workspace |
|
||||
| `save-memory` | Submit the guided save form |
|
||||
| `upload-file-submit` | Submit an encoded file upload |
|
||||
| `fetch-graph-data` | Fetch graph documents for the app |
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | Yes | Search query to find relevant memories |
|
||||
| `includeProfile` | boolean | No | Include user profile summary. Default: `true` |
|
||||
| `containerTag` | string | No | Project tag to scope the search |
|
||||
## Resources And Prompt
|
||||
|
||||
### `listMemories`
|
||||
| Kind | Name or URI | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Resource | `supermemory://profile` | Profile facts in the effective workspace |
|
||||
| Resource | `supermemory://container-tags` | Visible workspaces |
|
||||
| Resource | `ui://supermemory/app-v3.html` | Embedded MCP App bundle |
|
||||
| Prompt | `context` | Profile and recent context for an optional workspace |
|
||||
|
||||
Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search.
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"containerTag": "optional-project-tag"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `page` | integer | No | Page number (1-based). Default: `1` |
|
||||
| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` |
|
||||
| `containerTag` | string | No | Project tag to scope the listing |
|
||||
|
||||
### `whoAmI`
|
||||
|
||||
Get the current logged-in user's information.
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
Returns: `{ userId, email, name, client, sessionId }`
|
||||
|
||||
## Resources
|
||||
|
||||
| URI | Description |
|
||||
|-----|-------------|
|
||||
| `supermemory://profile` | User profile with stable preferences and recent activity |
|
||||
| `supermemory://projects` | List of available memory projects |
|
||||
|
||||
## Prompts
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `context` | User profile and preferences for system context injection |
|
||||
The App resource and tool metadata include both current nested `ui` metadata and
|
||||
the legacy flat resource URI key while MCP Apps completes its SDK v2 migration.
|
||||
The Worker runtime does not import the SDK v1 Apps server helpers.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) or Node.js
|
||||
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
|
||||
|
||||
### Install Dependencies
|
||||
Install from the repository root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.dev.vars` file:
|
||||
|
||||
```env
|
||||
API_URL=http://localhost:8787
|
||||
or
|
||||
API_URL=https://api.supermemory.ai
|
||||
```
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `API_URL` | Main Supermemory API URL for OAuth validation | `https://api.supermemory.ai` |
|
||||
|
||||
### Run Locally
|
||||
Run the local Worker:
|
||||
|
||||
```bash
|
||||
cd apps/mcp
|
||||
bun run dev
|
||||
```
|
||||
|
||||
The server will start at `http://localhost:8788`.
|
||||
The portless URL is `http://mcp.dev.supermemory`.
|
||||
|
||||
**Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the
|
||||
core journey: handshake → tool/resource/prompt discovery → `whoAmI` → `listProjects` →
|
||||
`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads,
|
||||
the `context` prompt, container-tag isolation, and auth rejections.
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default
|
||||
export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server
|
||||
bun e2e/capture-oauth-token.ts # one-time browser authorization
|
||||
bun run build
|
||||
bun run check-types
|
||||
bun run test:unit
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) |
|
||||
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
|
||||
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
|
||||
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
|
||||
| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation |
|
||||
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
|
||||
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |
|
||||
|
||||
#### OAuth flow tests
|
||||
|
||||
`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main
|
||||
API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers:
|
||||
|
||||
- **A–C (no secrets)** — discovery chain, dynamic client registration, and token/authorize
|
||||
negatives. These exercise the protocol wiring with no key and no browser, so they always run.
|
||||
- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to
|
||||
`/mcp` with it, exercising the OAuth-token validation path. It **skips** unless both OAuth
|
||||
credentials below are available.
|
||||
Authenticated end-to-end tests use credentials captured by:
|
||||
|
||||
```bash
|
||||
# One-time capture (opens a browser for login + consent, prints the env vars):
|
||||
bun e2e/capture-oauth-token.ts
|
||||
export SUPERMEMORY_MCP_CLIENT_ID=...
|
||||
export SUPERMEMORY_MCP_REFRESH_TOKEN=...
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Authenticated tests **skip** (not fail) without stored OAuth credentials or the refresh-token
|
||||
environment variables, so CI is safe without secrets.
|
||||
- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip
|
||||
**polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort.
|
||||
- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account.
|
||||
Without stored OAuth credentials, authenticated test groups skip. Public OAuth
|
||||
discovery and rejection tests still run.
|
||||
|
||||
### Deploy
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
bun run deploy
|
||||
```
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `API_URL` | Supermemory API and OAuth issuer | `https://api.supermemory.ai` |
|
||||
| `MCP_RESOURCE` | Expected OAuth audience | `https://mcp.supermemory.ai/mcp` |
|
||||
| `ALLOWED_MCP_ORIGIN_HOSTNAMES` | Additional comma-separated browser origins | Built-in host allowlist |
|
||||
|
||||
## Architecture
|
||||
## Storage And Rollout
|
||||
|
||||
```
|
||||
┌─────────────────┐ OAuth ┌──────────────────┐
|
||||
│ MCP Client │◄──────────────►│ Supermemory API │
|
||||
│ (Claude, Cursor)│ │ (api.supermemory.ai)
|
||||
└────────┬────────┘ └──────────────────┘
|
||||
│ ▲
|
||||
│ MCP Protocol │ Auth Validation
|
||||
▼ │
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Supermemory MCP Server │
|
||||
│ (mcp.supermemory.ai/mcp) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Cloudflare Durable Object │ │
|
||||
│ │ • Session state │ │
|
||||
│ │ • Client info persistence │ │
|
||||
│ │ • MCP protocol handling │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
`WorkspaceState` stores only the active container tag. It never stores bearer
|
||||
tokens, MCP client identity, or protocol messages.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Cloudflare Workers
|
||||
- **State:** Durable Objects with SQLite
|
||||
- **Framework:** Hono
|
||||
- **MCP SDK:** @modelcontextprotocol/sdk + agents
|
||||
- **API Client:** supermemory SDK
|
||||
- **Analytics:** PostHog
|
||||
The old `SupermemoryMCP` class and binding remain inert for one rollout. This
|
||||
keeps the migration non-destructive and rollback-safe. A later deployment can
|
||||
delete the old protocol class after production traffic and rollback windows
|
||||
have been checked.
|
||||
|
|
|
|||
|
|
@ -47,22 +47,35 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
|
|||
const res = await s.client.readResource({ uri: "supermemory://profile" })
|
||||
expect(res.contents.length).toBeGreaterThan(0)
|
||||
expect(res.contents[0].mimeType).toBe("text/plain")
|
||||
expect(typeof res.contents[0].text).toBe("string")
|
||||
expect(res.contents[0].text).toMatch(/# Active Workspace Profile/)
|
||||
expect(res.contents[0].text).toMatch(/Workspace:/)
|
||||
expect(res.contents[0].text).toMatch(
|
||||
/Use `listSpaces` to find the relevant workspace key/,
|
||||
)
|
||||
})
|
||||
|
||||
it("reads the container-tags resource as JSON", async () => {
|
||||
it("reads all workspaces in a compact human-readable format", async () => {
|
||||
const res = await s.client.readResource({
|
||||
uri: "supermemory://container-tags",
|
||||
})
|
||||
const text = res.contents[0].text as string
|
||||
const parsed = JSON.parse(text)
|
||||
expect(Array.isArray(parsed.containerTags)).toBe(true)
|
||||
expect(res.contents[0].mimeType).toBe("text/plain")
|
||||
expect(text).toMatch(/# My Workspaces/)
|
||||
expect(text).toMatch(/Active:/)
|
||||
expect(text).not.toMatch(/"containerTags":/)
|
||||
})
|
||||
|
||||
it("gets the context prompt as a system message", async () => {
|
||||
it("gets compact active-workspace context without prompt arguments", async () => {
|
||||
const prompts = await s.client.listPrompts()
|
||||
const contextPrompt = prompts.prompts.find(
|
||||
(prompt) => prompt.name === "context",
|
||||
)
|
||||
expect(contextPrompt?.arguments ?? []).toHaveLength(0)
|
||||
|
||||
const res = await s.client.getPrompt({ name: "context", arguments: {} })
|
||||
expect(res.messages.length).toBeGreaterThan(0)
|
||||
const text = res.messages[0].content.text as string
|
||||
expect(text).toMatch(/memory|context/i)
|
||||
expect(text).toMatch(/# Supermemory Context/)
|
||||
expect(text).toMatch(/Active workspace:/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -183,13 +183,13 @@ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|||
export type Session = { client: Client; close: () => Promise<void> }
|
||||
|
||||
export async function connect(
|
||||
opts: { token?: string; containerTag?: string } = {},
|
||||
opts: { token?: string; headers?: Record<string, string> } = {},
|
||||
): Promise<Session> {
|
||||
const bearerToken = opts.token ?? (await defaultBearerToken())
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
...opts.headers,
|
||||
}
|
||||
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
requestInit: { headers },
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
OAUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
recallUntil,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
type ToolLike = {
|
||||
name: string
|
||||
inputSchema?: { properties?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
|
||||
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
|
||||
|
||||
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
|
||||
const SCOPE_TAG = "sm_e2e_root"
|
||||
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
|
||||
|
||||
// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
|
||||
describeWithAuth("MCP — x-sm-project root scoping", () => {
|
||||
it("strips containerTag from tool schemas when x-sm-project is set", async () => {
|
||||
const scoped = await connect({ containerTag: SCOPE_TAG })
|
||||
const plain = await connect()
|
||||
try {
|
||||
const scopedTools = (await scoped.client.listTools()).tools
|
||||
const plainTools = (await plain.client.listTools()).tools
|
||||
|
||||
expect(propsOf(plainTools, "add_memory")).toHaveProperty("containerTag")
|
||||
expect(propsOf(plainTools, "search_memory")).toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
|
||||
expect(propsOf(scopedTools, "add_memory")).not.toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
expect(propsOf(scopedTools, "search_memory")).not.toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
} finally {
|
||||
await scoped.close()
|
||||
await plain.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("scopes saves to the connection project and isolates them from default", async () => {
|
||||
const marker = `root-${randomUUID()}`
|
||||
const content = `e2e root scope. token=${marker}. The root flower is bluebell.`
|
||||
|
||||
const rooted = await connect({ containerTag: SCOPE_TAG })
|
||||
try {
|
||||
const save = await callTool(rooted.client, "add_memory", {
|
||||
content,
|
||||
action: "save",
|
||||
})
|
||||
expect(save.isError).toBeFalsy()
|
||||
expect(textOf(save)).toContain(SCOPE_TAG)
|
||||
|
||||
const found = await recallUntil(
|
||||
rooted.client,
|
||||
"root flower bluebell",
|
||||
marker,
|
||||
)
|
||||
expect(found, "marker not found within its root scope").not.toBeNull()
|
||||
} finally {
|
||||
await callTool(rooted.client, "add_memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
await rooted.close()
|
||||
}
|
||||
|
||||
// A default connection searches sm_project_default only — must not see it.
|
||||
const plain = await connect()
|
||||
try {
|
||||
const leaked = await recallUntil(
|
||||
plain.client,
|
||||
"root flower bluebell",
|
||||
marker,
|
||||
{
|
||||
tries: 3,
|
||||
delayMs: 3000,
|
||||
},
|
||||
)
|
||||
expect(leaked, "rooted memory leaked into the default project").toBeNull()
|
||||
} finally {
|
||||
await plain.close()
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
38
apps/mcp/e2e/workspace-scope.test.ts
Normal file
38
apps/mcp/e2e/workspace-scope.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { OAUTH_CREDENTIALS_AVAILABLE, connect } from "./helpers"
|
||||
|
||||
type ToolLike = {
|
||||
name: string
|
||||
inputSchema?: { properties?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
|
||||
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
|
||||
|
||||
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
|
||||
|
||||
describeWithAuth("MCP - workspace scoping", () => {
|
||||
it("keeps per-call containerTag overrides when an obsolete header is sent", async () => {
|
||||
const scoped = await connect({
|
||||
headers: { "x-sm-project": "obsolete-root-scope" },
|
||||
})
|
||||
const plain = await connect()
|
||||
try {
|
||||
const scopedTools = (await scoped.client.listTools()).tools
|
||||
const plainTools = (await plain.client.listTools()).tools
|
||||
|
||||
expect(propsOf(plainTools, "add_memory")).toHaveProperty("containerTag")
|
||||
expect(propsOf(plainTools, "search_memory")).toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
|
||||
expect(propsOf(scopedTools, "add_memory")).toHaveProperty("containerTag")
|
||||
expect(propsOf(scopedTools, "search_memory")).toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
} finally {
|
||||
await scoped.close()
|
||||
await plain.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
"name": "supermemory-mcp",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"portless": {
|
||||
"name": "mcp.dev.supermemory",
|
||||
"script": "dev:app"
|
||||
},
|
||||
"scripts": {
|
||||
"build:widget": "vite build",
|
||||
"build": "vite build",
|
||||
|
|
@ -10,19 +14,23 @@
|
|||
"dev:widget": "vite --config vite.config.dev.ts",
|
||||
"studio": "vite --config vite.config.dev.ts --open /studio.html",
|
||||
"deploy": "vite build && wrangler deploy --minify",
|
||||
"check-types": "tsc --noEmit -p tsconfig.widget.json",
|
||||
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.widget.json",
|
||||
"test:unit": "vitest run src/server src/widget",
|
||||
"test:e2e": "vitest run e2e",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-oauth-provider": "^0.2.2",
|
||||
"@modelcontextprotocol/ext-apps": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"@modelcontextprotocol/server": "2.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@supermemory/memory-graph": "^0.2.0",
|
||||
"agents": "^0.3.5",
|
||||
"agents": "^0.20.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.11.1",
|
||||
|
|
@ -31,7 +39,7 @@
|
|||
"react-dom": "^19.2.4",
|
||||
"supermemory": "^4.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250620.0",
|
||||
|
|
@ -43,6 +51,7 @@
|
|||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { McpAgent } from "agents/mcp"
|
||||
import type { Props } from "../shared/types"
|
||||
import { fetchSession } from "./auth"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { registerContextPrompt } from "./prompts/context"
|
||||
import { registerContainerTagsResource } from "./resources/container-tags"
|
||||
import { registerProfileResource } from "./resources/profile"
|
||||
import { registerWidgetResource } from "./resources/widget"
|
||||
import { registerAllTools } from "./tools"
|
||||
import { errorResult } from "./tools/types"
|
||||
|
||||
type Env = {
|
||||
MCP_SERVER: DurableObjectNamespace<SupermemoryMCP>
|
||||
API_URL?: string
|
||||
}
|
||||
|
||||
const DEFAULT_API_URL = "https://api.supermemory.ai"
|
||||
const ACTIVE_CONTAINER_TAG_KEY = "activeContainerTag"
|
||||
|
||||
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
|
||||
// @ts-expect-error - agents/mcp ships its own bundled @modelcontextprotocol/sdk;
|
||||
// our installed sdk has a private `_serverInfo` field with a different declaration.
|
||||
server = new McpServer({
|
||||
name: "supermemory",
|
||||
version: "1.0.0",
|
||||
})
|
||||
|
||||
async init() {
|
||||
const stored = await this.ctx.storage.get<{
|
||||
name: string
|
||||
version?: string
|
||||
}>("clientInfo")
|
||||
if (stored) this.clientInfo = stored
|
||||
|
||||
this.server.server.oninitialized = async () => {
|
||||
const v = this.server.server.getClientVersion()
|
||||
if (v) {
|
||||
this.clientInfo = { name: v.name, version: v.version }
|
||||
await this.ctx.storage.put("clientInfo", this.clientInfo)
|
||||
}
|
||||
}
|
||||
|
||||
const deps = {
|
||||
server: this.server,
|
||||
props: this.props,
|
||||
getClient: (containerTag?: string) => this.getClient(containerTag),
|
||||
getSession: () => this.getSession(),
|
||||
resolveContainerTag: (explicit?: string) =>
|
||||
this.resolveContainerTag(explicit),
|
||||
getActiveContainerTag: () => this.getActiveContainerTag(),
|
||||
setActiveContainerTag: (containerTag: string) =>
|
||||
this.setActiveContainerTag(containerTag),
|
||||
getClientInfo: () => this.clientInfo,
|
||||
getMcpSessionId: () => this.getSessionId(),
|
||||
errorResult,
|
||||
}
|
||||
|
||||
registerAllTools(deps)
|
||||
|
||||
registerProfileResource(this.server, () => this.getClient())
|
||||
registerContainerTagsResource(this.server, () => this.getClient())
|
||||
registerWidgetResource(this.server)
|
||||
|
||||
registerContextPrompt(
|
||||
this.server,
|
||||
!!this.props?.containerTag,
|
||||
(tag) => this.getClient(tag),
|
||||
(explicit) => this.resolveContainerTag(explicit),
|
||||
)
|
||||
}
|
||||
|
||||
private getClient(containerTag?: string): SupermemoryClient {
|
||||
return new SupermemoryClient(
|
||||
this.props?.bearerToken || "",
|
||||
containerTag || this.props?.containerTag,
|
||||
this.env.API_URL || DEFAULT_API_URL,
|
||||
)
|
||||
}
|
||||
|
||||
private async resolveContainerTag(
|
||||
explicit?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (this.props?.containerTag) return this.props.containerTag
|
||||
if (explicit) return explicit
|
||||
const activeTag = await this.getActiveContainerTag()
|
||||
if (activeTag) return activeTag
|
||||
return undefined
|
||||
}
|
||||
|
||||
private workspaceState() {
|
||||
const userId = this.props?.userId
|
||||
if (!userId) throw new Error("Authenticated user ID is required")
|
||||
const organizationId = this.props?.organizationId ?? "default"
|
||||
return this.env.MCP_SERVER.getByName(
|
||||
`workspace-state:${userId}:${organizationId}`,
|
||||
)
|
||||
}
|
||||
|
||||
private getActiveContainerTag(): Promise<string | undefined> {
|
||||
return this.workspaceState().readActiveContainerTag()
|
||||
}
|
||||
|
||||
private setActiveContainerTag(containerTag: string): Promise<void> {
|
||||
return this.workspaceState().writeActiveContainerTag(containerTag)
|
||||
}
|
||||
|
||||
async readActiveContainerTag(): Promise<string | undefined> {
|
||||
return this.ctx.storage.get<string>(ACTIVE_CONTAINER_TAG_KEY)
|
||||
}
|
||||
|
||||
async writeActiveContainerTag(containerTag: string): Promise<void> {
|
||||
await this.ctx.storage.put(ACTIVE_CONTAINER_TAG_KEY, containerTag)
|
||||
}
|
||||
|
||||
private getSession() {
|
||||
return fetchSession(
|
||||
this.props?.bearerToken || "",
|
||||
this.env.API_URL || DEFAULT_API_URL,
|
||||
)
|
||||
}
|
||||
}
|
||||
27
apps/mcp/src/server/app-metadata.test.ts
Normal file
27
apps/mcp/src/server/app-metadata.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
29
apps/mcp/src/server/app-metadata.ts
Normal file
29
apps/mcp/src/server/app-metadata.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -24,9 +24,20 @@ describe("MCP authentication", () => {
|
|||
})
|
||||
|
||||
async function signToken(
|
||||
overrides: { audience?: string; subject?: string; expiresIn?: string } = {},
|
||||
overrides: {
|
||||
audience?: string
|
||||
subject?: string
|
||||
expiresIn?: string
|
||||
organizationId?: string
|
||||
} = {},
|
||||
) {
|
||||
let token = new SignJWT({ organization_id: "org_test" })
|
||||
let token = new SignJWT({
|
||||
...(overrides.organizationId === ""
|
||||
? {}
|
||||
: { organization_id: overrides.organizationId ?? "org_test" }),
|
||||
azp: "client_test",
|
||||
scope: "openid profile",
|
||||
})
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience(overrides.audience ?? MCP_RESOURCE)
|
||||
|
|
@ -51,10 +62,21 @@ describe("MCP authentication", () => {
|
|||
userId: "user_test",
|
||||
organizationId: "org_test",
|
||||
bearerToken: token,
|
||||
oauthClientId: "client_test",
|
||||
scopes: ["openid", "profile"],
|
||||
expiresAt: expect.any(Number),
|
||||
})
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects a token without an organization boundary", async () => {
|
||||
const token = await signToken({ organizationId: "" })
|
||||
|
||||
await expect(
|
||||
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("rejects a token issued for a different audience", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
const token = await signToken({ audience: "https://api.example.com" })
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ const FETCH_TIMEOUT_MS = 30_000
|
|||
|
||||
export interface AuthUser {
|
||||
userId: string
|
||||
organizationId?: string
|
||||
organizationId: string
|
||||
bearerToken: string
|
||||
oauthClientId?: string
|
||||
scopes: string[]
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
const remoteJwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
|
||||
|
|
@ -65,13 +68,32 @@ export async function validateOAuthToken(
|
|||
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
typeof payload.organization_id !== "string" ||
|
||||
payload.organization_id.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawScopes = payload.scope ?? payload.scopes
|
||||
const scopes = Array.isArray(rawScopes)
|
||||
? rawScopes.filter((scope): scope is string => typeof scope === "string")
|
||||
: typeof rawScopes === "string"
|
||||
? rawScopes.split(/\s+/).filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
organizationId:
|
||||
typeof payload.organization_id === "string"
|
||||
? payload.organization_id
|
||||
: undefined,
|
||||
organizationId: payload.organization_id,
|
||||
bearerToken: token,
|
||||
oauthClientId:
|
||||
typeof payload.azp === "string"
|
||||
? payload.azp
|
||||
: typeof payload.client_id === "string"
|
||||
? payload.client_id
|
||||
: undefined,
|
||||
scopes,
|
||||
expiresAt: payload.exp,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("OAuth token validation error:", error)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
} from "../../shared/types"
|
||||
|
||||
const MAX_CHARS = 200000
|
||||
const DEFAULT_PROJECT_ID = "sm_project_default"
|
||||
export const DEFAULT_PROJECT_ID = "sm_project_default"
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export type {
|
||||
|
|
|
|||
8
apps/mcp/src/server/container-tag.ts
Normal file
8
apps/mcp/src/server/container-tag.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export const containerTagSchema = z
|
||||
.string()
|
||||
.min(1, "Container tag is required")
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
|
||||
export const optionalContainerTagSchema = containerTagSchema.optional()
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import type { AuthInfo } from "@modelcontextprotocol/server"
|
||||
import { createMcpHandler } from "agents/mcp/server"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { Props } from "../shared/types"
|
||||
import { SupermemoryMCP } from "./agent"
|
||||
import { validateOAuthToken } from "./auth"
|
||||
import { validateOAuthToken, type AuthUser } from "./auth"
|
||||
import { SupermemoryMCP } from "./legacy-protocol-state"
|
||||
import { createSupermemoryServer } from "./server"
|
||||
import type { ActorContext, ServerEnv } from "./types"
|
||||
import { WorkspaceState } from "./workspace-state"
|
||||
|
||||
type Bindings = {
|
||||
MCP_SERVER: DurableObjectNamespace<SupermemoryMCP>
|
||||
API_URL?: string
|
||||
MCP_RESOURCE?: string
|
||||
}
|
||||
|
||||
export type { Props }
|
||||
type Bindings = ServerEnv
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>()
|
||||
|
||||
|
|
@ -19,22 +17,31 @@ const DEFAULT_API_URL = "https://api.supermemory.ai"
|
|||
const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp"
|
||||
const PROTECTED_RESOURCE_METADATA_PATH =
|
||||
"/.well-known/oauth-protected-resource/mcp"
|
||||
const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [
|
||||
"app.supermemory.ai",
|
||||
"mcp.supermemory.ai",
|
||||
"mcp.dev.supermemory.ai",
|
||||
"mcp.dev.supermemory",
|
||||
"claude.ai",
|
||||
"chatgpt.com",
|
||||
"chat.openai.com",
|
||||
"gemini.google.com",
|
||||
"grok.com",
|
||||
"x.ai",
|
||||
"t3.chat",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"[::1]",
|
||||
]
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: "*",
|
||||
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allowHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"x-sm-project",
|
||||
"Accept",
|
||||
"Mcp-Session-Id",
|
||||
"MCP-Protocol-Version",
|
||||
"Last-Event-ID",
|
||||
],
|
||||
exposeHeaders: ["Mcp-Session-Id", "WWW-Authenticate"],
|
||||
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
|
||||
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
|
||||
exposeHeaders: ["WWW-Authenticate"],
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -42,31 +49,27 @@ app.get("/", (c) => {
|
|||
return c.json({
|
||||
name: "supermemory-mcp",
|
||||
version: "1.0.0",
|
||||
description: "Supermemory MCP — AI memory for teams",
|
||||
docs: "https://docs.supermemory.ai/mcp",
|
||||
description: "Supermemory MCP - AI memory for teams",
|
||||
docs: "https://supermemory.ai/docs/supermemory-mcp/mcp",
|
||||
})
|
||||
})
|
||||
|
||||
// OAuth discovery: resource metadata. The protected resource is the MCP
|
||||
// endpoint itself, so path-aware clients discover metadata at the well-known
|
||||
// URL with `/mcp` appended.
|
||||
function resourceMetadata(c: Context<{ Bindings: Bindings }>) {
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
|
||||
|
||||
return c.json({
|
||||
resource: mcpResource,
|
||||
authorization_servers: [apiUrl],
|
||||
authorization_servers: [`${apiUrl.replace(/\/+$/, "")}/api/auth`],
|
||||
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
||||
bearer_methods_supported: ["header"],
|
||||
resource_documentation: "https://docs.supermemory.ai/mcp",
|
||||
resource_documentation: "https://supermemory.ai/docs/supermemory-mcp/mcp",
|
||||
})
|
||||
}
|
||||
|
||||
app.get("/.well-known/oauth-protected-resource", resourceMetadata)
|
||||
app.get(PROTECTED_RESOURCE_METADATA_PATH, resourceMetadata)
|
||||
|
||||
// OAuth discovery: proxy authorization server metadata
|
||||
app.get("/.well-known/oauth-authorization-server", async (c) => {
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
|
||||
|
|
@ -80,42 +83,46 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|||
{ status: response.status as ContentfulStatusCode },
|
||||
)
|
||||
}
|
||||
const metadata = await response.json()
|
||||
return c.json(metadata)
|
||||
return c.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error("Error fetching OAuth metadata:", error)
|
||||
return c.json({ error: "Internal server error" }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
const mcpHandler = SupermemoryMCP.serve("/mcp", {
|
||||
binding: "MCP_SERVER",
|
||||
corsOptions: {
|
||||
origin: "*",
|
||||
methods: "GET, POST, DELETE, OPTIONS",
|
||||
headers: "Content-Type, Authorization, x-sm-project",
|
||||
},
|
||||
})
|
||||
function allowedOriginHostnames(env: Bindings): string[] {
|
||||
const configured =
|
||||
env.ALLOWED_MCP_ORIGIN_HOSTNAMES?.split(",")
|
||||
.map((hostname) => hostname.trim().toLowerCase())
|
||||
.filter(Boolean) ?? []
|
||||
|
||||
async function handleMcpRequest(
|
||||
c: Context<{ Bindings: Bindings }>,
|
||||
rewritePath?: string,
|
||||
) {
|
||||
const authHeader = c.req.header("Authorization")
|
||||
const token = authHeader?.replace(/^Bearer\s+/i, "")
|
||||
const containerTag = c.req.header("x-sm-project")
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
|
||||
return [...new Set([...DEFAULT_ALLOWED_ORIGIN_HOSTNAMES, ...configured])]
|
||||
}
|
||||
|
||||
// Build absolute resource_metadata URL from incoming request (works
|
||||
// behind tunnels where the scheme/host differ from localhost)
|
||||
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
|
||||
const reqProto = c.req.header("x-forwarded-proto") || "https"
|
||||
const resourceMetadataUrl = reqHost
|
||||
? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}`
|
||||
: PROTECTED_RESOURCE_METADATA_PATH
|
||||
function authInfoFor(
|
||||
authUser: AuthUser,
|
||||
resource: string,
|
||||
): AuthInfo | undefined {
|
||||
if (!authUser.oauthClientId) return undefined
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
token: authUser.bearerToken,
|
||||
clientId: authUser.oauthClientId,
|
||||
scopes: authUser.scopes,
|
||||
expiresAt: authUser.expiresAt,
|
||||
resource: new URL(resource),
|
||||
extra: {
|
||||
userId: authUser.userId,
|
||||
organizationId: authUser.organizationId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorizedResponse(
|
||||
resourceMetadataUrl: string,
|
||||
invalidToken = false,
|
||||
): Response {
|
||||
if (!invalidToken) {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
|
|
@ -126,55 +133,76 @@ async function handleMcpRequest(
|
|||
})
|
||||
}
|
||||
|
||||
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
|
||||
if (!authUser) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Invalid or expired token",
|
||||
},
|
||||
id: null,
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
return Response.json(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
id: null,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function handleMcpRequest(
|
||||
c: Context<{ Bindings: Bindings }>,
|
||||
rewritePath?: string,
|
||||
) {
|
||||
const authHeader = c.req.header("Authorization")
|
||||
const token = authHeader?.replace(/^Bearer\s+/i, "").trim()
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
|
||||
|
||||
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
|
||||
const reqProto = c.req.header("x-forwarded-proto") || "https"
|
||||
const resourceMetadataUrl = reqHost
|
||||
? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}`
|
||||
: PROTECTED_RESOURCE_METADATA_PATH
|
||||
|
||||
if (!token) return unauthorizedResponse(resourceMetadataUrl)
|
||||
|
||||
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
|
||||
|
||||
const actor: ActorContext = {
|
||||
userId: authUser.userId,
|
||||
organizationId: authUser.organizationId,
|
||||
bearerToken: authUser.bearerToken,
|
||||
oauthClientId: authUser.oauthClientId,
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
...c.executionCtx,
|
||||
props: {
|
||||
userId: authUser.userId,
|
||||
organizationId: authUser.organizationId,
|
||||
bearerToken: authUser.bearerToken,
|
||||
containerTag,
|
||||
} satisfies Props,
|
||||
} as ExecutionContext & { props: Props }
|
||||
|
||||
const request = rewritePath
|
||||
? new Request(new URL(rewritePath, c.req.url).toString(), c.req.raw)
|
||||
: c.req.raw
|
||||
const handler = createMcpHandler(
|
||||
() => createSupermemoryServer(c.env, actor),
|
||||
{
|
||||
route: "/mcp",
|
||||
legacy: "stateless",
|
||||
corsOptions: false,
|
||||
allowedOriginHostnames: allowedOriginHostnames(c.env),
|
||||
onerror: (error) => console.error("MCP request error:", error),
|
||||
},
|
||||
)
|
||||
|
||||
return mcpHandler.fetch(request, c.env, ctx)
|
||||
return handler.fetch(request, {
|
||||
authInfo: authInfoFor(authUser, mcpResource),
|
||||
})
|
||||
}
|
||||
|
||||
app.all("/", async (c) => {
|
||||
return handleMcpRequest(c, "/mcp")
|
||||
})
|
||||
app.all("/", (c) => handleMcpRequest(c, "/mcp"))
|
||||
app.all("/mcp", (c) => handleMcpRequest(c))
|
||||
app.all("/mcp/", (c) => handleMcpRequest(c, "/mcp"))
|
||||
|
||||
app.all("/mcp/*", async (c) => {
|
||||
return handleMcpRequest(c)
|
||||
})
|
||||
|
||||
export { SupermemoryMCP }
|
||||
export { SupermemoryMCP, WorkspaceState }
|
||||
export type { ActorContext, ServerEnv }
|
||||
|
||||
export default app
|
||||
|
|
|
|||
5
apps/mcp/src/server/legacy-protocol-state.ts
Normal file
5
apps/mcp/src/server/legacy-protocol-state.ts
Normal 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 {}
|
||||
|
|
@ -1,76 +1,98 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { z } from "zod"
|
||||
import type { SupermemoryClient } from "../client"
|
||||
import type { McpServer } from "@modelcontextprotocol/server"
|
||||
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
|
||||
import {
|
||||
compactDescription,
|
||||
formatFactSection,
|
||||
formatWorkspaceRow,
|
||||
sortWorkspaces,
|
||||
workspaceDisplayName,
|
||||
workspaceMetadata,
|
||||
} from "../workspace-presentation"
|
||||
|
||||
const CONTEXT_FACT_LIMIT = 8
|
||||
const RECENT_WORKSPACE_LIMIT = 3
|
||||
|
||||
export function registerContextPrompt(
|
||||
server: McpServer,
|
||||
hasRootContainerTag: boolean,
|
||||
getClient: (tag?: string) => SupermemoryClient,
|
||||
resolveContainerTag: (explicit?: string) => Promise<string | undefined>,
|
||||
resolveContainerTag: () => Promise<string | undefined>,
|
||||
) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = hasRootContainerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
|
||||
const argsSchema = {
|
||||
includeRecent: z.boolean().optional().default(true),
|
||||
...containerTagField,
|
||||
}
|
||||
|
||||
server.registerPrompt(
|
||||
"context",
|
||||
{
|
||||
description: "Get user context including profile and workspace info",
|
||||
argsSchema,
|
||||
description: "Attach compact context for the active workspace",
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
includeRecent?: boolean
|
||||
containerTag?: string
|
||||
}
|
||||
async () => {
|
||||
try {
|
||||
const effectiveTag = await resolveContainerTag(args.containerTag)
|
||||
const client = getClient(effectiveTag)
|
||||
const profileResult = await client.getProfile()
|
||||
const selectedTag = await resolveContainerTag()
|
||||
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
|
||||
const [profileResult, workspaces] = await Promise.all([
|
||||
getClient(activeKey).getProfile(),
|
||||
getClient().listContainerTags(),
|
||||
])
|
||||
const activeWorkspace = workspaces.find(
|
||||
(workspace) => workspace.containerTag === activeKey,
|
||||
)
|
||||
const activeLabel = workspaceDisplayName(activeWorkspace, activeKey)
|
||||
const fallback = selectedTag ? "" : " (default)"
|
||||
const parts: string[] = [
|
||||
"# Supermemory Context",
|
||||
`Active workspace: ${activeLabel} [${activeKey}]${fallback}`,
|
||||
]
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (profileResult.profile.static.length > 0) {
|
||||
parts.push("## About the user")
|
||||
for (const fact of profileResult.profile.static) {
|
||||
parts.push(`- ${fact}`)
|
||||
}
|
||||
if (activeWorkspace) {
|
||||
const metadata = workspaceMetadata(activeWorkspace)
|
||||
if (metadata) parts.push(metadata)
|
||||
const description = compactDescription(activeWorkspace.description)
|
||||
if (description) parts.push(description)
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"",
|
||||
...formatFactSection(
|
||||
"Stable Context",
|
||||
profileResult.profile.static,
|
||||
CONTEXT_FACT_LIMIT,
|
||||
),
|
||||
...formatFactSection(
|
||||
"Recent Context",
|
||||
profileResult.profile.dynamic,
|
||||
CONTEXT_FACT_LIMIT,
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
args.includeRecent !== false &&
|
||||
profileResult.profile.dynamic.length > 0
|
||||
profileResult.profile.static.length === 0 &&
|
||||
profileResult.profile.dynamic.length === 0
|
||||
) {
|
||||
parts.push("\n## Recent context")
|
||||
for (const fact of profileResult.profile.dynamic) {
|
||||
parts.push(`- ${fact}`)
|
||||
}
|
||||
parts.push("No profile facts are available for this workspace yet.")
|
||||
}
|
||||
|
||||
if (effectiveTag) {
|
||||
parts.push(`\n## Active workspace: ${effectiveTag}`)
|
||||
const recentWorkspaces = sortWorkspaces(workspaces, activeKey)
|
||||
.filter((workspace) => workspace.containerTag !== activeKey)
|
||||
.slice(0, RECENT_WORKSPACE_LIMIT)
|
||||
if (recentWorkspaces.length > 0) {
|
||||
parts.push(
|
||||
"",
|
||||
"## Recently Active Workspaces",
|
||||
...recentWorkspaces.map((workspace) =>
|
||||
formatWorkspaceRow(workspace, activeKey, 100),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"",
|
||||
"Use a workspace key with workspace-aware tools when the user asks about another workspace. Keep workspace contexts separate unless the user asks to combine them.",
|
||||
)
|
||||
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text:
|
||||
parts.length > 0
|
||||
? parts.join("\n")
|
||||
: "No user context available yet.",
|
||||
text: parts.join("\n"),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,23 +1,48 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import type { SupermemoryClient } from "../client"
|
||||
import type { McpServer } from "@modelcontextprotocol/server"
|
||||
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
|
||||
import {
|
||||
formatWorkspaceRow,
|
||||
sortWorkspaces,
|
||||
workspaceDisplayName,
|
||||
} from "../workspace-presentation"
|
||||
|
||||
export function registerContainerTagsResource(
|
||||
server: McpServer,
|
||||
getClient: () => SupermemoryClient,
|
||||
resolveContainerTag: () => Promise<string | undefined>,
|
||||
) {
|
||||
server.registerResource(
|
||||
"My Container Tags",
|
||||
"My Workspaces",
|
||||
"supermemory://container-tags",
|
||||
{},
|
||||
async () => {
|
||||
const client = getClient()
|
||||
const containerTags = await client.listContainerTags()
|
||||
const [containerTags, selectedTag] = await Promise.all([
|
||||
client.listContainerTags(),
|
||||
resolveContainerTag(),
|
||||
])
|
||||
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
|
||||
const activeWorkspace = containerTags.find(
|
||||
(workspace) => workspace.containerTag === activeKey,
|
||||
)
|
||||
const rows = sortWorkspaces(containerTags, activeKey).map((workspace) =>
|
||||
formatWorkspaceRow(workspace, activeKey),
|
||||
)
|
||||
const activeLabel = workspaceDisplayName(activeWorkspace, activeKey)
|
||||
const fallback = selectedTag ? "" : " (default)"
|
||||
const text = [
|
||||
"# My Workspaces",
|
||||
`${containerTags.length} available · Active: ${activeLabel} [${activeKey}]${fallback}`,
|
||||
"",
|
||||
...rows,
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "supermemory://container-tags",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({ containerTags }, null, 2),
|
||||
mimeType: "text/plain",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,79 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import type { SupermemoryClient } from "../client"
|
||||
import type { McpServer } from "@modelcontextprotocol/server"
|
||||
import { DEFAULT_PROJECT_ID, type SupermemoryClient } from "../client"
|
||||
import {
|
||||
compactDescription,
|
||||
formatFactSection,
|
||||
workspaceDisplayName,
|
||||
workspaceMetadata,
|
||||
} from "../workspace-presentation"
|
||||
|
||||
const PROFILE_FACT_LIMIT = 12
|
||||
|
||||
export function registerProfileResource(
|
||||
server: McpServer,
|
||||
getClient: () => SupermemoryClient,
|
||||
getClient: (containerTag?: string) => SupermemoryClient,
|
||||
resolveContainerTag: () => Promise<string | undefined>,
|
||||
) {
|
||||
server.registerResource(
|
||||
"User Profile",
|
||||
"Active Workspace Profile",
|
||||
"supermemory://profile",
|
||||
{},
|
||||
async () => {
|
||||
const client = getClient()
|
||||
const profileResult = await client.getProfile()
|
||||
const parts: string[] = ["# User Profile\n"]
|
||||
const selectedTag = await resolveContainerTag()
|
||||
const activeKey = selectedTag ?? DEFAULT_PROJECT_ID
|
||||
const [profileResult, workspaces] = await Promise.all([
|
||||
getClient(activeKey).getProfile(),
|
||||
getClient().listContainerTags(),
|
||||
])
|
||||
const activeWorkspace = workspaces.find(
|
||||
(workspace) => workspace.containerTag === activeKey,
|
||||
)
|
||||
const activeLabel = workspaceDisplayName(activeWorkspace, activeKey)
|
||||
const fallback = selectedTag ? "" : " (default)"
|
||||
const parts: string[] = [
|
||||
"# Active Workspace Profile",
|
||||
`Workspace: ${activeLabel} [${activeKey}]${fallback}`,
|
||||
]
|
||||
|
||||
if (profileResult.profile.static.length > 0) {
|
||||
parts.push("## Stable Preferences")
|
||||
for (const fact of profileResult.profile.static) {
|
||||
parts.push(`- ${fact}`)
|
||||
}
|
||||
if (activeWorkspace) {
|
||||
const metadata = workspaceMetadata(activeWorkspace)
|
||||
if (metadata) parts.push(metadata)
|
||||
const description = compactDescription(activeWorkspace.description)
|
||||
if (description) parts.push(description)
|
||||
}
|
||||
|
||||
if (profileResult.profile.dynamic.length > 0) {
|
||||
parts.push("\n## Recent Activity")
|
||||
for (const fact of profileResult.profile.dynamic) {
|
||||
parts.push(`- ${fact}`)
|
||||
}
|
||||
parts.push(
|
||||
"",
|
||||
...formatFactSection(
|
||||
"Stable Context",
|
||||
profileResult.profile.static,
|
||||
PROFILE_FACT_LIMIT,
|
||||
),
|
||||
...formatFactSection(
|
||||
"Recent Context",
|
||||
profileResult.profile.dynamic,
|
||||
PROFILE_FACT_LIMIT,
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
profileResult.profile.static.length === 0 &&
|
||||
profileResult.profile.dynamic.length === 0
|
||||
) {
|
||||
parts.push("No profile facts are available for this workspace yet.")
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"",
|
||||
"Other workspaces are available. Use `listSpaces` to find the relevant workspace key, then use that key with workspace-aware tools when the user asks about another workspace. Keep workspace contexts separate unless the user asks to combine them.",
|
||||
)
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "supermemory://profile",
|
||||
mimeType: "text/plain",
|
||||
text:
|
||||
parts.length > 1
|
||||
? parts.join("\n")
|
||||
: "No profile yet. Start saving memories.",
|
||||
text: parts.join("\n"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import {
|
||||
RESOURCE_MIME_TYPE,
|
||||
registerAppResource,
|
||||
} from "@modelcontextprotocol/ext-apps/server"
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import type { McpServer } from "@modelcontextprotocol/server"
|
||||
import supermemoryAppHtml from "../../../dist/src/widget/index.html"
|
||||
import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
|
||||
import { APP_RESOURCE_MIME_TYPE } from "../app-metadata"
|
||||
|
||||
const CSP_DOMAINS = [
|
||||
"https://esm.sh",
|
||||
|
|
@ -21,15 +18,14 @@ const RESOURCE_UI_META = {
|
|||
}
|
||||
|
||||
export function registerWidgetResource(server: McpServer) {
|
||||
registerAppResource(
|
||||
server,
|
||||
server.registerResource(
|
||||
"Supermemory MCP UI",
|
||||
SUPERMEMORY_RESOURCE_URI,
|
||||
// Listing-level metadata: hosts use this when discovering resources
|
||||
// before invoking the read callback. Mirrors the read response below
|
||||
// so prefetch/connect-time decisions match what the host will get.
|
||||
{
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
mimeType: APP_RESOURCE_MIME_TYPE,
|
||||
_meta: { ui: RESOURCE_UI_META },
|
||||
},
|
||||
// Read response: per spec, content-item `_meta.ui` takes precedence
|
||||
|
|
@ -39,7 +35,7 @@ export function registerWidgetResource(server: McpServer) {
|
|||
contents: [
|
||||
{
|
||||
uri: SUPERMEMORY_RESOURCE_URI,
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
mimeType: APP_RESOURCE_MIME_TYPE,
|
||||
text: supermemoryAppHtml,
|
||||
_meta: { ui: RESOURCE_UI_META },
|
||||
},
|
||||
|
|
|
|||
80
apps/mcp/src/server/server.ts
Normal file
80
apps/mcp/src/server/server.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
CLIENT_INFO_META_KEY,
|
||||
McpServer,
|
||||
type ServerContext,
|
||||
} from "@modelcontextprotocol/server"
|
||||
import { fetchSession } from "./auth"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { registerContextPrompt } from "./prompts/context"
|
||||
import { registerContainerTagsResource } from "./resources/container-tags"
|
||||
import { registerProfileResource } from "./resources/profile"
|
||||
import { registerWidgetResource } from "./resources/widget"
|
||||
import { registerAllTools } from "./tools"
|
||||
import { errorResult } from "./tools/types"
|
||||
import type { ActorContext, ServerEnv } from "./types"
|
||||
import {
|
||||
resolveContainerTag as resolveWorkspaceContainerTag,
|
||||
workspaceStateName,
|
||||
} from "./workspace"
|
||||
|
||||
const DEFAULT_API_URL = "https://api.supermemory.ai"
|
||||
|
||||
type ClientInfo = { name: string; version?: string }
|
||||
|
||||
function clientInfoFromContext(context: ServerContext): ClientInfo | null {
|
||||
const envelope = context.mcpReq.envelope as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const value = envelope?.[CLIENT_INFO_META_KEY]
|
||||
if (!value || typeof value !== "object") return null
|
||||
|
||||
const name = Reflect.get(value, "name")
|
||||
const version = Reflect.get(value, "version")
|
||||
if (typeof name !== "string") return null
|
||||
|
||||
return {
|
||||
name,
|
||||
...(typeof version === "string" ? { version } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createSupermemoryServer(
|
||||
env: ServerEnv,
|
||||
actor: ActorContext,
|
||||
): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "supermemory",
|
||||
version: "1.0.0",
|
||||
})
|
||||
const apiUrl = env.API_URL || DEFAULT_API_URL
|
||||
const workspaceState = env.WORKSPACE_STATE.getByName(
|
||||
workspaceStateName(actor),
|
||||
)
|
||||
|
||||
const getClient = (containerTag?: string) =>
|
||||
new SupermemoryClient(actor.bearerToken, containerTag, apiUrl)
|
||||
const getActiveContainerTag = () => workspaceState.getActiveContainerTag()
|
||||
const setActiveContainerTag = (containerTag: string) =>
|
||||
workspaceState.setActiveContainerTag(containerTag)
|
||||
const resolveContainerTag = (explicit?: string) =>
|
||||
resolveWorkspaceContainerTag(explicit, getActiveContainerTag)
|
||||
|
||||
registerAllTools({
|
||||
server,
|
||||
actor,
|
||||
getClient,
|
||||
getSession: () => fetchSession(actor.bearerToken, apiUrl),
|
||||
resolveContainerTag,
|
||||
getActiveContainerTag,
|
||||
setActiveContainerTag,
|
||||
getClientInfo: clientInfoFromContext,
|
||||
errorResult,
|
||||
})
|
||||
|
||||
registerProfileResource(server, getClient, resolveContainerTag)
|
||||
registerContainerTagsResource(server, () => getClient(), resolveContainerTag)
|
||||
registerWidgetResource(server)
|
||||
registerContextPrompt(server, getClient, resolveContainerTag)
|
||||
|
||||
return server
|
||||
}
|
||||
|
|
@ -1,26 +1,17 @@
|
|||
import { z } from "zod"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
|
||||
?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
|
||||
const inputSchema = {
|
||||
const inputSchema = z.object({
|
||||
content: z
|
||||
.string()
|
||||
.max(200000, "Content exceeds maximum length")
|
||||
.describe("The memory content to save or forget"),
|
||||
action: z.enum(["save", "forget"]).optional().default("save"),
|
||||
...containerTagField,
|
||||
}
|
||||
containerTag: optionalContainerTagSchema,
|
||||
})
|
||||
|
||||
deps.server.registerTool(
|
||||
"add_memory",
|
||||
|
|
@ -30,12 +21,7 @@ export function register(deps: ToolDeps) {
|
|||
inputSchema,
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
content: string
|
||||
action?: "save" | "forget"
|
||||
containerTag?: string
|
||||
}
|
||||
async (args) => {
|
||||
try {
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
|
|
|
|||
|
|
@ -1,34 +1,23 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
|
||||
import { appToolMeta } from "../app-metadata"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"fetch-graph-data",
|
||||
{
|
||||
description: "Fetch documents with memories for graph display",
|
||||
inputSchema: {
|
||||
containerTag: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
containerTag: optionalContainerTagSchema,
|
||||
page: z.number().optional().default(1),
|
||||
limit: z.number().optional().default(200),
|
||||
},
|
||||
}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: SUPERMEMORY_RESOURCE_URI,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
containerTag?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
async (args) => {
|
||||
try {
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"guided-save",
|
||||
{
|
||||
title: "Add Memory",
|
||||
description: "Save information to memory with an interactive form.",
|
||||
inputSchema: {
|
||||
inputSchema: z.object({
|
||||
prefill: z.string().optional().describe("Optional content to prefill"),
|
||||
},
|
||||
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
|
||||
}),
|
||||
_meta: appToolMeta(),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const prefill = (args as { prefill?: string }).prefill
|
||||
const { prefill } = args
|
||||
const viewId = crypto.randomUUID()
|
||||
const [activeTag, tags, session] = await Promise.all([
|
||||
deps.getActiveContainerTag(),
|
||||
deps.getClient().listContainerTags(),
|
||||
|
|
@ -33,6 +33,7 @@ export function register(deps: ToolDeps) {
|
|||
|
||||
const sc: ViewMessage = {
|
||||
view: "save",
|
||||
viewId,
|
||||
activeTag,
|
||||
writableTags,
|
||||
prefill,
|
||||
|
|
@ -43,6 +44,7 @@ export function register(deps: ToolDeps) {
|
|||
{ type: "text" as const, text: "Opening memory save form..." },
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { z } from "zod"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
|
|
@ -7,7 +8,7 @@ export function register(deps: ToolDeps) {
|
|||
{
|
||||
description:
|
||||
"List the spaces available to you. Spaces are the workspaces you organize memories into — returns each space's name, identifier, emoji, document/memory counts, and last activity. The list is auto-filtered to spaces you have access to.",
|
||||
inputSchema: {},
|
||||
inputSchema: z.object({}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,11 @@
|
|||
import { z } from "zod"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { formatMemoriesList } from "../format"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
|
||||
?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
|
||||
const inputSchema = {
|
||||
const inputSchema = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
|
|
@ -32,8 +23,8 @@ export function register(deps: ToolDeps) {
|
|||
.describe(
|
||||
"Documents per page; each document groups its extracted memories (default 10, max 50)",
|
||||
),
|
||||
...containerTagField,
|
||||
}
|
||||
containerTag: optionalContainerTagSchema,
|
||||
})
|
||||
|
||||
deps.server.registerTool(
|
||||
"listMemories",
|
||||
|
|
@ -43,12 +34,7 @@ export function register(deps: ToolDeps) {
|
|||
inputSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
page?: number
|
||||
limit?: number
|
||||
containerTag?: string
|
||||
}
|
||||
async (args) => {
|
||||
try {
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appToolMeta } from "../app-metadata"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const inputSchema: Record<string, z.ZodTypeAny> = deps.props?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
const inputSchema = z.object({
|
||||
containerTag: optionalContainerTagSchema,
|
||||
})
|
||||
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"memory-graph",
|
||||
{
|
||||
title: "Memory Graph",
|
||||
|
|
@ -23,12 +18,11 @@ export function register(deps: ToolDeps) {
|
|||
"Visualize the user's memory graph as an interactive force-directed graph.",
|
||||
inputSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
|
||||
_meta: appToolMeta(),
|
||||
},
|
||||
async (rawArgs) => {
|
||||
async (args) => {
|
||||
try {
|
||||
const explicit = (rawArgs as { containerTag?: string }).containerTag
|
||||
const effectiveTag = await deps.resolveContainerTag(explicit)
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,31 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { containerTagSchema } from "../container-tag"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"save-memory",
|
||||
{
|
||||
description: "Save content to memory",
|
||||
inputSchema: {
|
||||
inputSchema: z.object({
|
||||
content: z.string().min(1),
|
||||
containerTag: z.string().min(1),
|
||||
},
|
||||
containerTag: containerTagSchema,
|
||||
viewId: z.string().uuid().optional(),
|
||||
}),
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: SUPERMEMORY_RESOURCE_URI,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as { content: string; containerTag: string }
|
||||
async (args) => {
|
||||
try {
|
||||
const viewId = args.viewId ?? crypto.randomUUID()
|
||||
const client = deps.getClient(args.containerTag)
|
||||
const result = await client.createMemory(args.content)
|
||||
const sc: ViewMessage = {
|
||||
view: "save-success",
|
||||
viewId,
|
||||
id: result.id,
|
||||
containerTag: args.containerTag,
|
||||
}
|
||||
|
|
@ -37,6 +34,7 @@ export function register(deps: ToolDeps) {
|
|||
{ type: "text" as const, text: `Memory saved: ${result.id}` },
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,27 +1,18 @@
|
|||
import { z } from "zod"
|
||||
import { getMemoryText } from "../client"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
|
||||
?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
|
||||
const inputSchema = {
|
||||
const inputSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.max(1000, "Query exceeds maximum length")
|
||||
.describe("The search query to find relevant memories"),
|
||||
includeProfile: z.boolean().optional().default(true),
|
||||
...containerTagField,
|
||||
}
|
||||
containerTag: optionalContainerTagSchema,
|
||||
})
|
||||
|
||||
deps.server.registerTool(
|
||||
"search_memory",
|
||||
|
|
@ -31,12 +22,7 @@ export function register(deps: ToolDeps) {
|
|||
inputSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
query: string
|
||||
includeProfile?: boolean
|
||||
containerTag?: string
|
||||
}
|
||||
async (args) => {
|
||||
try {
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"select-workspace",
|
||||
{
|
||||
title: "Select Workspace",
|
||||
description:
|
||||
"Choose which container tag to work in. Shows available container tags as interactive cards.",
|
||||
inputSchema: {},
|
||||
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
|
||||
inputSchema: z.object({}),
|
||||
_meta: appToolMeta(),
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const viewId = crypto.randomUUID()
|
||||
const client = deps.getClient()
|
||||
const [tags, session, activeTag] = await Promise.all([
|
||||
client.listContainerTags(),
|
||||
|
|
@ -29,6 +30,7 @@ export function register(deps: ToolDeps) {
|
|||
|
||||
const sc: ViewMessage = {
|
||||
view: "picker",
|
||||
viewId,
|
||||
containerTags: tags,
|
||||
activeTag,
|
||||
assignedTags,
|
||||
|
|
@ -42,6 +44,7 @@ export function register(deps: ToolDeps) {
|
|||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,27 +1,24 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { containerTagSchema } from "../container-tag"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"set-active-tag",
|
||||
{
|
||||
description: "Set the active container tag for this session",
|
||||
inputSchema: {
|
||||
containerTag: z.string().min(1),
|
||||
},
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: SUPERMEMORY_RESOURCE_URI,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
description: "Set the active container tag for this account",
|
||||
inputSchema: z.object({
|
||||
containerTag: containerTagSchema,
|
||||
viewId: z.string().uuid().optional(),
|
||||
}),
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
async (args) => {
|
||||
const containerTag = (args as { containerTag: string }).containerTag
|
||||
const { containerTag } = args
|
||||
try {
|
||||
const viewId = args.viewId ?? crypto.randomUUID()
|
||||
const tags = await deps.getClient().listContainerTags()
|
||||
if (!tags.some((tag) => tag.containerTag === containerTag)) {
|
||||
return deps.errorResult(
|
||||
|
|
@ -31,6 +28,7 @@ export function register(deps: ToolDeps) {
|
|||
await deps.setActiveContainerTag(containerTag)
|
||||
const sc: ViewMessage = {
|
||||
view: "confirmation",
|
||||
viewId,
|
||||
containerTag,
|
||||
}
|
||||
return {
|
||||
|
|
@ -41,6 +39,7 @@ export function register(deps: ToolDeps) {
|
|||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import type { Props, SessionInfo } from "../../shared/types"
|
||||
import type { McpServer, ServerContext } from "@modelcontextprotocol/server"
|
||||
import type { SessionInfo } from "../../shared/types"
|
||||
import type { SupermemoryClient } from "../client"
|
||||
import type { ActorContext } from "../types"
|
||||
|
||||
// Dependencies passed to every tool's register() function.
|
||||
// Keep this surface small — tools should read this rather than reach into the agent.
|
||||
export interface ToolDeps {
|
||||
server: McpServer
|
||||
props: Props | undefined
|
||||
actor: ActorContext
|
||||
getClient: (containerTag?: string) => SupermemoryClient
|
||||
getSession: () => Promise<SessionInfo>
|
||||
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
|
||||
getActiveContainerTag: () => Promise<string | undefined>
|
||||
setActiveContainerTag: (containerTag: string) => Promise<void>
|
||||
getClientInfo: () => { name: string; version?: string } | null
|
||||
getMcpSessionId: () => string
|
||||
getClientInfo: (
|
||||
context: ServerContext,
|
||||
) => { name: string; version?: string } | null
|
||||
errorResult: (error: unknown) => {
|
||||
content: { type: "text"; text: string }[]
|
||||
isError: true
|
||||
|
|
|
|||
|
|
@ -1,37 +1,28 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { containerTagSchema } from "../container-tag"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"upload-file-submit",
|
||||
{
|
||||
description: "Submit a file upload",
|
||||
inputSchema: {
|
||||
inputSchema: z.object({
|
||||
fileData: z.string().describe("Base64-encoded file content"),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
containerTag: z.string().min(1),
|
||||
},
|
||||
containerTag: containerTagSchema,
|
||||
viewId: z.string().uuid().optional(),
|
||||
}),
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: SUPERMEMORY_RESOURCE_URI,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
fileData: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
containerTag: string
|
||||
}
|
||||
async (args) => {
|
||||
try {
|
||||
const viewId = args.viewId ?? crypto.randomUUID()
|
||||
const binaryString = atob(args.fileData)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
|
|
@ -48,6 +39,7 @@ export function register(deps: ToolDeps) {
|
|||
|
||||
const sc: ViewMessage = {
|
||||
view: "upload-success",
|
||||
viewId,
|
||||
id: result.id,
|
||||
fileName: args.fileName,
|
||||
containerTag: args.containerTag,
|
||||
|
|
@ -61,6 +53,7 @@ export function register(deps: ToolDeps) {
|
|||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
registerAppTool(
|
||||
deps.server,
|
||||
deps.server.registerTool(
|
||||
"upload-file",
|
||||
{
|
||||
title: "Upload File",
|
||||
description: "Upload a file (PDF, text, image, video) to memory.",
|
||||
inputSchema: {},
|
||||
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
|
||||
inputSchema: z.object({}),
|
||||
_meta: appToolMeta(),
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const viewId = crypto.randomUUID()
|
||||
const [activeTag, tags, session] = await Promise.all([
|
||||
deps.getActiveContainerTag(),
|
||||
deps.getClient().listContainerTags(),
|
||||
|
|
@ -29,6 +30,7 @@ export function register(deps: ToolDeps) {
|
|||
|
||||
const sc: ViewMessage = {
|
||||
view: "upload",
|
||||
viewId,
|
||||
activeTag,
|
||||
writableTags,
|
||||
}
|
||||
|
|
@ -38,6 +40,7 @@ export function register(deps: ToolDeps) {
|
|||
{ type: "text" as const, text: "Opening file upload form..." },
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { z } from "zod"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
|
|
@ -6,10 +7,10 @@ export function register(deps: ToolDeps) {
|
|||
"whoAmI",
|
||||
{
|
||||
description: "Get current user info, role, and workspace context",
|
||||
inputSchema: {},
|
||||
inputSchema: z.object({}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async () => {
|
||||
async (_args, context) => {
|
||||
try {
|
||||
const [session, activeTag] = await Promise.all([
|
||||
deps.getSession(),
|
||||
|
|
@ -31,8 +32,8 @@ export function register(deps: ToolDeps) {
|
|||
? session.containerTags
|
||||
: null,
|
||||
scope: session.scope,
|
||||
client: deps.getClientInfo(),
|
||||
sessionId: deps.getMcpSessionId(),
|
||||
client: deps.getClientInfo(context),
|
||||
sessionId: context.sessionId ?? null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
15
apps/mcp/src/server/types.ts
Normal file
15
apps/mcp/src/server/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { WorkspaceState } from "./workspace-state"
|
||||
|
||||
export interface ActorContext {
|
||||
userId: string
|
||||
organizationId: string
|
||||
bearerToken: string
|
||||
oauthClientId?: string
|
||||
}
|
||||
|
||||
export interface ServerEnv {
|
||||
WORKSPACE_STATE: DurableObjectNamespace<WorkspaceState>
|
||||
API_URL?: string
|
||||
MCP_RESOURCE?: string
|
||||
ALLOWED_MCP_ORIGIN_HOSTNAMES?: string
|
||||
}
|
||||
64
apps/mcp/src/server/workspace-presentation.test.ts
Normal file
64
apps/mcp/src/server/workspace-presentation.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import type { ContainerTag } from "../shared/types"
|
||||
import {
|
||||
compactDescription,
|
||||
formatFactSection,
|
||||
formatWorkspaceRow,
|
||||
sortWorkspaces,
|
||||
} from "./workspace-presentation"
|
||||
|
||||
const workspace = (
|
||||
containerTag: string,
|
||||
lastActivityAt: string | null,
|
||||
): ContainerTag => ({
|
||||
id: containerTag,
|
||||
name: `Workspace ${containerTag}`,
|
||||
containerTag,
|
||||
description: "A compact workspace description.",
|
||||
visibility: "private",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
isExperimental: false,
|
||||
isNova: false,
|
||||
documentCount: 2,
|
||||
memoryCount: 3,
|
||||
lastActivityAt,
|
||||
})
|
||||
|
||||
describe("workspace presentation", () => {
|
||||
it("keeps the active workspace first, then sorts by activity", () => {
|
||||
const sorted = sortWorkspaces(
|
||||
[
|
||||
workspace("older", "2026-01-01T00:00:00.000Z"),
|
||||
workspace("active", "2025-01-01T00:00:00.000Z"),
|
||||
workspace("newer", "2026-02-01T00:00:00.000Z"),
|
||||
],
|
||||
"active",
|
||||
)
|
||||
|
||||
expect(sorted.map((item) => item.containerTag)).toEqual([
|
||||
"active",
|
||||
"newer",
|
||||
"older",
|
||||
])
|
||||
})
|
||||
|
||||
it("formats compact rows without internal database IDs", () => {
|
||||
const row = formatWorkspaceRow(
|
||||
workspace("project-key", "2026-07-29T19:44:28.177Z"),
|
||||
"project-key",
|
||||
)
|
||||
|
||||
expect(row).toContain("[project-key] · Active")
|
||||
expect(row).toContain("2 documents · 3 memories")
|
||||
expect(row).toContain("Last active Jul 29, 2026")
|
||||
expect(row).not.toContain('"id"')
|
||||
})
|
||||
|
||||
it("caps descriptions and fact lists", () => {
|
||||
expect(compactDescription("A".repeat(30), 12)).toBe("AAAAAAAAA...")
|
||||
expect(
|
||||
formatFactSection("Recent Context", ["one", "two", "three"], 2),
|
||||
).toEqual(["## Recent Context", "- one", "- two", "- +1 more"])
|
||||
})
|
||||
})
|
||||
104
apps/mcp/src/server/workspace-presentation.ts
Normal file
104
apps/mcp/src/server/workspace-presentation.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { ContainerTag } from "../shared/types"
|
||||
|
||||
const DEFAULT_DESCRIPTION_LIMIT = 160
|
||||
|
||||
const plural = (count: number, singular: string, pluralForm: string) =>
|
||||
`${count} ${count === 1 ? singular : pluralForm}`
|
||||
|
||||
export function workspaceDisplayName(
|
||||
workspace: ContainerTag | undefined,
|
||||
key: string,
|
||||
): string {
|
||||
return workspace?.name || key
|
||||
}
|
||||
|
||||
export function compactDescription(
|
||||
description: string | null | undefined,
|
||||
limit = DEFAULT_DESCRIPTION_LIMIT,
|
||||
): string | undefined {
|
||||
const compact = description?.replace(/\s+/g, " ").trim()
|
||||
if (!compact) return undefined
|
||||
if (compact.length <= limit) return compact
|
||||
const candidate = compact.slice(0, Math.max(0, limit - 3)).trimEnd()
|
||||
const lastSpace = candidate.lastIndexOf(" ")
|
||||
const boundary =
|
||||
lastSpace >= Math.floor(limit * 0.6) ? lastSpace : candidate.length
|
||||
return `${candidate.slice(0, boundary)}...`
|
||||
}
|
||||
|
||||
export function formatActivityDate(value: string | null): string | undefined {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function workspaceMetadata(workspace: ContainerTag): string {
|
||||
const lastActivity = formatActivityDate(workspace.lastActivityAt)
|
||||
const fields = [
|
||||
workspace.visibility
|
||||
? `${workspace.visibility.charAt(0).toUpperCase()}${workspace.visibility.slice(1)}`
|
||||
: undefined,
|
||||
plural(workspace.documentCount, "document", "documents"),
|
||||
plural(workspace.memoryCount, "memory", "memories"),
|
||||
lastActivity ? `Last active ${lastActivity}` : undefined,
|
||||
]
|
||||
|
||||
return fields.filter(Boolean).join(" · ")
|
||||
}
|
||||
|
||||
export function formatWorkspaceRow(
|
||||
workspace: ContainerTag,
|
||||
activeKey: string,
|
||||
descriptionLimit = DEFAULT_DESCRIPTION_LIMIT,
|
||||
): string {
|
||||
const active = workspace.containerTag === activeKey ? " · Active" : ""
|
||||
const metadata = workspaceMetadata(workspace)
|
||||
const description = compactDescription(
|
||||
workspace.description,
|
||||
descriptionLimit,
|
||||
)
|
||||
const firstLine =
|
||||
`- ${workspaceDisplayName(workspace, workspace.containerTag)} ` +
|
||||
`[${workspace.containerTag}]${active}${metadata ? ` · ${metadata}` : ""}`
|
||||
|
||||
return description ? `${firstLine}\n ${description}` : firstLine
|
||||
}
|
||||
|
||||
export function sortWorkspaces(
|
||||
workspaces: ContainerTag[],
|
||||
activeKey: string,
|
||||
): ContainerTag[] {
|
||||
return [...workspaces].sort((left, right) => {
|
||||
if (left.containerTag === activeKey) return -1
|
||||
if (right.containerTag === activeKey) return 1
|
||||
|
||||
const leftTime = left.lastActivityAt
|
||||
? new Date(left.lastActivityAt).getTime()
|
||||
: 0
|
||||
const rightTime = right.lastActivityAt
|
||||
? new Date(right.lastActivityAt).getTime()
|
||||
: 0
|
||||
return rightTime - leftTime
|
||||
})
|
||||
}
|
||||
|
||||
export function formatFactSection(
|
||||
title: string,
|
||||
facts: string[],
|
||||
limit: number,
|
||||
): string[] {
|
||||
if (facts.length === 0) return []
|
||||
|
||||
const shown = facts.slice(0, limit)
|
||||
const lines = [`## ${title}`, ...shown.map((fact) => `- ${fact}`)]
|
||||
const remaining = facts.length - shown.length
|
||||
if (remaining > 0) lines.push(`- +${remaining} more`)
|
||||
return lines
|
||||
}
|
||||
15
apps/mcp/src/server/workspace-state.ts
Normal file
15
apps/mcp/src/server/workspace-state.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { DurableObject } from "cloudflare:workers"
|
||||
import { containerTagSchema } from "./container-tag"
|
||||
|
||||
const ACTIVE_CONTAINER_TAG_KEY = "activeContainerTag"
|
||||
|
||||
export class WorkspaceState extends DurableObject {
|
||||
async getActiveContainerTag(): Promise<string | undefined> {
|
||||
return this.ctx.storage.get<string>(ACTIVE_CONTAINER_TAG_KEY)
|
||||
}
|
||||
|
||||
async setActiveContainerTag(containerTag: string): Promise<void> {
|
||||
const validatedTag = containerTagSchema.parse(containerTag)
|
||||
await this.ctx.storage.put(ACTIVE_CONTAINER_TAG_KEY, validatedTag)
|
||||
}
|
||||
}
|
||||
36
apps/mcp/src/server/workspace.test.ts
Normal file
36
apps/mcp/src/server/workspace.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it, vi } from "vitest"
|
||||
import { resolveContainerTag, workspaceStateName } from "./workspace"
|
||||
|
||||
describe("workspace application state", () => {
|
||||
it("keys active state by organization and user without collisions", () => {
|
||||
expect(
|
||||
workspaceStateName({
|
||||
organizationId: "org:one",
|
||||
userId: "user:two",
|
||||
}),
|
||||
).not.toBe(
|
||||
workspaceStateName({
|
||||
organizationId: "org",
|
||||
userId: "one:user:two",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("uses an explicit tool argument without reading active state", async () => {
|
||||
const getActive = vi.fn().mockResolvedValue("active")
|
||||
|
||||
await expect(resolveContainerTag("explicit", getActive)).resolves.toBe(
|
||||
"explicit",
|
||||
)
|
||||
expect(getActive).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("falls back to durable active state and then the client default", async () => {
|
||||
await expect(
|
||||
resolveContainerTag(undefined, vi.fn().mockResolvedValue("active")),
|
||||
).resolves.toBe("active")
|
||||
await expect(
|
||||
resolveContainerTag(undefined, vi.fn().mockResolvedValue(undefined)),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
14
apps/mcp/src/server/workspace.ts
Normal file
14
apps/mcp/src/server/workspace.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ActorContext } from "./types"
|
||||
|
||||
export function workspaceStateName(
|
||||
actor: Pick<ActorContext, "organizationId" | "userId">,
|
||||
): string {
|
||||
return `workspace:${JSON.stringify([actor.organizationId, actor.userId])}`
|
||||
}
|
||||
|
||||
export async function resolveContainerTag(
|
||||
explicit: string | undefined,
|
||||
getActiveContainerTag: () => Promise<string | undefined>,
|
||||
): Promise<string | undefined> {
|
||||
return explicit ?? (await getActiveContainerTag())
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ export interface ContainerTag {
|
|||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
description?: string | null
|
||||
visibility?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental: boolean
|
||||
|
|
@ -81,7 +83,7 @@ export interface DocumentsApiResponse {
|
|||
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
|
||||
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
|
||||
// Adding a new view here is a compile error in App.tsx until the case is handled.
|
||||
export type ViewMessage =
|
||||
type ViewMessagePayload =
|
||||
| {
|
||||
view: "picker"
|
||||
containerTags: ContainerTag[]
|
||||
|
|
@ -114,15 +116,18 @@ export type ViewMessage =
|
|||
containerTag?: string
|
||||
}
|
||||
|
||||
export type ViewName = ViewMessage["view"]
|
||||
|
||||
// Auth context passed from the OAuth/API-key middleware into the McpAgent via ctx.props.
|
||||
export type Props = {
|
||||
userId: string
|
||||
organizationId?: string
|
||||
bearerToken: string
|
||||
containerTag?: string
|
||||
export type ViewMessage = ViewMessagePayload & {
|
||||
/**
|
||||
* Stable identity for one rendered widget instance.
|
||||
*
|
||||
* The host may remount the iframe when a conversation is revisited. The
|
||||
* widget uses this id to restore a completed local view without treating UI
|
||||
* state as the source of truth for the underlying Supermemory write.
|
||||
*/
|
||||
viewId?: string
|
||||
}
|
||||
|
||||
export type ViewName = ViewMessage["view"]
|
||||
|
||||
// Hosts cache MCP UI resources by URI, so bump this when shipping a new widget bundle.
|
||||
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v2.html"
|
||||
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v3.html"
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ function renderView(
|
|||
containerTags={msg.containerTags}
|
||||
onAdvance={setView}
|
||||
onError={setError}
|
||||
viewId={msg.viewId}
|
||||
/>
|
||||
)
|
||||
case "save":
|
||||
|
|
@ -114,6 +115,7 @@ function renderView(
|
|||
onAdvance={setView}
|
||||
onError={setError}
|
||||
prefill={msg.prefill}
|
||||
viewId={msg.viewId}
|
||||
writableTags={msg.writableTags}
|
||||
/>
|
||||
)
|
||||
|
|
@ -123,6 +125,7 @@ function renderView(
|
|||
activeTag={msg.activeTag}
|
||||
onAdvance={setView}
|
||||
onError={setError}
|
||||
viewId={msg.viewId}
|
||||
writableTags={msg.writableTags}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|||
import { useMemo } from "react"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { app } from "../lib/app"
|
||||
import {
|
||||
handoffToModel as performModelHandoff,
|
||||
type ModelHandoffRequest,
|
||||
} from "../lib/modelHandoff"
|
||||
|
||||
export interface ToolCallResult<T = unknown> {
|
||||
ok: boolean
|
||||
|
|
@ -59,6 +63,15 @@ export function useApp() {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Publish a silent state snapshot, then explicitly return control to
|
||||
* the conversation agent. The message is still attempted when a host
|
||||
* rejects or drops model-context updates.
|
||||
*/
|
||||
handoffToModel(request: ModelHandoffRequest) {
|
||||
return performModelHandoff(app, request)
|
||||
},
|
||||
|
||||
/** Send a structured log line to the host. */
|
||||
log(level: "debug" | "info" | "warning" | "error", message: string) {
|
||||
return app.sendLog({ level, data: message })
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|||
import { useEffect, useState } from "react"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { app } from "../lib/app"
|
||||
import { loadViewCheckpoint, saveViewCheckpoint } from "../lib/viewCheckpoint"
|
||||
|
||||
function safeLog(
|
||||
level: "debug" | "info" | "warning" | "error",
|
||||
|
|
@ -37,7 +38,12 @@ export function useViewState(): {
|
|||
setView: (msg: ViewMessage) => void
|
||||
setError: (message: string) => void
|
||||
} {
|
||||
const [state, setState] = useState<ViewState>({ kind: "loading" })
|
||||
const [state, setState] = useState<ViewState>(() => {
|
||||
const checkpoint = loadViewCheckpoint()
|
||||
return checkpoint
|
||||
? { kind: "view", message: checkpoint }
|
||||
: { kind: "loading" }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
app.ontoolinput = (input: unknown) => {
|
||||
|
|
@ -63,7 +69,8 @@ export function useViewState(): {
|
|||
if ("view" in sc) {
|
||||
const msg = sc as ViewMessage
|
||||
safeLog("info", `[host] ontoolresult: view=${msg.view}`)
|
||||
setState({ kind: "view", message: msg })
|
||||
const checkpoint = loadViewCheckpoint(msg.viewId)
|
||||
setState({ kind: "view", message: checkpoint ?? msg })
|
||||
return
|
||||
}
|
||||
safeLog("warning", "[host] ontoolresult: structuredContent without view")
|
||||
|
|
@ -85,7 +92,10 @@ export function useViewState(): {
|
|||
|
||||
return {
|
||||
state,
|
||||
setView: (msg) => setState({ kind: "view", message: msg }),
|
||||
setView: (msg) => {
|
||||
saveViewCheckpoint(msg)
|
||||
setState({ kind: "view", message: msg })
|
||||
},
|
||||
setError: (message) => setState({ kind: "error", message }),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
apps/mcp/src/widget/lib/modelHandoff.test.ts
Normal file
107
apps/mcp/src/widget/lib/modelHandoff.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it, vi } from "vitest"
|
||||
import { handoffToModel } from "./modelHandoff"
|
||||
|
||||
function createApp(overrides?: {
|
||||
updateModelContext?: () => Promise<unknown>
|
||||
sendMessage?: () => Promise<{ isError?: boolean }>
|
||||
}) {
|
||||
return {
|
||||
updateModelContext:
|
||||
overrides?.updateModelContext ?? vi.fn(async () => ({})),
|
||||
sendMessage: overrides?.sendMessage ?? vi.fn(async () => ({})),
|
||||
}
|
||||
}
|
||||
|
||||
const request = {
|
||||
context: "Detailed state",
|
||||
message: "Continue from the widget action",
|
||||
structuredContent: { action: "saved" },
|
||||
}
|
||||
|
||||
describe("handoffToModel", () => {
|
||||
it("updates context before sending the portable conversation message", async () => {
|
||||
const order: string[] = []
|
||||
const app = createApp({
|
||||
updateModelContext: vi.fn(async () => {
|
||||
order.push("context")
|
||||
}),
|
||||
sendMessage: vi.fn(async () => {
|
||||
order.push("message")
|
||||
return {}
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await handoffToModel(app, request, undefined)
|
||||
|
||||
expect(order).toEqual(["context", "message"])
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
contextUpdate: { ok: true },
|
||||
conversationMessage: { ok: true },
|
||||
})
|
||||
expect(app.updateModelContext).toHaveBeenCalledWith({
|
||||
content: [{ type: "text", text: request.context }],
|
||||
structuredContent: request.structuredContent,
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers ChatGPT's follow-up helper when available", async () => {
|
||||
const app = createApp()
|
||||
const sendFollowUpMessage = vi.fn(async () => undefined)
|
||||
|
||||
const result = await handoffToModel(app, request, {
|
||||
sendFollowUpMessage,
|
||||
})
|
||||
|
||||
expect(result.conversationMessage).toEqual({ ok: true })
|
||||
expect(sendFollowUpMessage).toHaveBeenCalledWith({
|
||||
prompt: request.message,
|
||||
scrollToBottom: true,
|
||||
})
|
||||
expect(app.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("falls back to the portable message when ChatGPT's helper fails", async () => {
|
||||
const app = createApp()
|
||||
|
||||
const result = await handoffToModel(app, request, {
|
||||
sendFollowUpMessage: vi.fn(async () => {
|
||||
throw new Error("unavailable")
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result.conversationMessage).toEqual({ ok: true })
|
||||
expect(app.sendMessage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("still sends the conversation message when context publication fails", async () => {
|
||||
const app = createApp({
|
||||
updateModelContext: vi.fn(async () => {
|
||||
throw new Error("unsupported")
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await handoffToModel(app, request, undefined)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.contextUpdate).toMatchObject({
|
||||
ok: false,
|
||||
error: "Error: unsupported",
|
||||
})
|
||||
expect(app.sendMessage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("reports a rejected conversation message as the failed handoff", async () => {
|
||||
const app = createApp({
|
||||
sendMessage: vi.fn(async () => ({ isError: true })),
|
||||
})
|
||||
|
||||
const result = await handoffToModel(app, request, undefined)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.conversationMessage).toEqual({
|
||||
ok: false,
|
||||
error: "Host rejected the MCP Apps message",
|
||||
})
|
||||
})
|
||||
})
|
||||
115
apps/mcp/src/widget/lib/modelHandoff.ts
Normal file
115
apps/mcp/src/widget/lib/modelHandoff.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { ChatGptHostApi } from "./openaiHost"
|
||||
import { getChatGptHostApi } from "./openaiHost"
|
||||
|
||||
interface ModelHandoffApp {
|
||||
updateModelContext(params: {
|
||||
content?: Array<{ type: "text"; text: string }>
|
||||
structuredContent?: Record<string, unknown>
|
||||
}): Promise<unknown>
|
||||
sendMessage(params: {
|
||||
role: "user"
|
||||
content: Array<{ type: "text"; text: string }>
|
||||
}): Promise<{ isError?: boolean }>
|
||||
}
|
||||
|
||||
export interface HandoffStepResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ModelHandoffResult {
|
||||
ok: boolean
|
||||
contextUpdate: HandoffStepResult
|
||||
conversationMessage: HandoffStepResult
|
||||
}
|
||||
|
||||
export interface ModelHandoffRequest {
|
||||
context: string
|
||||
message: string
|
||||
structuredContent?: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function updateContext(
|
||||
app: ModelHandoffApp,
|
||||
request: ModelHandoffRequest,
|
||||
): Promise<HandoffStepResult> {
|
||||
try {
|
||||
await app.updateModelContext({
|
||||
content: [{ type: "text", text: request.context }],
|
||||
structuredContent: request.structuredContent,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendConversationMessage(
|
||||
app: ModelHandoffApp,
|
||||
message: string,
|
||||
chatGptHost: ChatGptHostApi | undefined,
|
||||
): Promise<HandoffStepResult> {
|
||||
let chatGptError: string | undefined
|
||||
|
||||
if (chatGptHost?.sendFollowUpMessage) {
|
||||
try {
|
||||
await chatGptHost.sendFollowUpMessage({
|
||||
prompt: message,
|
||||
scrollToBottom: true,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
// Match OpenAI's own example: fall back to the portable MCP Apps
|
||||
// message method when the ChatGPT-specific helper rejects.
|
||||
chatGptError = String(error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await app.sendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message }],
|
||||
})
|
||||
if (result.isError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: chatGptError
|
||||
? `ChatGPT follow-up failed (${chatGptError}); host rejected the MCP Apps message`
|
||||
: "Host rejected the MCP Apps message",
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const mcpError = String(error)
|
||||
return {
|
||||
ok: false,
|
||||
error: chatGptError
|
||||
? `ChatGPT follow-up failed (${chatGptError}); MCP Apps message failed (${mcpError})`
|
||||
: mcpError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the silent context snapshot and the explicit conversation turn as two
|
||||
* separate operations. An accepted context update is not proof that a host
|
||||
* attached it to a model turn, so the message is always attempted.
|
||||
*/
|
||||
export async function handoffToModel(
|
||||
app: ModelHandoffApp,
|
||||
request: ModelHandoffRequest,
|
||||
chatGptHost: ChatGptHostApi | undefined = getChatGptHostApi(),
|
||||
): Promise<ModelHandoffResult> {
|
||||
const contextUpdate = await updateContext(app, request)
|
||||
const conversationMessage = await sendConversationMessage(
|
||||
app,
|
||||
request.message,
|
||||
chatGptHost,
|
||||
)
|
||||
|
||||
return {
|
||||
ok: conversationMessage.ok,
|
||||
contextUpdate,
|
||||
conversationMessage,
|
||||
}
|
||||
}
|
||||
18
apps/mcp/src/widget/lib/openaiHost.ts
Normal file
18
apps/mcp/src/widget/lib/openaiHost.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface ChatGptHostApi {
|
||||
sendFollowUpMessage?: (args: {
|
||||
prompt: string
|
||||
scrollToBottom?: boolean
|
||||
}) => Promise<unknown>
|
||||
setWidgetState?: (state: unknown) => unknown
|
||||
widgetState?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional ChatGPT host extensions. Shared MCP Apps methods remain the
|
||||
* cross-host fallback; this bridge is used where ChatGPT provides a stronger
|
||||
* follow-up or widget-state primitive.
|
||||
*/
|
||||
export function getChatGptHostApi(): ChatGptHostApi | undefined {
|
||||
if (typeof window === "undefined") return undefined
|
||||
return (window as Window & { openai?: ChatGptHostApi }).openai
|
||||
}
|
||||
105
apps/mcp/src/widget/lib/viewCheckpoint.test.ts
Normal file
105
apps/mcp/src/widget/lib/viewCheckpoint.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { loadViewCheckpoint, saveViewCheckpoint } from "./viewCheckpoint"
|
||||
|
||||
function createStorage() {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
values.delete(key)
|
||||
}),
|
||||
clear: vi.fn(() => values.clear()),
|
||||
key: vi.fn(() => null),
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("view checkpoints", () => {
|
||||
it("restores a completed view from localStorage by stable view id", () => {
|
||||
const storage = createStorage()
|
||||
vi.stubGlobal("localStorage", storage)
|
||||
const view: ViewMessage = {
|
||||
view: "save-success",
|
||||
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
|
||||
id: "memory-123",
|
||||
containerTag: "model_test",
|
||||
}
|
||||
|
||||
saveViewCheckpoint(view)
|
||||
|
||||
expect(loadViewCheckpoint(view.viewId)).toEqual(view)
|
||||
})
|
||||
|
||||
it("does not persist non-terminal form views", () => {
|
||||
const storage = createStorage()
|
||||
vi.stubGlobal("localStorage", storage)
|
||||
const view: ViewMessage = {
|
||||
view: "save",
|
||||
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
|
||||
writableTags: ["model_test"],
|
||||
}
|
||||
|
||||
saveViewCheckpoint(view)
|
||||
|
||||
expect(storage.setItem).not.toHaveBeenCalled()
|
||||
expect(loadViewCheckpoint(view.viewId)).toBeNull()
|
||||
})
|
||||
|
||||
it("mirrors compact state into ChatGPT widget state", () => {
|
||||
const storage = createStorage()
|
||||
const setWidgetState = vi.fn()
|
||||
vi.stubGlobal("localStorage", storage)
|
||||
vi.stubGlobal("window", {
|
||||
openai: {
|
||||
widgetState: {
|
||||
privateContent: { existing: true },
|
||||
},
|
||||
setWidgetState,
|
||||
},
|
||||
})
|
||||
const view: ViewMessage = {
|
||||
view: "confirmation",
|
||||
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
|
||||
containerTag: "model_test",
|
||||
}
|
||||
|
||||
saveViewCheckpoint(view)
|
||||
|
||||
expect(setWidgetState).toHaveBeenCalledWith({
|
||||
modelContent: 'Supermemory active workspace is now "model_test".',
|
||||
privateContent: {
|
||||
existing: true,
|
||||
supermemoryView: view,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("can restore from ChatGPT widget state before a tool result is replayed", () => {
|
||||
const view: ViewMessage = {
|
||||
view: "upload-success",
|
||||
viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616",
|
||||
id: "document-123",
|
||||
fileName: "notes.txt",
|
||||
containerTag: "model_test",
|
||||
}
|
||||
vi.stubGlobal("window", {
|
||||
openai: {
|
||||
widgetState: {
|
||||
privateContent: { supermemoryView: view },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(loadViewCheckpoint()).toEqual(view)
|
||||
})
|
||||
})
|
||||
143
apps/mcp/src/widget/lib/viewCheckpoint.ts
Normal file
143
apps/mcp/src/widget/lib/viewCheckpoint.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import type { ViewMessage, ViewName } from "../../shared/types"
|
||||
import { getChatGptHostApi } from "./openaiHost"
|
||||
|
||||
const CHECKPOINT_VERSION = 1
|
||||
const STORAGE_PREFIX = "supermemory:mcp:view:"
|
||||
const CHECKPOINTABLE_VIEWS = new Set<ViewName>([
|
||||
"confirmation",
|
||||
"save-success",
|
||||
"upload-success",
|
||||
])
|
||||
const VIEW_NAMES = new Set<ViewName>([
|
||||
"picker",
|
||||
"confirmation",
|
||||
"save",
|
||||
"save-success",
|
||||
"upload",
|
||||
"upload-success",
|
||||
"graph",
|
||||
])
|
||||
|
||||
interface CheckpointEnvelope {
|
||||
version: number
|
||||
view: ViewMessage
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function isViewMessage(value: unknown): value is ViewMessage {
|
||||
if (!isRecord(value) || typeof value.view !== "string") return false
|
||||
if (!VIEW_NAMES.has(value.view as ViewName)) return false
|
||||
return value.viewId === undefined || typeof value.viewId === "string"
|
||||
}
|
||||
|
||||
function checkpointKey(viewId: string): string {
|
||||
return `${STORAGE_PREFIX}${viewId}`
|
||||
}
|
||||
|
||||
function getStorage(): Storage | undefined {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? undefined : localStorage
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function modelContentForView(view: ViewMessage): string {
|
||||
switch (view.view) {
|
||||
case "confirmation":
|
||||
return `Supermemory active workspace is now "${view.containerTag}".`
|
||||
case "save-success":
|
||||
return `A memory was saved to Supermemory workspace "${view.containerTag}" with memory ID "${view.id}".`
|
||||
case "upload-success":
|
||||
return `"${view.fileName}" was uploaded to Supermemory workspace "${view.containerTag}" with document ID "${view.id}".`
|
||||
default:
|
||||
return "Supermemory widget state updated."
|
||||
}
|
||||
}
|
||||
|
||||
function readHostCheckpoint(): ViewMessage | null {
|
||||
const state = getChatGptHostApi()?.widgetState
|
||||
if (!isRecord(state) || !isRecord(state.privateContent)) return null
|
||||
const checkpoint = state.privateContent.supermemoryView
|
||||
return isViewMessage(checkpoint) ? checkpoint : null
|
||||
}
|
||||
|
||||
function readLocalCheckpoint(viewId: string): ViewMessage | null {
|
||||
const storage = getStorage()
|
||||
if (!storage) return null
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(checkpointKey(viewId))
|
||||
if (!raw) return null
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isRecord(parsed) || parsed.version !== CHECKPOINT_VERSION) return null
|
||||
return isViewMessage(parsed.view) ? parsed.view : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest completed presentation state for this widget instance.
|
||||
* Business data remains authoritative on the server.
|
||||
*/
|
||||
export function loadViewCheckpoint(viewId?: string): ViewMessage | null {
|
||||
const hostView = readHostCheckpoint()
|
||||
if (hostView && (!viewId || hostView.viewId === viewId)) {
|
||||
return hostView
|
||||
}
|
||||
|
||||
if (!viewId) return null
|
||||
const localView = readLocalCheckpoint(viewId)
|
||||
return localView?.viewId === viewId ? localView : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Save only compact terminal views. Forms and graph payloads can be large and
|
||||
* should always be reconstructed from server tool results.
|
||||
*/
|
||||
export function saveViewCheckpoint(view: ViewMessage): void {
|
||||
if (!view.viewId || !CHECKPOINTABLE_VIEWS.has(view.view)) return
|
||||
|
||||
const envelope: CheckpointEnvelope = {
|
||||
version: CHECKPOINT_VERSION,
|
||||
view,
|
||||
}
|
||||
const storage = getStorage()
|
||||
if (storage) {
|
||||
try {
|
||||
storage.setItem(checkpointKey(view.viewId), JSON.stringify(envelope))
|
||||
} catch {
|
||||
// Sandboxed hosts may disable or quota-limit localStorage.
|
||||
}
|
||||
}
|
||||
|
||||
const chatGptHost = getChatGptHostApi()
|
||||
if (!chatGptHost?.setWidgetState) return
|
||||
|
||||
const currentState = isRecord(chatGptHost.widgetState)
|
||||
? chatGptHost.widgetState
|
||||
: {}
|
||||
const currentPrivate = isRecord(currentState.privateContent)
|
||||
? currentState.privateContent
|
||||
: {}
|
||||
|
||||
try {
|
||||
const stateResult = chatGptHost.setWidgetState({
|
||||
...currentState,
|
||||
modelContent: modelContentForView(view),
|
||||
privateContent: {
|
||||
...currentPrivate,
|
||||
supermemoryView: view,
|
||||
},
|
||||
})
|
||||
void Promise.resolve(stateResult).catch(() => {
|
||||
// The local checkpoint remains available if the async host write fails.
|
||||
})
|
||||
} catch {
|
||||
// The local checkpoint still covers hosts without widget-state support.
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ interface Props {
|
|||
assignedTags?: ContainerTagAccess[] | null
|
||||
onAdvance: (msg: ViewMessage) => void
|
||||
onError: (message: string) => void
|
||||
viewId?: string
|
||||
}
|
||||
|
||||
// Show the search box once the list is long enough to need it.
|
||||
|
|
@ -28,8 +29,9 @@ export function Picker({
|
|||
assignedTags,
|
||||
onAdvance,
|
||||
onError,
|
||||
viewId,
|
||||
}: Props) {
|
||||
const { callTool, updateModelContext } = useApp()
|
||||
const { callTool, handoffToModel } = useApp()
|
||||
const log = useLog()
|
||||
const [pending, setPending] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState("")
|
||||
|
|
@ -49,6 +51,7 @@ export function Picker({
|
|||
setPending(containerTag)
|
||||
const result = await callTool<ViewMessage>("set-active-tag", {
|
||||
containerTag,
|
||||
viewId,
|
||||
})
|
||||
setPending(null)
|
||||
if (!result.ok || !result.data) {
|
||||
|
|
@ -57,13 +60,26 @@ export function Picker({
|
|||
return
|
||||
}
|
||||
onAdvance(result.data)
|
||||
const contextUpdate = await updateModelContext(
|
||||
`Supermemory workspace selection changed. Active workspace: "${containerTag}". Use it for future Supermemory actions until another workspace is selected.`,
|
||||
)
|
||||
if (!contextUpdate.ok) {
|
||||
const handoff = await handoffToModel({
|
||||
context: `Supermemory workspace selection changed. Active workspace: "${containerTag}". Use it for future Supermemory actions until another workspace is selected.`,
|
||||
message: `I selected "${containerTag}" as my active Supermemory workspace. Use this workspace for future Supermemory actions until I select another one.`,
|
||||
structuredContent: {
|
||||
supermemory: {
|
||||
action: "workspace-selected",
|
||||
activeWorkspace: containerTag,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!handoff.contextUpdate.ok) {
|
||||
log(
|
||||
"warning",
|
||||
`[picker] model context update failed: ${contextUpdate.error}`,
|
||||
`[picker] model context update failed: ${handoff.contextUpdate.error}`,
|
||||
)
|
||||
}
|
||||
if (!handoff.conversationMessage.ok) {
|
||||
log(
|
||||
"warning",
|
||||
`[picker] agent handoff failed: ${handoff.conversationMessage.error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface Props {
|
|||
prefill?: string
|
||||
onAdvance: (msg: ViewMessage) => void
|
||||
onError: (message: string) => void
|
||||
viewId?: string
|
||||
}
|
||||
|
||||
export function Save({
|
||||
|
|
@ -27,8 +28,9 @@ export function Save({
|
|||
prefill,
|
||||
onAdvance,
|
||||
onError,
|
||||
viewId,
|
||||
}: Props) {
|
||||
const { callTool, updateModelContext } = useApp()
|
||||
const { callTool, handoffToModel } = useApp()
|
||||
const log = useLog()
|
||||
const [content, setContent] = useState(prefill ?? "")
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(
|
||||
|
|
@ -62,6 +64,7 @@ export function Save({
|
|||
const result = await callTool<ViewMessage>("save-memory", {
|
||||
content: trimmed,
|
||||
containerTag: selectedTag,
|
||||
viewId,
|
||||
})
|
||||
setSaving(false)
|
||||
if (!result.ok || !result.data) {
|
||||
|
|
@ -72,13 +75,28 @@ export function Save({
|
|||
const memoryId =
|
||||
result.data.view === "save-success" ? result.data.id : undefined
|
||||
onAdvance(result.data)
|
||||
const contextUpdate = await updateModelContext(
|
||||
`Supermemory widget action completed. A memory was saved to workspace "${selectedTag}"${memoryId ? ` with memory ID "${memoryId}"` : ""}. It is already saved; do not save it again.`,
|
||||
)
|
||||
if (!contextUpdate.ok) {
|
||||
const handoff = await handoffToModel({
|
||||
context: `Supermemory widget action completed. A memory was saved to workspace "${selectedTag}"${memoryId ? ` with memory ID "${memoryId}"` : ""}. Saved content:\n\n${trimmed}\n\nIt is already saved; do not save it again.`,
|
||||
message: `I used the Supermemory widget to save a memory to workspace "${selectedTag}"${memoryId ? ` (memory ID: ${memoryId})` : ""}. The memory is already saved; do not save it again.`,
|
||||
structuredContent: {
|
||||
supermemory: {
|
||||
action: "memory-saved",
|
||||
activeWorkspace: selectedTag,
|
||||
memoryId,
|
||||
content: trimmed,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!handoff.contextUpdate.ok) {
|
||||
log(
|
||||
"warning",
|
||||
`[save] model context update failed: ${contextUpdate.error}`,
|
||||
`[save] model context update failed: ${handoff.contextUpdate.error}`,
|
||||
)
|
||||
}
|
||||
if (!handoff.conversationMessage.ok) {
|
||||
log(
|
||||
"warning",
|
||||
`[save] agent handoff failed: ${handoff.conversationMessage.error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface Props {
|
|||
writableTags: string[]
|
||||
onAdvance: (msg: ViewMessage) => void
|
||||
onError: (message: string) => void
|
||||
viewId?: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
|
|
@ -30,8 +31,14 @@ function formatFileSize(bytes: number): string {
|
|||
|
||||
const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4"
|
||||
|
||||
export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
|
||||
const { callTool, updateModelContext } = useApp()
|
||||
export function Upload({
|
||||
activeTag,
|
||||
writableTags,
|
||||
onAdvance,
|
||||
onError,
|
||||
viewId,
|
||||
}: Props) {
|
||||
const { callTool, handoffToModel } = useApp()
|
||||
const log = useLog()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(
|
||||
|
|
@ -62,6 +69,7 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
|
|||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
containerTag: selectedTag,
|
||||
viewId,
|
||||
})
|
||||
if (!result.ok || !result.data) {
|
||||
log("error", `[upload] failed: ${result.error}`)
|
||||
|
|
@ -71,13 +79,28 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
|
|||
const documentId =
|
||||
result.data.view === "upload-success" ? result.data.id : undefined
|
||||
onAdvance(result.data)
|
||||
const contextUpdate = await updateModelContext(
|
||||
`Supermemory widget action completed. "${file.name}" was uploaded to workspace "${selectedTag}"${documentId ? ` with document ID "${documentId}"` : ""}. It is already uploaded; do not upload it again.`,
|
||||
)
|
||||
if (!contextUpdate.ok) {
|
||||
const handoff = await handoffToModel({
|
||||
context: `Supermemory widget action completed. "${file.name}" was uploaded to workspace "${selectedTag}"${documentId ? ` with document ID "${documentId}"` : ""}. It is already uploaded; do not upload it again.`,
|
||||
message: `I used the Supermemory widget to upload "${file.name}" to workspace "${selectedTag}"${documentId ? ` (document ID: ${documentId})` : ""}. The file is already uploaded; do not upload it again.`,
|
||||
structuredContent: {
|
||||
supermemory: {
|
||||
action: "file-uploaded",
|
||||
activeWorkspace: selectedTag,
|
||||
documentId,
|
||||
fileName: file.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!handoff.contextUpdate.ok) {
|
||||
log(
|
||||
"warning",
|
||||
`[upload] model context update failed: ${contextUpdate.error}`,
|
||||
`[upload] model context update failed: ${handoff.contextUpdate.error}`,
|
||||
)
|
||||
}
|
||||
if (!handoff.conversationMessage.ok) {
|
||||
log(
|
||||
"warning",
|
||||
`[upload] agent handoff failed: ${handoff.conversationMessage.error}`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
{
|
||||
"name": "MCP_SERVER",
|
||||
"class_name": "SupermemoryMCP"
|
||||
},
|
||||
{
|
||||
"name": "WORKSPACE_STATE",
|
||||
"class_name": "WorkspaceState"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -35,6 +39,10 @@
|
|||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["SupermemoryMCP"]
|
||||
},
|
||||
{
|
||||
"tag": "v2",
|
||||
"new_sqlite_classes": ["WorkspaceState"]
|
||||
}
|
||||
],
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue