share workspace selection across sessions

This commit is contained in:
Prasanna A P 2026-07-21 00:30:41 -07:00
parent 2d920c12f0
commit bdef38398e
18 changed files with 123 additions and 23 deletions

View file

@ -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 () => {

View file

@ -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() {

View file

@ -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()
})

View file

@ -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) {

View file

@ -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

View file

@ -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(),
])

View file

@ -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),

View file

@ -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,

View file

@ -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) => {

View file

@ -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(),
])

View file

@ -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: [

View 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)
}
}

View file

@ -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
}

View file

@ -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,
})
},

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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}`)

View file

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