mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-11 22:51:05 +00:00
share workspace selection across sessions
This commit is contained in:
parent
2d920c12f0
commit
bdef38398e
18 changed files with 123 additions and 23 deletions
|
|
@ -4,6 +4,7 @@ import {
|
|||
callTool,
|
||||
connect,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
|
||||
|
|
@ -42,7 +43,7 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it("sets an active workspace only from the visible list", async () => {
|
||||
it("shares the selected workspace across MCP transport sessions", async () => {
|
||||
const picker = await callTool(session.client, "select-workspace")
|
||||
const pickerContent = picker.structuredContent as {
|
||||
containerTags?: Array<{ containerTag: string }>
|
||||
|
|
@ -58,6 +59,17 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
|
|||
view: "confirmation",
|
||||
containerTag: firstTag,
|
||||
})
|
||||
|
||||
const separateSession = await connect()
|
||||
try {
|
||||
const identity = await callTool(separateSession.client, "whoAmI")
|
||||
expect(identity.isError).toBeFalsy()
|
||||
expect(JSON.parse(textOf(identity))).toMatchObject({
|
||||
activeWorkspace: firstTag,
|
||||
})
|
||||
} finally {
|
||||
await separateSession.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("loads guided-save writable choices on demand", async () => {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import { registerProfileResource } from "./resources/profile"
|
|||
import { registerWidgetResource } from "./resources/widget"
|
||||
import { registerAllTools } from "./tools"
|
||||
import { errorResult } from "./tools/types"
|
||||
import type { WorkspaceState } from "./workspace-state"
|
||||
|
||||
type Env = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
WORKSPACE_STATE: DurableObjectNamespace<WorkspaceState>
|
||||
API_URL?: string
|
||||
}
|
||||
|
||||
|
|
@ -49,12 +51,11 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
getSession: () => this.getSession(),
|
||||
resolveContainerTag: (explicit?: string) =>
|
||||
this.resolveContainerTag(explicit),
|
||||
storage: {
|
||||
get: <T>(key: string) => this.ctx.storage.get<T>(key),
|
||||
put: <T>(key: string, value: T) => this.ctx.storage.put(key, value),
|
||||
},
|
||||
getActiveContainerTag: () => this.getActiveContainerTag(),
|
||||
setActiveContainerTag: (containerTag: string) =>
|
||||
this.setActiveContainerTag(containerTag),
|
||||
getClientInfo: () => this.clientInfo,
|
||||
getMcpSessionId: () => this.ctx.id.name ?? "unknown",
|
||||
getMcpSessionId: () => this.getSessionId(),
|
||||
errorResult,
|
||||
}
|
||||
|
||||
|
|
@ -83,10 +84,24 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
private async resolveContainerTag(
|
||||
explicit?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (this.props?.containerTag) return this.props.containerTag
|
||||
if (explicit) return explicit
|
||||
const activeTag = await this.ctx.storage.get<string>("activeContainerTag")
|
||||
const activeTag = await this.getActiveContainerTag()
|
||||
if (activeTag) return activeTag
|
||||
return this.props?.containerTag
|
||||
return undefined
|
||||
}
|
||||
|
||||
private workspaceState() {
|
||||
const key = `${this.props.userId}:${this.props.organizationId ?? "default"}`
|
||||
return this.env.WORKSPACE_STATE.getByName(key)
|
||||
}
|
||||
|
||||
private getActiveContainerTag(): Promise<string | undefined> {
|
||||
return this.workspaceState().getActiveContainerTag()
|
||||
}
|
||||
|
||||
private setActiveContainerTag(containerTag: string): Promise<void> {
|
||||
return this.workspaceState().setActiveContainerTag(containerTag)
|
||||
}
|
||||
|
||||
private getSession() {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ describe("MCP authentication", () => {
|
|||
|
||||
await expect(
|
||||
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
|
||||
).resolves.toEqual({ userId: "user_test", bearerToken: token })
|
||||
).resolves.toEqual({
|
||||
userId: "user_test",
|
||||
organizationId: "org_test",
|
||||
bearerToken: token,
|
||||
})
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const FETCH_TIMEOUT_MS = 30_000
|
|||
|
||||
export interface AuthUser {
|
||||
userId: string
|
||||
organizationId?: string
|
||||
bearerToken: string
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +67,10 @@ export async function validateOAuthToken(
|
|||
}
|
||||
return {
|
||||
userId: payload.sub,
|
||||
organizationId:
|
||||
typeof payload.organization_id === "string"
|
||||
? payload.organization_id
|
||||
: undefined,
|
||||
bearerToken: token,
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|||
import type { Props } from "../shared/types"
|
||||
import { SupermemoryMCP } from "./agent"
|
||||
import { validateOAuthToken } from "./auth"
|
||||
import { WorkspaceState } from "./workspace-state"
|
||||
|
||||
type Bindings = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
WORKSPACE_STATE: DurableObjectNamespace<WorkspaceState>
|
||||
API_URL?: string
|
||||
MCP_RESOURCE?: string
|
||||
}
|
||||
|
|
@ -154,6 +156,7 @@ async function handleMcpRequest(
|
|||
...c.executionCtx,
|
||||
props: {
|
||||
userId: authUser.userId,
|
||||
organizationId: authUser.organizationId,
|
||||
bearerToken: authUser.bearerToken,
|
||||
containerTag,
|
||||
} satisfies Props,
|
||||
|
|
@ -174,6 +177,6 @@ app.all("/mcp/*", async (c) => {
|
|||
return handleMcpRequest(c)
|
||||
})
|
||||
|
||||
export { SupermemoryMCP }
|
||||
export { SupermemoryMCP, WorkspaceState }
|
||||
|
||||
export default app
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function register(deps: ToolDeps) {
|
|||
try {
|
||||
const prefill = (args as { prefill?: string }).prefill
|
||||
const [activeTag, tags, session] = await Promise.all([
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
deps.getActiveContainerTag(),
|
||||
deps.getClient().listContainerTags(),
|
||||
deps.getSession(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function register(deps: ToolDeps) {
|
|||
const [tags, session, activeTag] = await Promise.all([
|
||||
client.listContainerTags(),
|
||||
deps.getSession(),
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
deps.getActiveContainerTag(),
|
||||
])
|
||||
const assignedTags = effectiveContainerTagAccess(
|
||||
tags.map((tag) => tag.containerTag),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function register(deps: ToolDeps) {
|
|||
new Error(`No access to container tag '${containerTag}'.`),
|
||||
)
|
||||
}
|
||||
await deps.storage.put("activeContainerTag", containerTag)
|
||||
await deps.setActiveContainerTag(containerTag)
|
||||
const sc: ViewMessage = {
|
||||
view: "confirmation",
|
||||
containerTag,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,8 @@ export interface ToolDeps {
|
|||
getClient: (containerTag?: string) => SupermemoryClient
|
||||
getSession: () => Promise<SessionInfo>
|
||||
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
|
||||
storage: {
|
||||
get: <T>(key: string) => Promise<T | undefined>
|
||||
put: <T>(key: string, value: T) => Promise<void>
|
||||
}
|
||||
getActiveContainerTag: () => Promise<string | undefined>
|
||||
setActiveContainerTag: (containerTag: string) => Promise<void>
|
||||
getClientInfo: () => { name: string; version?: string } | null
|
||||
getMcpSessionId: () => string
|
||||
errorResult: (error: unknown) => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function register(deps: ToolDeps) {
|
|||
async () => {
|
||||
try {
|
||||
const [activeTag, tags, session] = await Promise.all([
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
deps.getActiveContainerTag(),
|
||||
deps.getClient().listContainerTags(),
|
||||
deps.getSession(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function register(deps: ToolDeps) {
|
|||
try {
|
||||
const [session, activeTag] = await Promise.all([
|
||||
deps.getSession(),
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
deps.getActiveContainerTag(),
|
||||
])
|
||||
return {
|
||||
content: [
|
||||
|
|
|
|||
13
apps/mcp/src/server/workspace-state.ts
Normal file
13
apps/mcp/src/server/workspace-state.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { DurableObject } from "cloudflare:workers"
|
||||
|
||||
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> {
|
||||
await this.ctx.storage.put(ACTIVE_CONTAINER_TAG_KEY, containerTag)
|
||||
}
|
||||
}
|
||||
|
|
@ -119,6 +119,7 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,10 @@ export function useApp() {
|
|||
},
|
||||
|
||||
/** Update ambient model context. Model sees on next user message. */
|
||||
updateContext(text: string) {
|
||||
updateContext(text: string, structuredContent?: Record<string, unknown>) {
|
||||
return app.updateModelContext({
|
||||
content: [{ type: "text", text }],
|
||||
structuredContent,
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function Picker({
|
|||
onAdvance,
|
||||
onError,
|
||||
}: Props) {
|
||||
const { callTool } = useApp()
|
||||
const { callTool, updateContext } = useApp()
|
||||
const log = useLog()
|
||||
const [pending, setPending] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState("")
|
||||
|
|
@ -55,6 +55,19 @@ export function Picker({
|
|||
onError(result.error ?? "Failed to set active workspace")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateContext(
|
||||
`The user selected the Supermemory workspace "${containerTag}". Use this as the active workspace for future Supermemory actions unless the user selects another workspace.`,
|
||||
{
|
||||
supermemory: {
|
||||
activeWorkspace: containerTag,
|
||||
lastAction: "workspace-selected",
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
log("warning", `[picker] model context update failed: ${error}`)
|
||||
}
|
||||
onAdvance(result.data)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function Save({
|
|||
onAdvance,
|
||||
onError,
|
||||
}: Props) {
|
||||
const { callTool } = useApp()
|
||||
const { callTool, updateContext } = useApp()
|
||||
const log = useLog()
|
||||
const [content, setContent] = useState(prefill ?? "")
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(
|
||||
|
|
@ -63,6 +63,19 @@ export function Save({
|
|||
onError(result.error ?? "Failed to save memory")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateContext(
|
||||
`The user saved a memory to the Supermemory workspace "${selectedTag}" from the interactive widget.`,
|
||||
{
|
||||
supermemory: {
|
||||
activeWorkspace: selectedTag,
|
||||
lastAction: "memory-saved",
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
log("warning", `[save] model context update failed: ${error}`)
|
||||
}
|
||||
onAdvance(result.data)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ function formatFileSize(bytes: number): string {
|
|||
const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4"
|
||||
|
||||
export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
|
||||
const { callTool } = useApp()
|
||||
const { callTool, updateContext } = useApp()
|
||||
const log = useLog()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(
|
||||
|
|
@ -62,6 +62,20 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
|
|||
onError(result.error ?? "Upload failed")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateContext(
|
||||
`The user uploaded "${file.name}" to the Supermemory workspace "${selectedTag}" from the interactive widget.`,
|
||||
{
|
||||
supermemory: {
|
||||
activeWorkspace: selectedTag,
|
||||
lastAction: "file-uploaded",
|
||||
fileName: file.name,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
log("warning", `[upload] model context update failed: ${error}`)
|
||||
}
|
||||
onAdvance(result.data)
|
||||
} catch (err) {
|
||||
log("error", `[upload] threw: ${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