Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// flag existed carry no value; absent now means off, same as a fresh
// connection that did not ask for `?artifacts=true`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
// Cold restores rebuild this server with no `initialize` to replay, so
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
loadAppShellHtml,
smokeRenderArtifact,
artifactUrl: artifactUrlFor(
Expand Down
4 changes: 4 additions & 0 deletions apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
// flag existed carry no value; absent now means off, same as a fresh
// connection that did not ask for `?artifacts=true`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
// Cold restores rebuild this server with no `initialize` to replay, so
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
loadAppShellHtml: self.loadAppShellHtml,
smokeRenderArtifact,
...(artifactOrigin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,96 @@ const makeHarnessSession = async (): Promise<HarnessSession> => {
return session;
};

// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives
// in the rebuilt server's memory. These pin the storage round-trip that lets a
// cold-restored session rebuild with it instead of silently downgrading every
// artifact to a deep link.
describe("McpAgentSessionDOBase apps capability persistence", () => {
type CapabilitySession = HarnessSession & {
persistAppsEnabled: (appsEnabled: boolean) => Effect.Effect<void>;
loadSessionMeta: () => Effect.Effect<SessionMeta | null>;
resolveSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>;
resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>;
};

const baseMeta: SessionMeta = {
organizationId: "org-1",
organizationName: "Org 1",
userId: "user-1",
resource: defaultMcpResource,
};

const makeCapabilitySession = async (
stored: SessionMeta = baseMeta,
): Promise<{ session: CapabilitySession; storage: MemoryStorage }> => {
const storage = new MemoryStorage();
await storage.put("session-meta", stored);
const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession;
session.ctx = storage;
session.getSessionId = () => "session-caps";
return { session, storage };
};

it("persists the negotiated capability so a later restore can read it back", async () => {
const { session, storage } = await makeCapabilitySession();

await Effect.runPromise(session.persistAppsEnabled(true));

expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({
organizationId: "org-1",
appsEnabled: true,
});
});

it("records a client that loses apps support just as durably", async () => {
const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true });

await Effect.runPromise(session.persistAppsEnabled(false));

expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ appsEnabled: false });
});

// `init` runs again on every cold restore and rebuilds meta from the bearer
// token, which carries no capabilities. If that overwrite won, restoring the
// session would erase the very bit meant to survive it.
it("carries the stored capability through the re-resolve on cold restore", async () => {
const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true });
// What the token resolves to: no `appsEnabled` anywhere in sight.
session.resolveSessionMeta = () => Effect.succeed(baseMeta);

const resolved = await Effect.runPromise(
session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }),
);

expect(resolved.appsEnabled).toBe(true);
expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ appsEnabled: true });
});

it("leaves a session with no negotiated capability untouched", async () => {
const { session, storage } = await makeCapabilitySession();
session.resolveSessionMeta = () => Effect.succeed(baseMeta);

const resolved = await Effect.runPromise(
session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }),
);

expect(resolved.appsEnabled).toBeUndefined();
expect(await storage.get<SessionMeta>("session-meta")).not.toHaveProperty("appsEnabled");
});

// Persistence is best-effort observation of a capability, never a reason to
// fail the session that was merely trying to render something.
it("stays silent when there is no stored meta to merge into", async () => {
const storage = new MemoryStorage();
const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession;
session.ctx = storage;
session.getSessionId = () => "session-caps";

await expect(Effect.runPromise(session.persistAppsEnabled(true))).resolves.toBeUndefined();
expect(await storage.get<SessionMeta>("session-meta")).toBeUndefined();
});
});

describe("McpAgentSessionDOBase transport restore", () => {
it("restores a same-session request after idle disposal leaves a stale server transport", async () => {
const session = await makeHarnessSession();
Expand Down
39 changes: 39 additions & 0 deletions packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ export interface SessionMeta {
* `buildMcpServer` scopes the tool catalog to it. */
readonly resource: McpResource;
readonly webOrigin?: string;
/**
* Whether this session's client advertised MCP-Apps support at `initialize`.
*
* Capabilities are negotiated once, into the server instance's memory. When
* the DO is evicted (deploy, idle) and a later request cold-restores it, the
* rebuilt server never sees an `initialize` — so without persisting this, an
* apps-capable client silently drops to artifact deep links mid-conversation.
* Absent — including for sessions persisted before this field existed — means
* unknown, which behaves as disabled until the next `initialize`.
*/
readonly appsEnabled?: boolean;
}

export interface BuiltMcpServer {
Expand Down Expand Up @@ -355,6 +366,28 @@ export abstract class McpAgentSessionDOBase<
await this.ctx.storage.put(SESSION_META_KEY, sessionMeta);
}

/**
* Persist the MCP-Apps support negotiated at `initialize`, so a later cold
* restore can rebuild the server with it. Subclasses hand this to
* `createExecutorMcpServer` as `onAppsEnabledChange`.
*
* A no-op before meta exists: `initialize` always follows `init`, so there is
* nothing to merge into and nothing worth failing the session over.
*/
protected persistAppsEnabled(appsEnabled: boolean): Effect.Effect<void> {
const self = this;
return Effect.gen(function* () {
const stored = yield* self.loadSessionMeta();
if (!stored || stored.appsEnabled === appsEnabled) return;
yield* Effect.promise(() => self.saveSessionMeta({ ...stored, appsEnabled }));
}).pipe(
Effect.withSpan("mcp.session.persist_apps_enabled", {
attributes: { "mcp.artifact.apps_enabled": appsEnabled },
}),
Effect.ignoreCause({ log: false }),
);
}

private async markActivity(now = Date.now()): Promise<void> {
this.lastActivityMs = now;
await Promise.all([
Expand Down Expand Up @@ -466,9 +499,15 @@ export abstract class McpAgentSessionDOBase<
const self = this;
return Effect.gen(function* () {
const resolved = yield* self.resolveSessionMeta(token);
// `init` runs again on every cold restore, and `resolveSessionMeta`
// rebuilds meta from the bearer token — which carries no negotiated
// capabilities. Carry the stored value forward, or restoring the session
// would erase the very bit that survives the restore.
const stored = yield* self.loadSessionMeta();
const sessionMeta: SessionMeta = {
...resolved,
...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
...(stored?.appsEnabled === undefined ? {} : { appsEnabled: stored.appsEnabled }),
};
yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
Effect.withSpan("mcp.session.save_meta"),
Expand Down
Loading
Loading