diff --git a/app/globals.css b/app/globals.css
index 758e8a0..a718194 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -206,3 +206,12 @@ button {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
+
+@keyframes mcp-activity-settle {
+ 0%, 72% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/app/mcp/route.ts b/app/mcp/route.ts
index 723ca81..e296424 100644
--- a/app/mcp/route.ts
+++ b/app/mcp/route.ts
@@ -6,6 +6,7 @@ import { resolveRequestOrigin } from "@/common/lib/siteUrl";
import { createSlideXMcpServer } from "@/mcp/server";
import { mcpResourceUrl } from "@/mcp/oauthMetadata";
import { SupabaseMcpOAuthStore } from "@/mcp/supabaseOAuthStore";
+import { SupabaseMcpOperationActivityStore } from "@/mcp/supabaseOperationActivityStore";
import { SupabaseMcpPresentationImageUploadStore } from "@/mcp/supabasePresentationImageUploadStore";
import { SupabaseMcpPresentationStore } from "@/mcp/supabasePresentationStore";
@@ -35,6 +36,13 @@ async function handleMcpRequest(request: NextRequest) {
return NextResponse.json({ error: "insufficient_scope" }, { status: 403 });
}
+ const oauthClient = await oauthStore.getClient(auth.clientId).catch(() => null);
+ const operationActivity = new SupabaseMcpOperationActivityStore(admin, {
+ clientId: auth.clientId,
+ clientName: oauthClient?.client_name?.trim() || "MCP client",
+ userId: auth.userId
+ });
+
const server = createSlideXMcpServer({
enablePresentationWrites: auth.scopes.includes("presentations:write"),
imageUploads: auth.scopes.includes("presentation-assets:write")
@@ -44,6 +52,7 @@ async function handleMcpRequest(request: NextRequest) {
userId: auth.userId
}
: undefined,
+ operationActivity,
profile: "remote",
presentationStore: new SupabaseMcpPresentationStore(admin, auth.userId)
});
diff --git a/app/workspace/pitch/page.tsx b/app/workspace/pitch/page.tsx
index 94f4032..a6dbc47 100644
--- a/app/workspace/pitch/page.tsx
+++ b/app/workspace/pitch/page.tsx
@@ -14,6 +14,8 @@ function LocalPitchWorkspace() {
agentSessionId,
error,
isReady,
+ mcpActivities,
+ mcpActivityWarning,
openAgentSession,
presentation,
save,
@@ -70,6 +72,8 @@ function LocalPitchWorkspace() {
onSelectedAgentSessionChange={selectAgentSession}
presentationId={presentation.id}
projectVersion={presentation.sourceRevision}
+ remoteMcpActivityWarning={mcpActivityWarning}
+ remoteMcpOperations={mcpActivities}
syncWarning={syncWarning}
/>
diff --git a/common/lib/supabase/database.types.ts b/common/lib/supabase/database.types.ts
index c43fd6e..39641bc 100644
--- a/common/lib/supabase/database.types.ts
+++ b/common/lib/supabase/database.types.ts
@@ -182,6 +182,61 @@ export type Database = {
}
];
};
+ mcp_operation_events: {
+ Row: {
+ client_id: string;
+ client_name: string;
+ completed_at: string | null;
+ completed_revision: number | null;
+ created_at: string;
+ error_code: string | null;
+ expires_at: string;
+ id: string;
+ node_id: string | null;
+ presentation_id: string;
+ slide_index: number | null;
+ status: "running" | "completed" | "failed";
+ target_kind: "presentation" | "slide" | "block";
+ tool_name: string;
+ updated_at: string;
+ user_id: string;
+ };
+ Insert: {
+ client_id: string;
+ client_name: string;
+ completed_at?: string | null;
+ completed_revision?: number | null;
+ created_at?: string;
+ error_code?: string | null;
+ id?: string;
+ node_id?: string | null;
+ presentation_id: string;
+ slide_index?: number | null;
+ status?: "running" | "completed" | "failed";
+ target_kind: "presentation" | "slide" | "block";
+ tool_name: string;
+ updated_at?: string;
+ user_id: string;
+ };
+ Update: {
+ completed_at?: string | null;
+ completed_revision?: number | null;
+ error_code?: string | null;
+ node_id?: string | null;
+ slide_index?: number | null;
+ status?: "running" | "completed" | "failed";
+ target_kind?: "presentation" | "slide" | "block";
+ };
+ Relationships: [
+ {
+ foreignKeyName: "mcp_operation_events_presentation_id_fkey";
+ columns: ["presentation_id"];
+ isOneToOne: false;
+ referencedRelation: "presentations";
+ referencedColumns: ["id"];
+ }
+ ];
+ };
official_templates: {
Row: {
created_at: string;
diff --git a/features/marketing/ui/DocsPage.tsx b/features/marketing/ui/DocsPage.tsx
index 50bda83..2b384fc 100644
--- a/features/marketing/ui/DocsPage.tsx
+++ b/features/marketing/ui/DocsPage.tsx
@@ -161,11 +161,11 @@ export function DocsPage() {
SlideX MCP Server
- v0.4.0 on npm
+ v0.5.0 on npm
@@ -174,6 +174,11 @@ export function DocsPage() {
? "透過 Model Context Protocol,讓相容的 AI 客戶端建立、檢查與編輯 MotionDoc 簡報。你可以選擇在電腦執行本機 MCP,或連接 SlideX 的受保護 Remote MCP。"
: "Use the Model Context Protocol to let compatible AI clients create, validate, and edit MotionDoc presentations. Run the local MCP on your computer or connect to SlideX through the protected Remote MCP."}
+
+ {isZh
+ ? "v0.5 會在開啟的 Workspace 與 Pitch 以紫色框即時標示真實 Remote MCP 操作與 OAuth client 名稱;它不會模擬滑鼠,也不會顯示模型未呼叫 tool 時的思考過程。"
+ : "v0.5 visualizes real Remote MCP operations in an open Workspace or Pitch editor with purple frames and the OAuth client name. It does not simulate a cursor or expose model reasoning when no tool is called."}
+
diff --git a/features/pitch/application/remoteMcpOperation.ts b/features/pitch/application/remoteMcpOperation.ts
new file mode 100644
index 0000000..f23a301
--- /dev/null
+++ b/features/pitch/application/remoteMcpOperation.ts
@@ -0,0 +1,58 @@
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
+
+const runningLifetimeMs = 10 * 60_000;
+const settledLifetimeMs = 6_000;
+
+export function remoteMcpOperationDeadline(activity: RemoteMcpOperation) {
+ const timestamp = activity.status === "running"
+ ? activity.createdAt
+ : activity.completedAt ?? activity.updatedAt;
+ return new Date(timestamp).getTime()
+ + (activity.status === "running" ? runningLifetimeMs : settledLifetimeMs);
+}
+
+export function remoteMcpOperationAction(
+ activity: Pick
,
+ locale: "en" | "zh-TW"
+) {
+ if (activity.status === "failed") {
+ if (activity.errorCode === "revision_conflict") {
+ return locale === "zh-TW" ? "版本衝突,未覆蓋內容" : "Revision conflict; nothing overwritten";
+ }
+ return locale === "zh-TW" ? "操作未完成" : "Operation not completed";
+ }
+
+ const action = remoteToolAction(activity.toolName, locale);
+ if (activity.status === "completed") {
+ return locale === "zh-TW" ? `${action}完成` : `${action} complete`;
+ }
+ return action;
+}
+
+export function remoteMcpOperationTargetsSlide(
+ activity: RemoteMcpOperation,
+ slideIndex: number,
+ activeSlideIndex: number
+) {
+ if (activity.target.kind === "presentation") return slideIndex === activeSlideIndex;
+ return activity.target.slideIndex === slideIndex;
+}
+
+function remoteToolAction(toolName: string, locale: "en" | "zh-TW") {
+ const actions: Record = {
+ slidex_add_block: ["Adding an object", "新增物件"],
+ slidex_add_slide_from_layout: ["Adding a slide", "新增投影片"],
+ slidex_apply_shader_preset: ["Applying a shader", "套用 Shader"],
+ slidex_delete_block: ["Deleting an object", "刪除物件"],
+ slidex_duplicate_block: ["Duplicating an object", "複製物件"],
+ slidex_finalize_presentation_image_upload: ["Finalizing an image", "完成圖片上傳"],
+ slidex_prepare_presentation_image_upload: ["Preparing an image", "準備圖片上傳"],
+ slidex_reorder_block: ["Reordering objects", "調整物件順序"],
+ slidex_replace_slide_with_layout: ["Applying a layout", "套用版面"],
+ slidex_save_presentation: ["Saving the presentation", "儲存整份簡報"],
+ slidex_update_block: ["Editing an object", "修改物件"],
+ slidex_update_canvas_node: ["Moving an object", "調整物件位置"]
+ };
+ return actions[toolName]?.[locale === "zh-TW" ? 1 : 0]
+ ?? (locale === "zh-TW" ? "修改簡報" : "Editing presentation");
+}
diff --git a/features/pitch/domain/remoteMcpOperation.ts b/features/pitch/domain/remoteMcpOperation.ts
new file mode 100644
index 0000000..5b213ff
--- /dev/null
+++ b/features/pitch/domain/remoteMcpOperation.ts
@@ -0,0 +1,18 @@
+export type RemoteMcpOperation = {
+ clientId: string;
+ clientName: string;
+ completedAt?: string;
+ completedRevision?: number;
+ createdAt: string;
+ errorCode?: string;
+ expiresAt: string;
+ id: string;
+ presentationId: string;
+ status: "running" | "completed" | "failed";
+ target:
+ | { kind: "presentation" }
+ | { kind: "slide"; slideIndex: number }
+ | { kind: "block"; nodeId: string; slideIndex: number };
+ toolName: string;
+ updatedAt: string;
+};
diff --git a/features/pitch/ui/LayerSidebar.tsx b/features/pitch/ui/LayerSidebar.tsx
index b180be7..51a8453 100644
--- a/features/pitch/ui/LayerSidebar.tsx
+++ b/features/pitch/ui/LayerSidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Group, Layers, Plus, Trash2 } from "lucide-react";
+import { Bot, Group, Layers, Plus, Trash2 } from "lucide-react";
import type { MouseEvent } from "react";
import { useState } from "react";
import { LayerRow } from "@/features/pitch/ui/LayerRow";
@@ -9,6 +9,11 @@ import type { MotionDocBlock, MotionDocScene } from "@/core/motion-doc/domain/mo
import { SlideThumbnailPreview } from "@/features/pitch/ui/preview/SlideThumbnailPreview";
import type { SlideRow } from "@/features/pitch/application/slideRows";
import { usePitchI18n } from "@/features/pitch/ui/pitchI18n";
+import {
+ remoteMcpOperationAction,
+ remoteMcpOperationTargetsSlide
+} from "@/features/pitch/application/remoteMcpOperation";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
export function LayerSidebar({
activeSlideIndex,
@@ -24,6 +29,7 @@ export function LayerSidebar({
reorderBlock,
reorderSlide,
renameBlock,
+ remoteMcpOperations,
replayNonce,
scenes,
selectedBlockIndex,
@@ -47,6 +53,7 @@ export function LayerSidebar({
reorderBlock: (fromIndex: number, toIndex: number) => void;
reorderSlide: (fromIndex: number, toIndex: number) => void;
renameBlock: (index: number, name: string) => void;
+ remoteMcpOperations: readonly RemoteMcpOperation[];
replayNonce: number;
scenes: MotionDocScene[];
selectedBlockIndex: number | null;
@@ -119,6 +126,9 @@ export function LayerSidebar({
{slideRows.map((slide) => {
const isActive = slide.index === activeSlideIndex;
const currentSlide = scenes[slide.index];
+ const mcpActivity = remoteMcpOperations.find((activity) => (
+ remoteMcpOperationTargetsSlide(activity, slide.index, activeSlideIndex)
+ ));
return (
@@ -129,7 +139,7 @@ export function LayerSidebar({
isActive
? "bg-white/[0.08] text-white shadow-[inset_0_1px_1px_0_rgba(255,255,255,0.05)] border border-white/[0.04]"
: "text-neutral-400 hover:bg-white/[0.03] hover:text-neutral-200 border border-transparent"
- }`}
+ } ${mcpActivity ? `border-[#8b5cf6]/70 ${mcpActivity.status === "running" ? "motion-safe:animate-pulse" : "motion-safe:animate-[mcp-activity-settle_6s_ease-out_forwards]"} ${mcpActivity.status === "failed" ? "border-dashed" : "border-solid"}` : ""}`}
onClick={() => onSelectSlide(slide.index)}
>
@@ -139,6 +149,7 @@ export function LayerSidebar({
+ {mcpActivity ?
: null}
{slide.duration}s
@@ -202,6 +213,15 @@ export function LayerSidebar({
scene={currentSlide}
source={source}
/>
+ {mcpActivity ? (
+
+
+
+ AI · {mcpActivity.clientName}
+ {remoteMcpOperationAction(mcpActivity, locale)}
+
+
+ ) : null}
{slide.index + 1}
diff --git a/features/pitch/ui/MotionDocApp.tsx b/features/pitch/ui/MotionDocApp.tsx
index c8da2a4..b4b7a22 100644
--- a/features/pitch/ui/MotionDocApp.tsx
+++ b/features/pitch/ui/MotionDocApp.tsx
@@ -33,6 +33,7 @@ import {
type GuestSignInIntent
} from "@/features/pitch/ui/GuestSignInDialog";
import type { AgentSessionSummary } from "@/features/pitch/domain/agentRun";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
const isSlideXAgentEnabled = process.env.NEXT_PUBLIC_SLIDEX_AGENT_ENABLED === "true";
@@ -59,6 +60,8 @@ type MotionDocAppProps = {
onSelectedAgentSessionChange?: (sessionId?: string) => void;
presentationId?: string;
projectVersion?: number;
+ remoteMcpActivityWarning?: string | null;
+ remoteMcpOperations?: readonly RemoteMcpOperation[];
syncWarning?: string | null;
};
@@ -76,6 +79,8 @@ export function MotionDocApp({
onSelectedAgentSessionChange,
presentationId,
projectVersion,
+ remoteMcpActivityWarning,
+ remoteMcpOperations = [],
syncWarning
}: MotionDocAppProps = {}) {
const isMobileViewport = useMobilePitchViewport();
@@ -498,6 +503,10 @@ export function MotionDocApp({
const desktopExperience = (
<>
("fit");
const [fitScale, setFitScale] = useState(1);
+ const visibleRemoteMcpOperations = useVisibleRemoteMcpOperations(remoteMcp?.activities ?? []);
useMobileEdgePanels({
isLeftPanelOpen: view.isMobileSidebarOpen,
@@ -77,6 +79,7 @@ export function PitchWorkspace({ agent, commands, document, selection, view }: P
commands={commands}
document={document}
onSelectSlide={selectSlide}
+ remoteMcpOperations={visibleRemoteMcpOperations}
selection={selection}
view={view}
/>
@@ -121,6 +124,8 @@ export function PitchWorkspace({ agent, commands, document, selection, view }: P
onUpdateBlockFrames={commands.updatePositionedBlockFrames}
onUseSelectedImageAsBackground={commands.useSelectedImageAsBackground}
replayNonce={view.replayNonce}
+ remoteMcpActivityWarning={remoteMcp?.connectionWarning}
+ remoteMcpOperations={visibleRemoteMcpOperations}
sceneCount={sceneCount}
scenes={document.scenes}
selectedBlockIndex={selection.selectedBlockIndex}
diff --git a/features/pitch/ui/PreviewCanvas.tsx b/features/pitch/ui/PreviewCanvas.tsx
index 93eaec8..a5c95d3 100644
--- a/features/pitch/ui/PreviewCanvas.tsx
+++ b/features/pitch/ui/PreviewCanvas.tsx
@@ -26,6 +26,9 @@ import { CanvasBlockDock, CanvasSlideAddControls, CanvasSlideNav } from "@/featu
import { MobileEdgePanelHandles } from "@/features/pitch/ui/preview/MobileCanvasChrome";
import { CanvasContextMenu } from "@/features/pitch/ui/preview/CanvasContextMenu";
import { CanvasSelectionLayer } from "@/features/pitch/ui/preview/CanvasSelectionLayer";
+import { RemoteMcpActivityOverlay } from "@/features/pitch/ui/preview/RemoteMcpActivityOverlay";
+import { RemoteMcpActivityRail } from "@/features/pitch/ui/preview/RemoteMcpActivityRail";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
import { PreviewPane } from "@/features/pitch/ui/preview/PreviewPane";
import { useCanvasContextMenu } from "@/features/pitch/ui/preview/interaction/useCanvasContextMenu";
import { useCanvasInteractionEngine } from "@/features/pitch/ui/preview/interaction/useCanvasInteractionEngine";
@@ -70,6 +73,8 @@ type PreviewCanvasProps = {
onUpdateBlockFrames: (updates: BlockFramePatch[]) => void;
onUseSelectedImageAsBackground: () => void;
replayNonce: number;
+ remoteMcpActivityWarning?: string | null;
+ remoteMcpOperations: readonly RemoteMcpOperation[];
sceneCount: number;
scenes: MotionDocScene[];
selectedBlockIndex: number | null;
@@ -114,6 +119,8 @@ export function PreviewCanvas({
onInsertSlideNearActive,
onCanvasToolChange,
replayNonce,
+ remoteMcpActivityWarning,
+ remoteMcpOperations,
sceneCount,
scenes,
selectedBlockIndex,
@@ -596,6 +603,12 @@ export function PreviewCanvas({
onPreviousSlide={onPreviousSlide}
sceneCount={sceneCount}
/>
+
+ {remoteMcpActivityWarning ? (
+
+ {remoteMcpActivityWarning}
+
+ ) : null}
) : null}
+
{isActiveSlideFrame && contextMenu ? (
1}
diff --git a/features/pitch/ui/hooks/useVisibleRemoteMcpOperations.ts b/features/pitch/ui/hooks/useVisibleRemoteMcpOperations.ts
new file mode 100644
index 0000000..42cb904
--- /dev/null
+++ b/features/pitch/ui/hooks/useVisibleRemoteMcpOperations.ts
@@ -0,0 +1,28 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+
+import { remoteMcpOperationDeadline } from "@/features/pitch/application/remoteMcpOperation";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
+
+export function useVisibleRemoteMcpOperations(activities: readonly RemoteMcpOperation[]) {
+ const [now, setNow] = useState(() => Date.now());
+ const visible = useMemo(() => activities.filter((activity) => (
+ remoteMcpOperationDeadline(activity) > now && new Date(activity.expiresAt).getTime() > now
+ )), [activities, now]);
+
+ useEffect(() => {
+ const nextDeadline = visible.reduce(
+ (earliest, activity) => Math.min(earliest, remoteMcpOperationDeadline(activity)),
+ Number.POSITIVE_INFINITY
+ );
+ if (!Number.isFinite(nextDeadline)) return;
+ const timeout = window.setTimeout(
+ () => setNow(Date.now()),
+ Math.max(nextDeadline - Date.now() + 20, 20)
+ );
+ return () => window.clearTimeout(timeout);
+ }, [visible]);
+
+ return visible;
+}
diff --git a/features/pitch/ui/preview/RemoteMcpActivityOverlay.tsx b/features/pitch/ui/preview/RemoteMcpActivityOverlay.tsx
new file mode 100644
index 0000000..1651e8d
--- /dev/null
+++ b/features/pitch/ui/preview/RemoteMcpActivityOverlay.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { Bot, CheckCircle2, CircleAlert } from "lucide-react";
+
+import { motionDocBlockKey } from "@/core/motion-doc/application/motionDocBlockIdentity";
+import type { MotionDocScene } from "@/core/motion-doc/domain/motionDocTypes";
+import {
+ remoteMcpOperationAction,
+ remoteMcpOperationTargetsSlide
+} from "@/features/pitch/application/remoteMcpOperation";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
+import { blockFrame } from "@/features/pitch/application/previewCanvas";
+import { usePitchI18n } from "@/features/pitch/ui/pitchI18n";
+
+type RemoteMcpActivityOverlayProps = {
+ activeSlideIndex: number;
+ activities: readonly RemoteMcpOperation[];
+ scene: MotionDocScene | undefined;
+ slideIndex: number;
+};
+
+export function RemoteMcpActivityOverlay({
+ activeSlideIndex,
+ activities,
+ scene,
+ slideIndex
+}: RemoteMcpActivityOverlayProps) {
+ const { locale } = usePitchI18n();
+
+ return (
+
+ {activities.filter((activity) => remoteMcpOperationTargetsSlide(
+ activity,
+ slideIndex,
+ activeSlideIndex
+ )).map((activity, index) => {
+ const frame = operationFrame(activity, scene, slideIndex, activeSlideIndex);
+ if (!frame) return null;
+ const isConflict = activity.errorCode === "revision_conflict";
+
+ return (
+
+
+ {activity.status === "running" ? : activity.status === "completed" ? : }
+ AI · {activity.clientName}
+ · {remoteMcpOperationAction(activity, locale)}
+
+
+ );
+ })}
+
+ );
+}
+
+function operationFrame(
+ activity: RemoteMcpOperation,
+ scene: MotionDocScene | undefined,
+ slideIndex: number,
+ activeSlideIndex: number
+) {
+ if (activity.target.kind !== "block" || slideIndex !== activeSlideIndex) {
+ return { h: 100, w: 100, x: 0, y: 0 };
+ }
+
+ const nodeId = activity.target.nodeId;
+ const blockIndex = scene?.blocks.findIndex(
+ (block, index) => (
+ motionDocBlockKey(block, index) === nodeId || `${block.type}-legacy-${index}` === nodeId
+ )
+ ) ?? -1;
+ const block = blockIndex >= 0 ? scene?.blocks[blockIndex] : undefined;
+ return block ? blockFrame(block) : { h: 100, w: 100, x: 0, y: 0 };
+}
diff --git a/features/pitch/ui/preview/RemoteMcpActivityRail.tsx b/features/pitch/ui/preview/RemoteMcpActivityRail.tsx
new file mode 100644
index 0000000..f660452
--- /dev/null
+++ b/features/pitch/ui/preview/RemoteMcpActivityRail.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { Bot, CheckCircle2, CircleAlert } from "lucide-react";
+
+import { remoteMcpOperationAction } from "@/features/pitch/application/remoteMcpOperation";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
+import { usePitchI18n } from "@/features/pitch/ui/pitchI18n";
+
+export function RemoteMcpActivityRail({ activities }: { activities: readonly RemoteMcpOperation[] }) {
+ const { locale } = usePitchI18n();
+ if (activities.length === 0) return null;
+
+ return (
+
+ {activities.slice(0, 3).map((activity) => (
+
+ {activity.status === "running" ? : activity.status === "completed" ? : }
+ AI · {activity.clientName}
+ {remoteMcpOperationAction(activity, locale)}
+
+ ))}
+
+ );
+}
diff --git a/features/pitch/ui/workspace/PitchWorkspaceTypes.ts b/features/pitch/ui/workspace/PitchWorkspaceTypes.ts
index 6890545..4089179 100644
--- a/features/pitch/ui/workspace/PitchWorkspaceTypes.ts
+++ b/features/pitch/ui/workspace/PitchWorkspaceTypes.ts
@@ -7,6 +7,7 @@ import type { BlockFramePatch } from "@/features/pitch/application/pitchGeometry
import type { SlideComment } from "@/features/pitch/application/slideComments";
import type { SlideRow } from "@/features/pitch/application/slideRows";
import type { AddBlockType } from "@/features/pitch/ui/pitchOptions";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
export type SelectionMdx = { label: string; source: string };
@@ -143,6 +144,10 @@ export type PitchWorkspaceProps = {
agent?: PitchWorkspaceAgent;
commands: PitchWorkspaceCommands;
document: PitchWorkspaceDocument;
+ remoteMcp?: {
+ activities: readonly RemoteMcpOperation[];
+ connectionWarning?: string | null;
+ };
selection: PitchWorkspaceSelection;
view: PitchWorkspaceView;
};
diff --git a/features/pitch/ui/workspace/WorkspaceLayerSidebar.tsx b/features/pitch/ui/workspace/WorkspaceLayerSidebar.tsx
index 91b5253..9623706 100644
--- a/features/pitch/ui/workspace/WorkspaceLayerSidebar.tsx
+++ b/features/pitch/ui/workspace/WorkspaceLayerSidebar.tsx
@@ -4,9 +4,11 @@ import { X } from "lucide-react";
import { LayerSidebar } from "@/features/pitch/ui/LayerSidebar";
import type { PitchWorkspaceProps } from "@/features/pitch/ui/workspace/PitchWorkspaceTypes";
import { usePitchI18n } from "@/features/pitch/ui/pitchI18n";
+import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";
type WorkspaceLayerSidebarProps = Pick & {
onSelectSlide: (index: number) => void;
+ remoteMcpOperations: readonly RemoteMcpOperation[];
};
export function WorkspaceLayerSidebar(props: WorkspaceLayerSidebarProps) {
@@ -54,7 +56,7 @@ export function WorkspaceLayerSidebar(props: WorkspaceLayerSidebarProps) {
);
}
-function LayerSidebarContent({ commands, document, onSelectSlide, selection, view }: WorkspaceLayerSidebarProps) {
+function LayerSidebarContent({ commands, document, onSelectSlide, remoteMcpOperations, selection, view }: WorkspaceLayerSidebarProps) {
return (
{
+ assert.equal(isMcpOperationVisuallyActive(activity, Date.parse("2026-07-19T01:09:59.000Z")), true);
+ assert.equal(isMcpOperationVisuallyActive(activity, Date.parse("2026-07-19T01:10:01.000Z")), false);
+ assert.equal(
+ latestMcpOperationForPresentation([activity], activity.presentationId, Date.parse("2026-07-19T01:05:00.000Z"))?.id,
+ activity.id
+ );
+});
+
+test("uses purple-state copy for completion and conflict without exposing raw errors", () => {
+ assert.equal(mcpOperationAction({ ...activity, status: "completed" }, "zh-TW"), "修改物件完成");
+ assert.equal(mcpOperationAction({ ...activity, errorCode: "revision_conflict", status: "failed" }, "en"), "Revision conflict; nothing overwritten");
+});
diff --git a/features/workspace/application/mcpOperationActivity.ts b/features/workspace/application/mcpOperationActivity.ts
new file mode 100644
index 0000000..589140f
--- /dev/null
+++ b/features/workspace/application/mcpOperationActivity.ts
@@ -0,0 +1,59 @@
+import type { McpOperationActivity } from "@/features/workspace/domain/mcpOperationActivity";
+
+const runningVisualLifetimeMs = 10 * 60_000;
+const settledVisualLifetimeMs = 6_000;
+
+const toolActions: Record = {
+ slidex_add_block: { en: "Adding an object", "zh-TW": "新增物件" },
+ slidex_add_slide_from_layout: { en: "Adding a slide", "zh-TW": "新增投影片" },
+ slidex_apply_shader_preset: { en: "Applying a shader", "zh-TW": "套用 Shader" },
+ slidex_delete_block: { en: "Deleting an object", "zh-TW": "刪除物件" },
+ slidex_duplicate_block: { en: "Duplicating an object", "zh-TW": "複製物件" },
+ slidex_finalize_presentation_image_upload: { en: "Finalizing an image", "zh-TW": "完成圖片上傳" },
+ slidex_prepare_presentation_image_upload: { en: "Preparing an image", "zh-TW": "準備圖片上傳" },
+ slidex_reorder_block: { en: "Reordering objects", "zh-TW": "調整物件順序" },
+ slidex_replace_slide_with_layout: { en: "Applying a layout", "zh-TW": "套用版面" },
+ slidex_save_presentation: { en: "Saving the presentation", "zh-TW": "儲存整份簡報" },
+ slidex_update_block: { en: "Editing an object", "zh-TW": "修改物件" },
+ slidex_update_canvas_node: { en: "Moving an object", "zh-TW": "調整物件位置" }
+};
+
+export function mcpOperationAction(
+ activity: Pick,
+ locale: "en" | "zh-TW"
+) {
+ if (activity.status === "failed") {
+ if (activity.errorCode === "revision_conflict") {
+ return locale === "zh-TW" ? "版本衝突,未覆蓋內容" : "Revision conflict; nothing overwritten";
+ }
+ return locale === "zh-TW" ? "操作未完成" : "Operation not completed";
+ }
+
+ const action = toolActions[activity.toolName]?.[locale]
+ ?? (locale === "zh-TW" ? "修改簡報" : "Editing presentation");
+ if (activity.status === "completed") {
+ return locale === "zh-TW" ? `${action}完成` : `${action} complete`;
+ }
+ return action;
+}
+
+export function mcpOperationVisualDeadline(activity: McpOperationActivity) {
+ const base = activity.status === "running" ? activity.createdAt : activity.completedAt ?? activity.updatedAt;
+ const lifetime = activity.status === "running" ? runningVisualLifetimeMs : settledVisualLifetimeMs;
+ return new Date(base).getTime() + lifetime;
+}
+
+export function isMcpOperationVisuallyActive(activity: McpOperationActivity, now = Date.now()) {
+ const deadline = mcpOperationVisualDeadline(activity);
+ return Number.isFinite(deadline) && deadline > now && new Date(activity.expiresAt).getTime() > now;
+}
+
+export function latestMcpOperationForPresentation(
+ activities: readonly McpOperationActivity[],
+ presentationId: string,
+ now = Date.now()
+) {
+ return activities.find(
+ (activity) => activity.presentationId === presentationId && isMcpOperationVisuallyActive(activity, now)
+ );
+}
diff --git a/features/workspace/domain/mcpOperationActivity.ts b/features/workspace/domain/mcpOperationActivity.ts
new file mode 100644
index 0000000..fbdc21c
--- /dev/null
+++ b/features/workspace/domain/mcpOperationActivity.ts
@@ -0,0 +1,22 @@
+export type McpOperationActivityStatus = "running" | "completed" | "failed";
+
+export type McpOperationActivityTarget =
+ | { kind: "presentation" }
+ | { kind: "slide"; slideIndex: number }
+ | { kind: "block"; nodeId: string; slideIndex: number };
+
+export type McpOperationActivity = {
+ clientId: string;
+ clientName: string;
+ completedAt?: string;
+ completedRevision?: number;
+ createdAt: string;
+ errorCode?: string;
+ expiresAt: string;
+ id: string;
+ presentationId: string;
+ status: McpOperationActivityStatus;
+ target: McpOperationActivityTarget;
+ toolName: string;
+ updatedAt: string;
+};
diff --git a/features/workspace/infrastructure/supabaseMcpOperationActivityRepository.test.ts b/features/workspace/infrastructure/supabaseMcpOperationActivityRepository.test.ts
new file mode 100644
index 0000000..dc5cc19
--- /dev/null
+++ b/features/workspace/infrastructure/supabaseMcpOperationActivityRepository.test.ts
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { parseSupabaseMcpOperationRealtimeChange } from "@/features/workspace/infrastructure/supabaseMcpOperationActivityRepository";
+
+const ownerId = "729c3ccc-09e6-47d8-a49f-66a8395c041c";
+const row = {
+ client_id: "slx_client_codex",
+ client_name: "Codex",
+ completed_at: null,
+ completed_revision: null,
+ created_at: "2026-07-19T01:00:00.000Z",
+ error_code: null,
+ expires_at: "2026-07-26T01:00:00.000Z",
+ id: "67bc1915-dcdc-4474-9061-9d3e8f862121",
+ node_id: "block-stable-id",
+ presentation_id: "3ffb8bd0-f055-415d-8ec5-29c7effdecd2",
+ slide_index: 2,
+ status: "running",
+ target_kind: "block",
+ tool_name: "slidex_update_canvas_node",
+ updated_at: "2026-07-19T01:00:00.000Z",
+ user_id: ownerId
+} as const;
+
+test("parses an owner-matching private MCP operation Broadcast", () => {
+ const change = parseSupabaseMcpOperationRealtimeChange({
+ event: "INSERT",
+ payload: {
+ operation: "INSERT",
+ record: row,
+ schema: "public",
+ table: "mcp_operation_events"
+ },
+ type: "broadcast"
+ }, ownerId);
+
+ assert.deepEqual(change, {
+ activity: {
+ clientId: "slx_client_codex",
+ clientName: "Codex",
+ completedAt: undefined,
+ completedRevision: undefined,
+ createdAt: "2026-07-19T01:00:00.000Z",
+ errorCode: undefined,
+ expiresAt: "2026-07-26T01:00:00.000Z",
+ id: "67bc1915-dcdc-4474-9061-9d3e8f862121",
+ presentationId: "3ffb8bd0-f055-415d-8ec5-29c7effdecd2",
+ status: "running",
+ target: { kind: "block", nodeId: "block-stable-id", slideIndex: 2 },
+ toolName: "slidex_update_canvas_node",
+ updatedAt: "2026-07-19T01:00:00.000Z"
+ },
+ event: "INSERT"
+ });
+});
+
+test("rejects a cross-owner or malformed MCP operation Broadcast", () => {
+ const message = {
+ event: "INSERT",
+ payload: {
+ operation: "INSERT",
+ record: row,
+ schema: "public",
+ table: "mcp_operation_events"
+ },
+ type: "broadcast"
+ };
+
+ assert.equal(parseSupabaseMcpOperationRealtimeChange(message, "another-user"), null);
+ assert.equal(parseSupabaseMcpOperationRealtimeChange({ ...message, event: "UPDATE" }, ownerId), null);
+});
diff --git a/features/workspace/infrastructure/supabaseMcpOperationActivityRepository.ts b/features/workspace/infrastructure/supabaseMcpOperationActivityRepository.ts
new file mode 100644
index 0000000..ddc49b3
--- /dev/null
+++ b/features/workspace/infrastructure/supabaseMcpOperationActivityRepository.ts
@@ -0,0 +1,123 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+
+import type {
+ McpOperationActivity,
+ McpOperationActivityTarget
+} from "@/features/workspace/domain/mcpOperationActivity";
+
+type OperationRow = {
+ client_id: string;
+ client_name: string;
+ completed_at: string | null;
+ completed_revision: number | null;
+ created_at: string;
+ error_code: string | null;
+ expires_at: string;
+ id: string;
+ node_id: string | null;
+ presentation_id: string;
+ slide_index: number | null;
+ status: "running" | "completed" | "failed";
+ target_kind: "presentation" | "slide" | "block";
+ tool_name: string;
+ updated_at: string;
+ user_id: string;
+};
+type SlideXSupabaseClient = SupabaseClient;
+
+const activityColumns = "id,user_id,presentation_id,client_id,client_name,tool_name,status,target_kind,slide_index,node_id,completed_revision,error_code,created_at,updated_at,completed_at,expires_at";
+
+export async function listSupabaseMcpOperationActivities(
+ client: SlideXSupabaseClient,
+ options: { limit?: number; presentationId?: string } = {}
+) {
+ let query = client
+ .from("mcp_operation_events")
+ .select(activityColumns)
+ .gt("expires_at", new Date().toISOString())
+ .order("created_at", { ascending: false })
+ .limit(Math.min(Math.max(options.limit ?? 50, 1), 100));
+
+ if (options.presentationId) query = query.eq("presentation_id", options.presentationId);
+ const { data, error } = await query;
+ if (error) throw error;
+ return data.map(toMcpOperationActivity);
+}
+
+export function parseSupabaseMcpOperationRealtimeChange(
+ value: unknown,
+ expectedOwnerId: string
+) {
+ if (
+ !isUnknownRecord(value) ||
+ value.type !== "broadcast" ||
+ !isUnknownRecord(value.payload) ||
+ value.payload.schema !== "public" ||
+ value.payload.table !== "mcp_operation_events"
+ ) return null;
+
+ const operation = value.payload.operation;
+ if (operation !== "INSERT" && operation !== "UPDATE" && operation !== "DELETE") return null;
+ if (value.event !== operation) return null;
+ const record = operation === "DELETE" ? value.payload.old_record : value.payload.record;
+ if (!isOperationRow(record) || record.user_id !== expectedOwnerId) return null;
+
+ return {
+ activity: toMcpOperationActivity(record),
+ event: operation
+ } as const;
+}
+
+function toMcpOperationActivity(row: OperationRow): McpOperationActivity {
+ return {
+ clientId: row.client_id,
+ clientName: row.client_name,
+ completedAt: row.completed_at ?? undefined,
+ completedRevision: row.completed_revision ?? undefined,
+ createdAt: row.created_at,
+ errorCode: row.error_code ?? undefined,
+ expiresAt: row.expires_at,
+ id: row.id,
+ presentationId: row.presentation_id,
+ status: row.status,
+ target: targetFromRow(row),
+ toolName: row.tool_name,
+ updatedAt: row.updated_at
+ };
+}
+
+function targetFromRow(row: OperationRow): McpOperationActivityTarget {
+ if (row.target_kind === "block" && row.slide_index !== null && row.node_id) {
+ return { kind: "block", nodeId: row.node_id, slideIndex: row.slide_index };
+ }
+ if (row.target_kind === "slide" && row.slide_index !== null) {
+ return { kind: "slide", slideIndex: row.slide_index };
+ }
+ return { kind: "presentation" };
+}
+
+function isOperationRow(value: unknown): value is OperationRow {
+ if (!isUnknownRecord(value)) return false;
+ return (
+ typeof value.id === "string" &&
+ typeof value.user_id === "string" &&
+ typeof value.presentation_id === "string" &&
+ typeof value.client_id === "string" &&
+ typeof value.client_name === "string" &&
+ typeof value.tool_name === "string" &&
+ (value.status === "running" || value.status === "completed" || value.status === "failed") &&
+ (value.target_kind === "presentation" || value.target_kind === "slide" || value.target_kind === "block") &&
+ (value.slide_index === null || typeof value.slide_index === "number") &&
+ (value.node_id === null || typeof value.node_id === "string") &&
+ (value.completed_revision === null || typeof value.completed_revision === "number") &&
+ (value.error_code === null || typeof value.error_code === "string") &&
+ typeof value.created_at === "string" &&
+ typeof value.updated_at === "string" &&
+ (value.completed_at === null || typeof value.completed_at === "string") &&
+ typeof value.expires_at === "string"
+ );
+}
+
+function isUnknownRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/features/workspace/ui/WorkspacePage.tsx b/features/workspace/ui/WorkspacePage.tsx
index e7513f0..022043f 100644
--- a/features/workspace/ui/WorkspacePage.tsx
+++ b/features/workspace/ui/WorkspacePage.tsx
@@ -28,6 +28,7 @@ import { useWorkspaceOnboarding } from "@/features/workspace/ui/hooks/useWorkspa
import { useWorkspacePrivacyMode } from "@/features/workspace/ui/hooks/useWorkspacePrivacyMode";
import { useSupabaseOfficialTemplates } from "@/features/workspace/ui/hooks/useSupabaseOfficialTemplates";
import { useSupabaseWorkspacePresentations } from "@/features/workspace/ui/hooks/useSupabaseWorkspacePresentations";
+import { useMcpOperationActivities } from "@/features/workspace/ui/hooks/useMcpOperationActivities";
import { useWorkspaceLazyLoad } from "@/features/workspace/ui/hooks/useWorkspaceLazyLoad";
import { WorkspaceOnboardingDialog } from "@/features/workspace/ui/onboarding/WorkspaceOnboardingDialog";
import { useWorkspaceI18n } from "@/features/workspace/ui/workspaceI18n";
@@ -104,6 +105,7 @@ export function WorkspacePage() {
renamePresentation: renameRemotePresentation,
totalCount: presentationCount
} = useSupabaseWorkspacePresentations(session?.user.id, deferredSearchQuery);
+ const { activities: mcpActivities } = useMcpOperationActivities(session?.user.id);
const lazyLoadMoreRef = useWorkspaceLazyLoad({
enabled: activeView === "presentations" && hasMorePresentations && !presentationsError,
isLoading: areMorePresentationsLoading,
@@ -218,6 +220,7 @@ export function WorkspacePage() {
onOpen={() => openPresentation(presentation.id)}
onRename={(title) => void renamePresentation(presentation.id, title)}
onToggleMenu={() => setMenuPresentationId((current) => current === presentation.id ? null : presentation.id)}
+ mcpActivities={mcpActivities.filter((activity) => activity.presentationId === presentation.id)}
presentation={presentation}
textSize={options?.textSize}
/>
diff --git a/features/workspace/ui/WorkspacePresentationCard.tsx b/features/workspace/ui/WorkspacePresentationCard.tsx
index c95a1a7..a879f06 100644
--- a/features/workspace/ui/WorkspacePresentationCard.tsx
+++ b/features/workspace/ui/WorkspacePresentationCard.tsx
@@ -1,6 +1,12 @@
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import Image from "next/image";
-import { Check, Copy, MoreHorizontal, Pencil, Trash2, X } from "lucide-react";
+import { Bot, Check, CheckCircle2, CircleAlert, Copy, MoreHorizontal, Pencil, Trash2, X } from "lucide-react";
+import {
+ isMcpOperationVisuallyActive,
+ mcpOperationAction,
+ mcpOperationVisualDeadline
+} from "@/features/workspace/application/mcpOperationActivity";
+import type { McpOperationActivity } from "@/features/workspace/domain/mcpOperationActivity";
import {
canDeleteWorkspacePresentation,
type WorkspacePresentation
@@ -10,6 +16,7 @@ import { useWorkspaceI18n } from "@/features/workspace/ui/workspaceI18n";
type WorkspacePresentationCardProps = {
isMenuOpen: boolean;
+ mcpActivities?: readonly McpOperationActivity[];
onDelete?: () => void;
onDuplicate: () => void;
onOpen: () => void;
@@ -52,14 +59,24 @@ function PresentationArtwork({ presentation }: { presentation: WorkspacePresenta
);
}
-export function WorkspacePresentationCard({ isMenuOpen, onDelete, onDuplicate, onOpen, onRename, onToggleMenu, presentation, textSize = "default" }: WorkspacePresentationCardProps) {
+export function WorkspacePresentationCard({ isMenuOpen, mcpActivities = [], onDelete, onDuplicate, onOpen, onRename, onToggleMenu, presentation, textSize = "default" }: WorkspacePresentationCardProps) {
const { locale, tx } = useWorkspaceI18n();
const [isRenaming, setIsRenaming] = useState(false);
const [draftTitle, setDraftTitle] = useState(presentation.title);
+ const [visualNow, setVisualNow] = useState(() => Date.now());
const menuContainerRef = useRef(null);
const menuRef = useRef(null);
const moreButtonRef = useRef(null);
const canDelete = canDeleteWorkspacePresentation(presentation) && Boolean(onDelete);
+ const mcpActivity = mcpActivities.find((activity) => isMcpOperationVisuallyActive(activity, visualNow));
+
+ useEffect(() => {
+ if (!mcpActivity) return;
+ const remaining = mcpOperationVisualDeadline(mcpActivity) - Date.now();
+ if (remaining <= 0) return;
+ const timeout = window.setTimeout(() => setVisualNow(Date.now()), remaining + 20);
+ return () => window.clearTimeout(timeout);
+ }, [mcpActivity]);
useEffect(() => {
if (!isMenuOpen) return;
@@ -122,6 +139,22 @@ export function WorkspacePresentationCard({ isMenuOpen, onDelete, onDuplicate, o
+ {mcpActivity ? (
+
+
+
+
+ {mcpActivity.status === "running" ? : mcpActivity.status === "completed" ? : }
+ AI · {mcpActivity.clientName}
+
+
{mcpOperationAction(mcpActivity, locale)}
+
+
+ ) : null}
diff --git a/features/workspace/ui/hooks/useMcpOperationActivities.ts b/features/workspace/ui/hooks/useMcpOperationActivities.ts
new file mode 100644
index 0000000..ae82960
--- /dev/null
+++ b/features/workspace/ui/hooks/useMcpOperationActivities.ts
@@ -0,0 +1,83 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+import { createSupabaseBrowserClient } from "@/common/lib/supabase/browserClient";
+import type { McpOperationActivity } from "@/features/workspace/domain/mcpOperationActivity";
+import {
+ listSupabaseMcpOperationActivities,
+ parseSupabaseMcpOperationRealtimeChange
+} from "@/features/workspace/infrastructure/supabaseMcpOperationActivityRepository";
+
+export function useMcpOperationActivities(userId?: string, presentationId?: string) {
+ const [activities, setActivities] = useState([]);
+ const [connectionWarning, setConnectionWarning] = useState(null);
+
+ const reload = useCallback(async () => {
+ if (!userId) {
+ setActivities([]);
+ return;
+ }
+ const next = await listSupabaseMcpOperationActivities(createSupabaseBrowserClient(), {
+ presentationId
+ });
+ setActivities(next);
+ }, [presentationId, userId]);
+
+ useEffect(() => {
+ let isCancelled = false;
+ void reload().catch(() => {
+ if (!isCancelled) setConnectionWarning("MCP activity is temporarily unavailable.");
+ });
+ return () => {
+ isCancelled = true;
+ };
+ }, [reload]);
+
+ useEffect(() => {
+ if (!userId) return;
+
+ const client = createSupabaseBrowserClient();
+ let channel: ReturnType | null = null;
+ let isCancelled = false;
+
+ void client.realtime.setAuth().then(() => {
+ if (isCancelled) return;
+ channel = client
+ .channel(`mcp-operation-events:${userId}`, { config: { private: true } })
+ .on("broadcast", { event: "*" }, (message) => {
+ const change = parseSupabaseMcpOperationRealtimeChange(message, userId);
+ if (!change || (presentationId && change.activity.presentationId !== presentationId)) return;
+ setActivities((current) => {
+ if (change.event === "DELETE") {
+ return current.filter((activity) => activity.id !== change.activity.id);
+ }
+ const next = [change.activity, ...current.filter((activity) => activity.id !== change.activity.id)];
+ return next
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
+ .slice(0, 50);
+ });
+ })
+ .subscribe((status) => {
+ if (isCancelled) return;
+ if (status === "SUBSCRIBED") {
+ setConnectionWarning(null);
+ void reload().catch(() => {
+ if (!isCancelled) setConnectionWarning("MCP activity reconnected, but recent operations could not be verified.");
+ });
+ } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
+ setConnectionWarning("MCP activity is temporarily unavailable.");
+ }
+ });
+ }).catch(() => {
+ if (!isCancelled) setConnectionWarning("MCP activity is temporarily unavailable.");
+ });
+
+ return () => {
+ isCancelled = true;
+ if (channel) void client.removeChannel(channel);
+ };
+ }, [presentationId, reload, userId]);
+
+ return { activities, connectionWarning };
+}
diff --git a/features/workspace/ui/useLocalPitchPresentation.ts b/features/workspace/ui/useLocalPitchPresentation.ts
index 27810f6..a02ba0b 100644
--- a/features/workspace/ui/useLocalPitchPresentation.ts
+++ b/features/workspace/ui/useLocalPitchPresentation.ts
@@ -40,6 +40,7 @@ import {
PresentationRevisionConflictError,
updateSupabasePresentation
} from "@/features/workspace/infrastructure/supabasePresentationRepository";
+import { useMcpOperationActivities } from "@/features/workspace/ui/hooks/useMcpOperationActivities";
export type PitchAccessMode = "authenticated" | "guest";
@@ -69,6 +70,10 @@ export function useLocalPitchPresentation() {
const presentationId = searchParams.get("presentation");
const isDemoEntry = searchParams.get("demo") === "1";
const userId = session?.user.id;
+ const {
+ activities: mcpActivities,
+ connectionWarning: mcpActivityWarning
+ } = useMcpOperationActivities(userId, presentationId ?? undefined);
const accessMode: PitchAccessMode = isDemoEntry && !session ? "guest" : "authenticated";
const agentSessionId = searchParams.get("agentSession") ?? undefined;
const syncWarning = conflictWarning ?? connectionWarning;
@@ -542,6 +547,8 @@ export function useLocalPitchPresentation() {
agentSessionId,
error,
isReady,
+ mcpActivities,
+ mcpActivityWarning,
openAgentSession,
presentation,
save,
diff --git a/mcp/operationActivity.test.ts b/mcp/operationActivity.test.ts
new file mode 100644
index 0000000..5401815
--- /dev/null
+++ b/mcp/operationActivity.test.ts
@@ -0,0 +1,19 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { safeMcpOperationErrorCode } from "@/mcp/operationActivity";
+
+test("MCP activity errors are reduced to bounded safe codes", () => {
+ assert.equal(
+ safeMcpOperationErrorCode(new Error("The presentation changed. Current revision is 92.")),
+ "revision_conflict"
+ );
+ assert.equal(
+ safeMcpOperationErrorCode(new Error("Presentation not found or not accessible for alice@example.com")),
+ "inaccessible"
+ );
+ assert.equal(
+ safeMcpOperationErrorCode(new Error("secret raw provider failure with user-authored text")),
+ "operation_failed"
+ );
+});
diff --git a/mcp/operationActivity.ts b/mcp/operationActivity.ts
new file mode 100644
index 0000000..917f1c7
--- /dev/null
+++ b/mcp/operationActivity.ts
@@ -0,0 +1,88 @@
+export type McpOperationTarget =
+ | { kind: "presentation" }
+ | { kind: "slide"; slideIndex: number }
+ | { kind: "block"; nodeId: string; slideIndex: number };
+
+export type StartMcpOperationInput = {
+ presentationId: string;
+ target: McpOperationTarget;
+ toolName: string;
+};
+
+export type CompleteMcpOperationInput = {
+ completedRevision?: number;
+ operationId: string;
+ target?: McpOperationTarget;
+};
+
+export type FailMcpOperationInput = {
+ errorCode: string;
+ operationId: string;
+};
+
+export interface McpOperationActivityStore {
+ completeOperation(input: CompleteMcpOperationInput): Promise;
+ failOperation(input: FailMcpOperationInput): Promise;
+ startOperation(input: StartMcpOperationInput): Promise;
+}
+
+export function safeMcpOperationErrorCode(error: unknown) {
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
+
+ if (message.includes("changed") || message.includes("revision")) {
+ return "revision_conflict";
+ }
+ if (message.includes("not found") || message.includes("not accessible")) {
+ return "inaccessible";
+ }
+ if (message.includes("rate") && message.includes("limit")) {
+ return "rate_limited";
+ }
+ if (message.includes("quota")) {
+ return "quota_exceeded";
+ }
+ if (message.includes("invalid") || message.includes("outside") || message.includes("must")) {
+ return "invalid_input";
+ }
+ return "operation_failed";
+}
+
+export async function safelyStartMcpOperation(
+ activity: McpOperationActivityStore | undefined,
+ input: StartMcpOperationInput
+) {
+ if (!activity) return undefined;
+ try {
+ return await activity.startOperation(input);
+ } catch {
+ return undefined;
+ }
+}
+
+export async function safelyCompleteMcpOperation(
+ activity: McpOperationActivityStore | undefined,
+ input: CompleteMcpOperationInput
+) {
+ if (!activity) return;
+ try {
+ await activity.completeOperation(input);
+ } catch {
+ // Activity visibility is best effort and must never weaken the underlying operation.
+ }
+}
+
+export async function safelyFailMcpOperation(
+ activity: McpOperationActivityStore | undefined,
+ operationId: string | undefined,
+ error: unknown
+) {
+ if (!activity || !operationId) return;
+ try {
+ await activity.failOperation({
+ errorCode: safeMcpOperationErrorCode(error),
+ operationId
+ });
+ } catch {
+ // Activity visibility is best effort and must never replace the original error.
+ }
+}
diff --git a/mcp/operationActivityMigration.test.ts b/mcp/operationActivityMigration.test.ts
new file mode 100644
index 0000000..0a9fea6
--- /dev/null
+++ b/mcp/operationActivityMigration.test.ts
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+const migrationUrl = new URL(
+ "../supabase/migrations/20260719093000_add_mcp_operation_events.sql",
+ import.meta.url
+);
+
+test("MCP operation events are owner-private, service-written, and content-safe", async () => {
+ const sql = await readFile(migrationUrl, "utf8");
+ const tableDefinition = sql.match(/create table public\.mcp_operation_events \(([\s\S]*?)\n\);/)?.[1] ?? "";
+
+ assert.match(sql, /alter table public\.mcp_operation_events enable row level security/);
+ assert.match(sql, /grant select on table public\.mcp_operation_events to authenticated/);
+ assert.match(sql, /grant select, insert, update, delete on table public\.mcp_operation_events to service_role/);
+ assert.match(sql, /user_id = \(select auth\.uid\(\)\)/);
+ assert.match(sql, /expires_at > statement_timestamp\(\)/);
+ assert.match(sql, /presentation not owned by operation event user/);
+ assert.doesNotMatch(tableDefinition, /\b(prompt|token|source|user_text|raw_error)\b/i);
+});
+
+test("MCP operation events enforce targets, transitions, private Broadcast, and hourly cleanup", async () => {
+ const sql = await readFile(migrationUrl, "utf8");
+
+ assert.match(sql, /target_kind in \('presentation', 'slide', 'block'\)/);
+ assert.match(sql, /target_kind = 'block' and slide_index is not null and node_id is not null/);
+ assert.match(sql, /old\.status <> 'running' or new\.status not in \('completed', 'failed'\)/);
+ assert.match(sql, /'mcp-operation-events:' \|\| owner_id::text/);
+ assert.match(sql, /realtime\.messages\.extension = 'broadcast'/);
+ assert.match(sql, /new\.expires_at := new\.created_at \+ interval '7 days'/);
+ assert.match(sql, /'purge-expired-mcp-operation-events',[\s\S]*?'0 \* \* \* \*'/);
+ assert.match(sql, /delete from public\.mcp_operation_events where expires_at <= statement_timestamp\(\)/);
+});
diff --git a/mcp/remotePresentationHelpers.ts b/mcp/remotePresentationHelpers.ts
index 90d84b0..69d170e 100644
--- a/mcp/remotePresentationHelpers.ts
+++ b/mcp/remotePresentationHelpers.ts
@@ -2,38 +2,111 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { summarizeMotionDoc } from "@/core/motion-doc/application/motionDocAutomation";
import { jsonMcpResult } from "@/mcp/mcpResults";
+import {
+ safelyCompleteMcpOperation,
+ safelyFailMcpOperation,
+ safelyStartMcpOperation,
+ type McpOperationActivityStore,
+ type McpOperationTarget
+} from "@/mcp/operationActivity";
import type { McpPresentation, McpPresentationStore } from "@/mcp/presentationStore";
+type MotionDocMutation = { source: string; [key: string]: unknown };
+
+type MutationActivityOptions = {
+ activity?: McpOperationActivityStore;
+ completedTarget?: (input: {
+ mutation: MotionDocMutation;
+ nextSource: string;
+ previousSource: string;
+ }) => McpOperationTarget;
+ target: McpOperationTarget | ((source: string) => McpOperationTarget);
+ toolName: string;
+};
+
export async function mutatePresentation(
store: McpPresentationStore,
presentationId: string | undefined,
expectedRevision: number,
- mutate: (source: string) => { source: string; [key: string]: unknown }
+ mutate: (source: string) => MotionDocMutation,
+ activityOptions?: MutationActivityOptions
) {
return runAsyncMcpTool(async () => {
const current = await store.getPresentation(presentationId);
- if (current.sourceRevision !== expectedRevision) {
- throw new Error(
- `The presentation changed. Current revision is ${current.sourceRevision}; read it again before saving.`
- );
- }
+ const initialTarget = resolveActivityTarget(activityOptions?.target, current.source);
+ const operationId = activityOptions
+ ? await safelyStartMcpOperation(activityOptions.activity, {
+ presentationId: current.id,
+ target: initialTarget,
+ toolName: activityOptions.toolName
+ })
+ : undefined;
- const mutation = mutate(current.source);
- assertValidSource(mutation.source);
- const presentation = await store.savePresentation({
- expectedRevision,
- presentationId: current.id,
- source: mutation.source
- });
+ try {
+ if (current.sourceRevision !== expectedRevision) {
+ throw new Error(
+ `The presentation changed. Current revision is ${current.sourceRevision}; read it again before saving.`
+ );
+ }
- const details: Record = { ...mutation };
- delete details.source;
- delete details.summary;
- return {
- ...details,
- autoSelected: presentationId === undefined,
- presentation
- };
+ const mutation = mutate(current.source);
+ assertValidSource(mutation.source);
+ const presentation = await store.savePresentation({
+ expectedRevision,
+ presentationId: current.id,
+ source: mutation.source
+ });
+ const completedTarget = activityOptions?.completedTarget
+ ? resolveCompletedActivityTarget(activityOptions.completedTarget, {
+ mutation,
+ nextSource: mutation.source,
+ previousSource: current.source
+ }, initialTarget)
+ : initialTarget;
+ if (operationId) {
+ await safelyCompleteMcpOperation(activityOptions?.activity, {
+ completedRevision: presentation.sourceRevision,
+ operationId,
+ target: completedTarget
+ });
+ }
+
+ const details: Record = { ...mutation };
+ delete details.source;
+ delete details.summary;
+ return {
+ ...details,
+ autoSelected: presentationId === undefined,
+ presentation
+ };
+ } catch (error) {
+ await safelyFailMcpOperation(activityOptions?.activity, operationId, error);
+ throw error;
+ }
+ });
+}
+
+export function runTrackedMcpTool(
+ activity: McpOperationActivityStore | undefined,
+ input: {
+ presentationId: string;
+ target: McpOperationTarget;
+ toolName: string;
+ },
+ callback: () => Promise
+) {
+ return runAsyncMcpTool(async () => {
+ const operationId = await safelyStartMcpOperation(activity, input);
+ try {
+ const result = await callback();
+ if (operationId) {
+ await safelyCompleteMcpOperation(activity, { operationId, target: input.target });
+ }
+ return result;
+ } catch (error) {
+ await safelyFailMcpOperation(activity, operationId, error);
+ throw error;
+ }
});
}
@@ -85,3 +158,27 @@ export async function runAsyncMcpTool(
};
}
}
+
+function resolveActivityTarget(
+ target: MutationActivityOptions["target"] | undefined,
+ source: string
+): McpOperationTarget {
+ if (!target) return { kind: "presentation" };
+ try {
+ return typeof target === "function" ? target(source) : target;
+ } catch {
+ return { kind: "presentation" };
+ }
+}
+
+function resolveCompletedActivityTarget(
+ resolver: NonNullable,
+ input: Parameters>[0],
+ fallback: McpOperationTarget
+) {
+ try {
+ return resolver(input);
+ } catch {
+ return fallback;
+ }
+}
diff --git a/mcp/remotePresentationImageUploadMcp.ts b/mcp/remotePresentationImageUploadMcp.ts
index adcb125..08eb683 100644
--- a/mcp/remotePresentationImageUploadMcp.ts
+++ b/mcp/remotePresentationImageUploadMcp.ts
@@ -3,7 +3,8 @@ import { z } from "zod/v4";
import type { McpPresentationImageUploadStore } from "@/mcp/presentationImageUploadStore";
import { mcpPresentationImageMimeTypes } from "@/mcp/presentationImageUploadStore";
-import { runAsyncMcpTool } from "@/mcp/remotePresentationHelpers";
+import type { McpOperationActivityStore } from "@/mcp/operationActivity";
+import { runTrackedMcpTool } from "@/mcp/remotePresentationHelpers";
import { requiredPresentationIdSchema } from "@/mcp/remotePresentationSchemas";
const maximumImageBytes = 10 * 1024 * 1024;
@@ -16,7 +17,8 @@ export type RemotePresentationImageUploadOptions = {
export function registerRemotePresentationImageUploadTools(
server: McpServer,
- options: RemotePresentationImageUploadOptions
+ options: RemotePresentationImageUploadOptions,
+ activity?: McpOperationActivityStore
) {
server.registerTool(
"slidex_prepare_presentation_image_upload",
@@ -31,13 +33,21 @@ export function registerRemotePresentationImageUploadTools(
}
},
({ byteLength, contentType, presentationId }) =>
- runAsyncMcpTool(() => options.store.prepareUpload({
- byteLength,
- contentType,
- origin: options.origin,
- presentationId,
- userId: options.userId
- }))
+ runTrackedMcpTool(
+ activity,
+ {
+ presentationId,
+ target: { kind: "presentation" },
+ toolName: "slidex_prepare_presentation_image_upload"
+ },
+ () => options.store.prepareUpload({
+ byteLength,
+ contentType,
+ origin: options.origin,
+ presentationId,
+ userId: options.userId
+ })
+ )
);
server.registerTool(
@@ -52,10 +62,18 @@ export function registerRemotePresentationImageUploadTools(
}
},
({ presentationId, uploadId }) =>
- runAsyncMcpTool(() => options.store.finalizeUpload({
- presentationId,
- uploadId,
- userId: options.userId
- }))
+ runTrackedMcpTool(
+ activity,
+ {
+ presentationId,
+ target: { kind: "presentation" },
+ toolName: "slidex_finalize_presentation_image_upload"
+ },
+ () => options.store.finalizeUpload({
+ presentationId,
+ uploadId,
+ userId: options.userId
+ })
+ )
);
}
diff --git a/mcp/remotePresentationMcp.ts b/mcp/remotePresentationMcp.ts
index e71d5da..94ab4ee 100644
--- a/mcp/remotePresentationMcp.ts
+++ b/mcp/remotePresentationMcp.ts
@@ -1,6 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { McpPresentationStore } from "@/mcp/presentationStore";
+import type { McpOperationActivityStore } from "@/mcp/operationActivity";
import {
registerRemotePresentationImageUploadTools,
type RemotePresentationImageUploadOptions
@@ -11,20 +12,21 @@ import { registerRemotePresentationWriteTools } from "@/mcp/remotePresentationWr
type RemotePresentationMcpOptions = {
enableWrites: boolean;
imageUploads?: RemotePresentationImageUploadOptions;
+ operationActivity?: McpOperationActivityStore;
presentationStore: McpPresentationStore;
};
export function registerRemotePresentationMcp(
server: McpServer,
- { enableWrites, imageUploads, presentationStore }: RemotePresentationMcpOptions
+ { enableWrites, imageUploads, operationActivity, presentationStore }: RemotePresentationMcpOptions
) {
registerRemotePresentationReadTools(server, presentationStore);
if (enableWrites) {
- registerRemotePresentationWriteTools(server, presentationStore);
+ registerRemotePresentationWriteTools(server, presentationStore, operationActivity);
}
if (imageUploads) {
- registerRemotePresentationImageUploadTools(server, imageUploads);
+ registerRemotePresentationImageUploadTools(server, imageUploads, operationActivity);
}
}
diff --git a/mcp/remotePresentationWriteMcp.ts b/mcp/remotePresentationWriteMcp.ts
index c8276f5..4a35057 100644
--- a/mcp/remotePresentationWriteMcp.ts
+++ b/mcp/remotePresentationWriteMcp.ts
@@ -9,7 +9,10 @@ import {
reorderMotionDocBlock,
updateMotionDocBlock
} from "@/core/motion-doc/application/motionDocAutomation";
-import { updateMotionDocCanvasNodeFrame } from "@/core/motion-doc/application/motionDocCanvas";
+import {
+ getMotionDocCanvasNodes,
+ updateMotionDocCanvasNodeFrame
+} from "@/core/motion-doc/application/motionDocCanvas";
import {
addMotionDocSlideFromLayout,
replaceMotionDocSlideWithLayout
@@ -20,6 +23,7 @@ import {
motionDocPropsSchema
} from "@/mcp/motionDocMcpSchema";
import type { McpPresentationStore } from "@/mcp/presentationStore";
+import type { McpOperationActivityStore, McpOperationTarget } from "@/mcp/operationActivity";
import { mutatePresentation } from "@/mcp/remotePresentationHelpers";
import {
blockIndexSchema,
@@ -34,7 +38,8 @@ import { applyMcpShaderPreset } from "@/mcp/shaderMcp";
export function registerRemotePresentationWriteTools(
server: McpServer,
- store: McpPresentationStore
+ store: McpPresentationStore,
+ activity?: McpOperationActivityStore
) {
server.registerTool(
"slidex_save_presentation",
@@ -49,7 +54,13 @@ export function registerRemotePresentationWriteTools(
}
},
({ expectedRevision, presentationId, source }) =>
- mutatePresentation(store, presentationId, expectedRevision, () => ({ source }))
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ () => ({ source }),
+ tracked(activity, "slidex_save_presentation", { kind: "presentation" })
+ )
);
server.registerTool(
@@ -70,8 +81,22 @@ export function registerRemotePresentationWriteTools(
}
},
({ afterBlockIndex, expectedRevision, position, presentationId, props, slideIndex, text, type }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- addMotionDocBlock(source, slideIndex, type, { afterBlockIndex, position, props, text })
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => addMotionDocBlock(
+ source,
+ slideIndex,
+ type,
+ { afterBlockIndex, position, props, text }
+ ),
+ tracked(
+ activity,
+ "slidex_add_block",
+ { kind: "slide", slideIndex },
+ ({ mutation, nextSource }) => blockTarget(nextSource, slideIndex, mutation.blockIndex)
+ )
)
);
@@ -90,8 +115,16 @@ export function registerRemotePresentationWriteTools(
}
},
({ blockIndex, expectedRevision, presentationId, props, slideIndex, text }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- updateMotionDocBlock(source, slideIndex, blockIndex, { props, text })
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => updateMotionDocBlock(source, slideIndex, blockIndex, { props, text }),
+ tracked(
+ activity,
+ "slidex_update_block",
+ (source) => blockTarget(source, slideIndex, blockIndex)
+ )
)
);
@@ -110,8 +143,12 @@ export function registerRemotePresentationWriteTools(
}
},
({ expectedRevision, frame, nodeId, presentationId, slideIndex }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- updateMotionDocCanvasNodeFrame(source, slideIndex, nodeId, frame)
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => updateMotionDocCanvasNodeFrame(source, slideIndex, nodeId, frame),
+ tracked(activity, "slidex_update_canvas_node", { kind: "block", nodeId, slideIndex })
)
);
@@ -128,8 +165,17 @@ export function registerRemotePresentationWriteTools(
}
},
({ blockIndex, expectedRevision, presentationId, slideIndex }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- deleteMotionDocBlock(source, slideIndex, blockIndex)
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => deleteMotionDocBlock(source, slideIndex, blockIndex),
+ tracked(
+ activity,
+ "slidex_delete_block",
+ (source) => blockTarget(source, slideIndex, blockIndex),
+ () => ({ kind: "slide", slideIndex })
+ )
)
);
@@ -147,8 +193,17 @@ export function registerRemotePresentationWriteTools(
}
},
({ blockIndex, expectedRevision, offset, presentationId, slideIndex }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- duplicateMotionDocBlock(source, slideIndex, blockIndex, offset)
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => duplicateMotionDocBlock(source, slideIndex, blockIndex, offset),
+ tracked(
+ activity,
+ "slidex_duplicate_block",
+ (source) => blockTarget(source, slideIndex, blockIndex),
+ ({ mutation, nextSource }) => blockTarget(nextSource, slideIndex, mutation.blockIndex)
+ )
)
);
@@ -166,8 +221,16 @@ export function registerRemotePresentationWriteTools(
}
},
({ expectedRevision, fromIndex, presentationId, slideIndex, toIndex }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- reorderMotionDocBlock(source, slideIndex, fromIndex, toIndex)
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => reorderMotionDocBlock(source, slideIndex, fromIndex, toIndex),
+ tracked(
+ activity,
+ "slidex_reorder_block",
+ (source) => blockTarget(source, slideIndex, fromIndex)
+ )
)
);
@@ -185,8 +248,12 @@ export function registerRemotePresentationWriteTools(
}
},
({ expectedRevision, presentationId, presetName, shaderId, slideIndex }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) =>
- applyMcpShaderPreset(source, slideIndex, shaderId, presetName)
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => applyMcpShaderPreset(source, slideIndex, shaderId, presetName),
+ tracked(activity, "slidex_apply_shader_preset", { kind: "slide", slideIndex })
)
);
@@ -204,14 +271,30 @@ export function registerRemotePresentationWriteTools(
}
},
({ afterSlideIndex, expectedRevision, layoutId, presentationId, ...options }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) => ({
- source: addMotionDocSlideFromLayout(
- source,
- getLayout(layoutId).source,
- afterSlideIndex,
- { ...options, layoutId }
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => ({
+ source: addMotionDocSlideFromLayout(
+ source,
+ getLayout(layoutId).source,
+ afterSlideIndex,
+ { ...options, layoutId }
+ )
+ }),
+ tracked(
+ activity,
+ "slidex_add_slide_from_layout",
+ { kind: "presentation" },
+ ({ nextSource }) => ({
+ kind: "slide",
+ slideIndex: afterSlideIndex === undefined
+ ? getMotionDocCanvasNodes(nextSource).slides.length - 1
+ : afterSlideIndex + 1
+ })
)
- }))
+ )
);
server.registerTool(
@@ -228,13 +311,49 @@ export function registerRemotePresentationWriteTools(
}
},
({ expectedRevision, layoutId, presentationId, slideIndex, ...options }) =>
- mutatePresentation(store, presentationId, expectedRevision, (source) => ({
- source: replaceMotionDocSlideWithLayout(
- source,
- slideIndex,
- getLayout(layoutId).source,
- { ...options, layoutId }
+ mutatePresentation(
+ store,
+ presentationId,
+ expectedRevision,
+ (source) => ({
+ source: replaceMotionDocSlideWithLayout(
+ source,
+ slideIndex,
+ getLayout(layoutId).source,
+ { ...options, layoutId }
+ )
+ }),
+ tracked(
+ activity,
+ "slidex_replace_slide_with_layout",
+ { kind: "slide", slideIndex }
)
- }))
+ )
+ );
+}
+
+function blockTarget(
+ source: string,
+ slideIndex: number,
+ blockIndexValue: unknown
+): McpOperationTarget {
+ const blockIndex = Number(blockIndexValue);
+ const node = getMotionDocCanvasNodes(source, slideIndex).slides[0]?.nodes.find(
+ (candidate) => candidate.blockIndex === blockIndex
);
+ if (!node) throw new Error(`blockIndex ${blockIndex} is outside slide ${slideIndex}.`);
+ return { kind: "block", nodeId: node.nodeId, slideIndex };
+}
+
+function tracked(
+ activity: McpOperationActivityStore | undefined,
+ toolName: string,
+ target: McpOperationTarget | ((source: string) => McpOperationTarget),
+ completedTarget?: (input: {
+ mutation: { source: string; [key: string]: unknown };
+ nextSource: string;
+ previousSource: string;
+ }) => McpOperationTarget
+) {
+ return { activity, completedTarget, target, toolName };
}
diff --git a/mcp/server.test.ts b/mcp/server.test.ts
index 2647058..bc459ed 100644
--- a/mcp/server.test.ts
+++ b/mcp/server.test.ts
@@ -9,6 +9,12 @@ import type {
McpPresentationImageUploadStore,
PreparedMcpPresentationImageUpload
} from "@/mcp/presentationImageUploadStore";
+import type {
+ CompleteMcpOperationInput,
+ FailMcpOperationInput,
+ McpOperationActivityStore,
+ StartMcpOperationInput
+} from "@/mcp/operationActivity";
import type { McpPresentationStore } from "@/mcp/presentationStore";
const presentationId = "89e45398-5fd4-4b7b-b9cf-0954dd2ac364";
@@ -22,6 +28,7 @@ test("local MCP exposes the bounded block, shader, schema, and PPTX tools", asyn
const connection = await connectMcp({ enableWorkspaceSkills: false });
try {
+ assert.equal(connection.client.getServerVersion()?.version, "0.5.0");
const tools = await connection.client.listTools();
const names = tools.tools.map((tool) => tool.name);
@@ -111,8 +118,10 @@ test("remote MCP keeps reads available but gates saves and local PPTX export", a
await readOnly.close();
}
+ const operationActivity = createOperationActivityStore();
const writable = await connectMcp({
enablePresentationWrites: true,
+ operationActivity,
profile: "remote",
presentationStore: store
});
@@ -188,6 +197,23 @@ test("remote MCP keeps reads available but gates saves and local PPTX export", a
.result.presentation.source,
/shader="mesh-gradient"/
);
+
+ assert.deepEqual(
+ operationActivity.started.map(({ target, toolName }) => ({ target, toolName })),
+ [
+ { target: { kind: "presentation" }, toolName: "slidex_save_presentation" },
+ { target: { kind: "block", nodeId: "Text-legacy-0", slideIndex: 0 }, toolName: "slidex_update_canvas_node" },
+ { target: { kind: "slide", slideIndex: 0 }, toolName: "slidex_apply_shader_preset" }
+ ]
+ );
+ assert.deepEqual(
+ operationActivity.completed.map(({ completedRevision, target }) => ({ completedRevision, target })),
+ [
+ { completedRevision: 5, target: { kind: "presentation" } },
+ { completedRevision: 5, target: { kind: "block", nodeId: "Text-legacy-0", slideIndex: 0 } },
+ { completedRevision: 5, target: { kind: "slide", slideIndex: 0 } }
+ ]
+ );
} finally {
await writable.close();
}
@@ -195,6 +221,7 @@ test("remote MCP keeps reads available but gates saves and local PPTX export", a
test("remote MCP exposes private image tools only when the asset scope is configured", async () => {
const imageStore = createImageUploadStore();
+ const operationActivity = createOperationActivityStore();
const connection = await connectMcp({
enablePresentationWrites: false,
imageUploads: {
@@ -202,6 +229,7 @@ test("remote MCP exposes private image tools only when the asset scope is config
store: imageStore,
userId: "893301ee-2be1-4c01-8827-c13788233c24"
},
+ operationActivity,
profile: "remote",
presentationStore: createPresentationStore()
});
@@ -240,6 +268,124 @@ test("remote MCP exposes private image tools only when the asset scope is config
(finalized.structuredContent as { result: { src: string } }).result.src,
/^\/api\/presentation-images\//
);
+ assert.deepEqual(
+ operationActivity.started.map(({ target, toolName }) => ({ target, toolName })),
+ [
+ { target: { kind: "presentation" }, toolName: "slidex_prepare_presentation_image_upload" },
+ { target: { kind: "presentation" }, toolName: "slidex_finalize_presentation_image_upload" }
+ ]
+ );
+ assert.equal(operationActivity.completed.every((item) => item.completedRevision === undefined), true);
+ } finally {
+ await connection.close();
+ }
+});
+
+test("remote writes map every mutation to a safe visual target and sanitize conflicts", async () => {
+ const operationActivity = createOperationActivityStore();
+ const connection = await connectMcp({
+ enablePresentationWrites: true,
+ operationActivity,
+ profile: "remote",
+ presentationStore: createPresentationStore()
+ });
+
+ try {
+ const calls = [
+ {
+ arguments: { expectedRevision: 4, presentationId, slideIndex: 0, type: "ShapeRectangle" },
+ name: "slidex_add_block"
+ },
+ {
+ arguments: { blockIndex: 0, expectedRevision: 4, presentationId, slideIndex: 0, text: "Updated" },
+ name: "slidex_update_block"
+ },
+ {
+ arguments: { blockIndex: 0, expectedRevision: 4, presentationId, slideIndex: 0 },
+ name: "slidex_delete_block"
+ },
+ {
+ arguments: { blockIndex: 0, expectedRevision: 4, offset: 2, presentationId, slideIndex: 0 },
+ name: "slidex_duplicate_block"
+ },
+ {
+ arguments: { expectedRevision: 4, fromIndex: 0, presentationId, slideIndex: 0, toIndex: 0 },
+ name: "slidex_reorder_block"
+ },
+ {
+ arguments: { expectedRevision: 4, layoutId: "title", presentationId },
+ name: "slidex_add_slide_from_layout"
+ },
+ {
+ arguments: { expectedRevision: 4, layoutId: "title", presentationId, slideIndex: 0 },
+ name: "slidex_replace_slide_with_layout"
+ }
+ ];
+
+ for (const call of calls) {
+ const result = await connection.client.callTool(call);
+ assert.equal(result.isError, undefined, `${call.name} succeeds`);
+ }
+
+ assert.deepEqual(
+ operationActivity.started.map(({ target, toolName }) => ({ kind: target.kind, toolName })),
+ [
+ { kind: "slide", toolName: "slidex_add_block" },
+ { kind: "block", toolName: "slidex_update_block" },
+ { kind: "block", toolName: "slidex_delete_block" },
+ { kind: "block", toolName: "slidex_duplicate_block" },
+ { kind: "block", toolName: "slidex_reorder_block" },
+ { kind: "presentation", toolName: "slidex_add_slide_from_layout" },
+ { kind: "slide", toolName: "slidex_replace_slide_with_layout" }
+ ]
+ );
+ assert.deepEqual(
+ operationActivity.completed.map(({ target }) => target?.kind),
+ ["block", "block", "slide", "block", "block", "slide", "slide"]
+ );
+
+ const conflict = await connection.client.callTool({
+ arguments: { expectedRevision: 3, presentationId, source },
+ name: "slidex_save_presentation"
+ });
+ assert.equal(conflict.isError, true);
+ assert.equal(operationActivity.failed.at(-1)?.errorCode, "revision_conflict");
+ assert.ok(!JSON.stringify(operationActivity.failed).includes("Current revision"));
+ } finally {
+ await connection.close();
+ }
+});
+
+test("activity recording failures never fail an authorized revision-safe write", async () => {
+ const unavailableActivity: McpOperationActivityStore = {
+ async completeOperation() {
+ throw new Error("activity database unavailable");
+ },
+ async failOperation() {
+ throw new Error("activity database unavailable");
+ },
+ async startOperation() {
+ throw new Error("activity database unavailable");
+ }
+ };
+ const connection = await connectMcp({
+ enablePresentationWrites: true,
+ operationActivity: unavailableActivity,
+ profile: "remote",
+ presentationStore: createPresentationStore()
+ });
+
+ try {
+ const result = await connection.client.callTool({
+ arguments: { expectedRevision: 4, presentationId, source },
+ name: "slidex_save_presentation"
+ });
+ assert.equal(result.isError, undefined);
+ assert.equal(
+ (result.structuredContent as { result: { presentation: { sourceRevision: number } } })
+ .result.presentation.sourceRevision,
+ 5
+ );
} finally {
await connection.close();
}
@@ -280,6 +426,31 @@ function createPresentationStore(): McpPresentationStore {
};
}
+function createOperationActivityStore(): McpOperationActivityStore & {
+ completed: CompleteMcpOperationInput[];
+ failed: FailMcpOperationInput[];
+ started: StartMcpOperationInput[];
+} {
+ const completed: CompleteMcpOperationInput[] = [];
+ const failed: FailMcpOperationInput[] = [];
+ const started: StartMcpOperationInput[] = [];
+ return {
+ completed,
+ failed,
+ started,
+ async completeOperation(input) {
+ completed.push(input);
+ },
+ async failOperation(input) {
+ failed.push(input);
+ },
+ async startOperation(input) {
+ started.push(input);
+ return `operation-${started.length}`;
+ }
+ };
+}
+
function createImageUploadStore(): McpPresentationImageUploadStore {
return {
async claimUpload() {
@@ -327,7 +498,7 @@ function createImageUploadStore(): McpPresentationImageUploadStore {
async function connectMcp(options: Parameters[0]) {
const server = createSlideXMcpServer(options);
- const client = new Client({ name: "slidex-contract-test", version: "0.4.0" });
+ const client = new Client({ name: "slidex-contract-test", version: "0.5.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
diff --git a/mcp/server.ts b/mcp/server.ts
index 0214c20..3a51f43 100644
--- a/mcp/server.ts
+++ b/mcp/server.ts
@@ -34,6 +34,7 @@ import {
} from "./motionDocMcpSchema";
import { exportMotionDocPptx } from "./pptxExport";
import type { McpPresentationStore } from "./presentationStore";
+import type { McpOperationActivityStore } from "./operationActivity";
import type { RemotePresentationImageUploadOptions } from "./remotePresentationImageUploadMcp";
import { registerRemotePresentationMcp } from "./remotePresentationMcp";
import { registerShaderMcp } from "./shaderMcp";
@@ -44,6 +45,7 @@ export type SlideXMcpServerOptions = {
enablePresentationWrites?: boolean;
enableWorkspaceSkills?: boolean;
imageUploads?: RemotePresentationImageUploadOptions;
+ operationActivity?: McpOperationActivityStore;
profile?: "local" | "remote";
presentationStore?: McpPresentationStore;
};
@@ -55,7 +57,7 @@ const blockIndexSchema = z.number().int().min(0);
export function createSlideXMcpServer(options: SlideXMcpServerOptions = {}) {
const server = new McpServer({
name: "slidex-motion-doc",
- version: "0.4.0"
+ version: "0.5.0"
});
if (options.profile === "remote") {
@@ -66,6 +68,7 @@ export function createSlideXMcpServer(options: SlideXMcpServerOptions = {}) {
registerRemotePresentationMcp(server, {
enableWrites: options.enablePresentationWrites !== false,
imageUploads: options.imageUploads,
+ operationActivity: options.operationActivity,
presentationStore: options.presentationStore
});
return server;
diff --git a/mcp/supabaseOperationActivityStore.ts b/mcp/supabaseOperationActivityStore.ts
new file mode 100644
index 0000000..f339b1b
--- /dev/null
+++ b/mcp/supabaseOperationActivityStore.ts
@@ -0,0 +1,91 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+
+import type { Database } from "@/common/lib/supabase/database.types";
+import type {
+ CompleteMcpOperationInput,
+ FailMcpOperationInput,
+ McpOperationActivityStore,
+ McpOperationTarget,
+ StartMcpOperationInput
+} from "@/mcp/operationActivity";
+
+type SupabaseOperationActivityContext = {
+ clientId: string;
+ clientName: string;
+ userId: string;
+};
+
+export class SupabaseMcpOperationActivityStore implements McpOperationActivityStore {
+ constructor(
+ private readonly client: SupabaseClient,
+ private readonly context: SupabaseOperationActivityContext
+ ) {}
+
+ async startOperation(input: StartMcpOperationInput) {
+ const { data, error } = await this.client
+ .from("mcp_operation_events")
+ .insert({
+ client_id: this.context.clientId,
+ client_name: this.context.clientName,
+ presentation_id: input.presentationId,
+ status: "running",
+ tool_name: input.toolName,
+ user_id: this.context.userId,
+ ...targetColumns(input.target)
+ })
+ .select("id")
+ .single();
+
+ if (error) throw error;
+ return data.id;
+ }
+
+ async completeOperation(input: CompleteMcpOperationInput) {
+ const update: Database["public"]["Tables"]["mcp_operation_events"]["Update"] = {
+ completed_at: new Date().toISOString(),
+ completed_revision: input.completedRevision,
+ status: "completed"
+ };
+ if (input.target) Object.assign(update, targetColumns(input.target));
+
+ const { error } = await this.client
+ .from("mcp_operation_events")
+ .update(update)
+ .eq("id", input.operationId)
+ .eq("user_id", this.context.userId)
+ .eq("status", "running");
+
+ if (error) throw error;
+ }
+
+ async failOperation(input: FailMcpOperationInput) {
+ const { error } = await this.client
+ .from("mcp_operation_events")
+ .update({
+ completed_at: new Date().toISOString(),
+ error_code: input.errorCode,
+ status: "failed"
+ })
+ .eq("id", input.operationId)
+ .eq("user_id", this.context.userId)
+ .eq("status", "running");
+
+ if (error) throw error;
+ }
+}
+
+function targetColumns(target: McpOperationTarget) {
+ if (target.kind === "presentation") {
+ return {
+ node_id: null,
+ slide_index: null,
+ target_kind: target.kind
+ } as const;
+ }
+
+ return {
+ node_id: target.kind === "block" ? target.nodeId : null,
+ slide_index: target.slideIndex,
+ target_kind: target.kind
+ } as const;
+}
diff --git a/package.json b/package.json
index 544fc62..4b6066d 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"mcp:test:tarball": "node scripts/smoke-mcp-tarball.mjs",
"test:agent": "esbuild features/pitch/infrastructure/slidexAgentProtocol.test.mts --bundle --platform=node --format=esm --outfile=.next/agent-protocol-test.mjs && node --test .next/agent-protocol-test.mjs",
"test:agent:e2e": "playwright test",
+ "test:mcp-activity:e2e": "playwright test --config=playwright.mcp-activity.config.ts",
"test:agent:e2e:install": "playwright install chromium",
"supabase:start": "supabase start",
"supabase:stop": "supabase stop",
diff --git a/packages/slidex-mcp-server/README.md b/packages/slidex-mcp-server/README.md
index 33ad679..0f4d384 100644
--- a/packages/slidex-mcp-server/README.md
+++ b/packages/slidex-mcp-server/README.md
@@ -1,4 +1,4 @@
-# SlideX MCP Server v0.4
+# SlideX MCP Server v0.5
Local MCP server for SlideX MotionDoc decks. It lets MCP clients create and validate decks, edit slides and blocks, apply Paper Shader presets, export standalone HTML, and write editable PowerPoint files.
@@ -126,6 +126,8 @@ Remote read tools automatically select the authenticated user's most recently op
Every Remote write requires the automatically discovered `presentationId` plus `expectedRevision`, performs a compare-and-swap save, and triggers Animark's existing private presentation Realtime broadcast so an open Pitch page receives the new revision. Revision conflicts reject the save and require the client to read again. Ownership validation applies to both discovery and mutation.
+Starting in v0.5, real Remote write and private-image tool operations also emit owner-private activity events. An open SlideX Workspace or Pitch editor renders these as non-interactive purple frames labelled with the authorized OAuth client name. The activity channel does not simulate a cursor or DOM clicks and never stores prompts, tokens, complete MotionDoc source, user-authored text, or raw errors. Activity summaries expire after seven days.
+
Remote MCP deliberately does not expose deck creation, template cloning, local HTML/PPTX export, workspace CRUD, presentation deletion, video/SVG upload, or public media URLs. Image upload requires a ten-minute single-use upload request, writes only normalized static WebP files to the private `presentation-images` bucket, and never exposes the service-role credential. The Animark server must configure `SUPABASE_SERVICE_ROLE_KEY`; this value is server-only and must never be placed in a browser, MCP client configuration, or any `NEXT_PUBLIC_*` variable.
## Built-in Slide Layouts
diff --git a/packages/slidex-mcp-server/package.json b/packages/slidex-mcp-server/package.json
index 7fee899..d0b0997 100644
--- a/packages/slidex-mcp-server/package.json
+++ b/packages/slidex-mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@z7589xxz758/slidex-mcp-server",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "MCP server for creating, validating, editing, and exporting SlideX MotionDoc decks.",
"type": "module",
"bin": {
diff --git a/playwright.config.ts b/playwright.config.ts
index fb9c231..5e90284 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test";
const port = 3100;
const baseURL = `http://127.0.0.1:${port}`;
+const supabaseFixturePort = 54328;
+const supabaseFixtureURL = `http://127.0.0.1:${supabaseFixturePort}`;
export default defineConfig({
testDir: "./tests/browser",
@@ -17,16 +19,24 @@ export default defineConfig({
trace: "retain-on-failure",
video: "retain-on-failure"
},
- webServer: {
- command: "npm run dev:no-clean -- --hostname 127.0.0.1 --port 3100",
- env: {
- NEXT_PUBLIC_SLIDEX_AGENT_ENABLED: "true",
- NEXT_PUBLIC_SLIDEX_AGENT_SERVER_URL: baseURL,
- NEXT_PUBLIC_SUPABASE_URL: baseURL,
- NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "test-publishable-key"
+ webServer: [
+ {
+ command: `node tests/browser/supabase-fixture.mjs --port ${supabaseFixturePort} --app-origin ${baseURL}`,
+ reuseExistingServer: false,
+ timeout: 30_000,
+ url: `${supabaseFixtureURL}/health`
},
- reuseExistingServer: !process.env.CI,
- timeout: 120_000,
- url: `${baseURL}/workspace/pitch/`
- }
+ {
+ command: "npm run dev:no-clean -- --hostname 127.0.0.1 --port 3100",
+ env: {
+ NEXT_PUBLIC_SLIDEX_AGENT_ENABLED: "true",
+ NEXT_PUBLIC_SLIDEX_AGENT_SERVER_URL: baseURL,
+ NEXT_PUBLIC_SUPABASE_URL: supabaseFixtureURL,
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "test-publishable-key"
+ },
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ url: `${baseURL}/workspace/pitch/`
+ }
+ ]
});
diff --git a/playwright.mcp-activity.config.ts b/playwright.mcp-activity.config.ts
new file mode 100644
index 0000000..d2a1d8c
--- /dev/null
+++ b/playwright.mcp-activity.config.ts
@@ -0,0 +1,39 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const appPort = 3102;
+const fixturePort = 54328;
+const baseURL = `http://127.0.0.1:${appPort}`;
+const fixtureURL = `http://127.0.0.1:${fixturePort}`;
+
+export default defineConfig({
+ testDir: "./tests/browser/mcp-activity",
+ fullyParallel: false,
+ retries: 0,
+ workers: 1,
+ reporter: "line",
+ use: {
+ ...devices["Desktop Chrome"],
+ baseURL,
+ screenshot: "only-on-failure",
+ trace: "retain-on-failure"
+ },
+ webServer: [
+ {
+ command: `node tests/browser/supabase-fixture.mjs --port ${fixturePort} --app-origin ${baseURL}`,
+ reuseExistingServer: false,
+ timeout: 30_000,
+ url: `${fixtureURL}/health`
+ },
+ {
+ command: `npm run dev:no-clean -- --hostname 127.0.0.1 --port ${appPort}`,
+ env: {
+ NEXT_PUBLIC_SLIDEX_AGENT_ENABLED: "false",
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "test-publishable-key",
+ NEXT_PUBLIC_SUPABASE_URL: fixtureURL
+ },
+ reuseExistingServer: false,
+ timeout: 120_000,
+ url: `${baseURL}/workspace/pitch/?demo=1`
+ }
+ ]
+});
diff --git a/supabase/migrations/20260719093000_add_mcp_operation_events.sql b/supabase/migrations/20260719093000_add_mcp_operation_events.sql
new file mode 100644
index 0000000..f20a656
--- /dev/null
+++ b/supabase/migrations/20260719093000_add_mcp_operation_events.sql
@@ -0,0 +1,171 @@
+create extension if not exists pg_cron;
+
+create table public.mcp_operation_events (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references auth.users(id) on delete cascade,
+ presentation_id uuid not null references public.presentations(id) on delete cascade,
+ client_id text not null check (char_length(client_id) between 1 and 180),
+ client_name text not null check (char_length(client_name) between 1 and 160),
+ tool_name text not null check (char_length(tool_name) between 1 and 120),
+ status text not null default 'running' check (status in ('running', 'completed', 'failed')),
+ target_kind text not null check (target_kind in ('presentation', 'slide', 'block')),
+ slide_index integer check (slide_index is null or slide_index >= 0),
+ node_id text check (node_id is null or char_length(node_id) between 1 and 200),
+ completed_revision bigint check (completed_revision is null or completed_revision >= 0),
+ error_code text check (error_code is null or char_length(error_code) between 1 and 80),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ completed_at timestamptz,
+ expires_at timestamptz not null default (now() + interval '7 days'),
+ constraint mcp_operation_events_target_shape check (
+ (target_kind = 'presentation' and slide_index is null and node_id is null)
+ or (target_kind = 'slide' and slide_index is not null and node_id is null)
+ or (target_kind = 'block' and slide_index is not null and node_id is not null)
+ ),
+ constraint mcp_operation_events_status_shape check (
+ (status = 'running' and completed_revision is null and error_code is null and completed_at is null)
+ or (status = 'completed' and error_code is null and completed_at is not null)
+ or (status = 'failed' and completed_revision is null and error_code is not null and completed_at is not null)
+ )
+);
+
+create index mcp_operation_events_user_created_idx
+ on public.mcp_operation_events (user_id, created_at desc);
+
+create index mcp_operation_events_presentation_created_idx
+ on public.mcp_operation_events (presentation_id, created_at desc);
+
+create index mcp_operation_events_expiry_idx
+ on public.mcp_operation_events (expires_at);
+
+alter table public.mcp_operation_events enable row level security;
+
+revoke all on table public.mcp_operation_events from public, anon, authenticated;
+grant select on table public.mcp_operation_events to authenticated;
+grant select, insert, update, delete on table public.mcp_operation_events to service_role;
+
+create policy "mcp_operation_events_select_own_unexpired"
+on public.mcp_operation_events
+for select
+to authenticated
+using (
+ user_id = (select auth.uid())
+ and expires_at > statement_timestamp()
+);
+
+create or replace function public.enforce_mcp_operation_event_owner()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ new.created_at := statement_timestamp();
+ new.updated_at := new.created_at;
+ new.expires_at := new.created_at + interval '7 days';
+
+ if not exists (
+ select 1
+ from public.presentations
+ where id = new.presentation_id
+ and user_id = new.user_id
+ ) then
+ raise exception 'presentation not owned by operation event user' using errcode = '42501';
+ end if;
+
+ return new;
+end;
+$$;
+
+create trigger mcp_operation_events_enforce_owner
+before insert on public.mcp_operation_events
+for each row
+execute function public.enforce_mcp_operation_event_owner();
+
+create or replace function public.enforce_mcp_operation_event_transition()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if old.status <> 'running' or new.status not in ('completed', 'failed') then
+ raise exception 'invalid mcp operation event transition' using errcode = '23514';
+ end if;
+
+ if new.user_id <> old.user_id
+ or new.presentation_id <> old.presentation_id
+ or new.client_id <> old.client_id
+ or new.client_name <> old.client_name
+ or new.tool_name <> old.tool_name
+ or new.created_at <> old.created_at
+ or new.expires_at <> old.expires_at then
+ raise exception 'immutable mcp operation event identity' using errcode = '23514';
+ end if;
+
+ new.updated_at := statement_timestamp();
+ return new;
+end;
+$$;
+
+create trigger mcp_operation_events_enforce_transition
+before update on public.mcp_operation_events
+for each row
+execute function public.enforce_mcp_operation_event_transition();
+
+create or replace function public.broadcast_mcp_operation_event_changes()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ owner_id uuid := coalesce(new.user_id, old.user_id);
+begin
+ perform realtime.broadcast_changes(
+ 'mcp-operation-events:' || owner_id::text,
+ tg_op,
+ tg_op,
+ tg_table_name,
+ tg_table_schema,
+ new,
+ old
+ );
+ return null;
+end;
+$$;
+
+create trigger mcp_operation_events_broadcast_changes
+after insert or update or delete
+on public.mcp_operation_events
+for each row
+execute function public.broadcast_mcp_operation_event_changes();
+
+create policy "mcp_operation_events_receive_own_broadcasts"
+on realtime.messages
+for select
+to authenticated
+using (
+ realtime.messages.extension = 'broadcast'
+ and (select realtime.topic()) = 'mcp-operation-events:' || (select auth.uid())::text
+);
+
+do $$
+declare
+ existing_job_id bigint;
+begin
+ select jobid into existing_job_id
+ from cron.job
+ where jobname = 'purge-expired-mcp-operation-events';
+
+ if existing_job_id is not null then
+ perform cron.unschedule(existing_job_id);
+ end if;
+
+ perform cron.schedule(
+ 'purge-expired-mcp-operation-events',
+ '0 * * * *',
+ $cleanup$delete from public.mcp_operation_events where expires_at <= statement_timestamp()$cleanup$
+ );
+end;
+$$;
diff --git a/tests/browser/README.md b/tests/browser/README.md
new file mode 100644
index 0000000..b4e71aa
--- /dev/null
+++ b/tests/browser/README.md
@@ -0,0 +1,27 @@
+# Browser regression fixtures
+
+The default Playwright suite verifies the SlideX agent lifecycle through the
+real editor UI while replacing only external service boundaries:
+
+- `supabase-fixture.mjs` supplies deterministic Supabase auth,
+ presentation, and MCP activity HTTP responses to both browser suites. It
+ exists because the protected workspace and editor now use the production
+ Supabase session and presentation paths; old local-storage seeds do not
+ exercise those paths and redirect to login.
+- `supabaseFixtureClient.ts` creates the matching browser session cookie and
+ acknowledges the Supabase Realtime protocol used by the production hooks.
+- `DeterministicAgentApi` in `agent-lifecycle.spec.ts` supplies agent HTTP/SSE
+ responses. The agent server repository separately verifies its real router,
+ storage, and run service.
+
+Run the suite with:
+
+```sh
+npm run test:agent:e2e
+```
+
+The MCP activity visual regression has its own config and uses the same shared
+fixture; run it separately with `npm run test:mcp-activity:e2e`.
+
+The fixtures are test-only. They do not bypass production authentication code,
+write to a real Supabase project, or encode product lifecycle rules.
diff --git a/tests/browser/agent-lifecycle.spec.ts b/tests/browser/agent-lifecycle.spec.ts
index a698dc9..6124a43 100644
--- a/tests/browser/agent-lifecycle.spec.ts
+++ b/tests/browser/agent-lifecycle.spec.ts
@@ -1,11 +1,54 @@
import { expect, test, type Locator, type Page, type Route } from "@playwright/test";
import { defaultMdx } from "../../core/motion-doc/presets/defaultMdx";
+import {
+ createSupabaseFixtureSession,
+ installSupabaseRealtimeFixture,
+ supabaseFixtureURL
+} from "./supabaseFixtureClient";
const timestamp = "2026-07-12T00:00:00.000Z";
-const anonymousAccessToken = "test-anonymous-access-token";
const defaultModelKey = "sk-test-current-tab-only-key";
-const workspaceOwnerId = "test-workspace-user";
-const workspacePresentationId = "test-presentation";
+const workspaceOwnerId = "729c3ccc-09e6-47d8-a49f-66a8395c041c";
+const workspacePresentationId = "3ffb8bd0-f055-415d-8ec5-29c7effdecd2";
+const productSession = createSupabaseFixtureSession({
+ id: workspaceOwnerId,
+ aud: "authenticated",
+ role: "authenticated",
+ email: "agent-test@example.com",
+ phone: "",
+ app_metadata: { provider: "google", providers: ["google"] },
+ user_metadata: { full_name: "Agent Test User" },
+ identities: [],
+ created_at: timestamp,
+ updated_at: timestamp,
+ is_anonymous: false
+});
+const productAccessToken = productSession.accessToken;
+
+test.beforeEach(async ({ context, page, request }) => {
+ const response = await request.post(`${supabaseFixtureURL}/test/reset`, {
+ data: {
+ presentation: {
+ createdAt: timestamp,
+ id: workspacePresentationId,
+ ownerId: workspaceOwnerId,
+ source: defaultMdx,
+ title: "Untitled"
+ },
+ user: productSession.user
+ }
+ });
+ expect(response.ok()).toBe(true);
+ await context.addCookies([{
+ domain: "127.0.0.1",
+ name: "sb-127-auth-token",
+ path: "/",
+ sameSite: "Lax",
+ value: productSession.cookie
+ }]);
+ await page.addInitScript(() => localStorage.setItem("slidex-locale", "en"));
+ await installSupabaseRealtimeFixture(page);
+});
test("keeps one conversational deck across turns, refresh, detach, and explicit delete", async ({ page }) => {
const agent = new DeterministicAgentApi();
@@ -125,28 +168,16 @@ test("opens a saved conversation with its presentation", async ({ page }) => {
title: "Other deck conversation"
});
const { consoleErrors, panel } = await openAgentPanel(page, agent);
- await page.evaluate(({ createdAt, ownerId, presentationId, source }) => {
- const key = `slidex_local_presentations_v1:${ownerId}`;
- const presentations = JSON.parse(localStorage.getItem(key) ?? "[]") as Array>;
- localStorage.setItem(key, JSON.stringify([
- ...presentations,
- {
- createdAt,
- id: presentationId,
- kind: "presentation",
- lastOpenedAt: createdAt,
- ownerId,
- source,
- title: "Other deck",
- updatedAt: createdAt
- }
- ]));
- }, {
- createdAt: timestamp,
- ownerId: workspaceOwnerId,
- presentationId: otherPresentationId,
- source: defaultMdx
+ const seedResponse = await page.request.post(`${supabaseFixtureURL}/test/presentations`, {
+ data: {
+ createdAt: timestamp,
+ id: otherPresentationId,
+ ownerId: workspaceOwnerId,
+ source: defaultMdx,
+ title: "Other deck"
+ }
});
+ expect(seedResponse.ok()).toBe(true);
await panel.getByRole("button", { name: "Conversation history" }).click();
await panel.getByRole("button", { name: /^Other deck conversation/ }).click();
@@ -254,7 +285,7 @@ test("keeps the model key only in current-tab memory", async ({ page }) => {
await panel.getByRole("button", { name: "Agent settings" }).click();
await expect(panel.getByLabel("OpenAI API key")).toHaveValue("");
expect(agent.authorizationHeaders.every(
- (header) => header === `Bearer ${anonymousAccessToken}`
+ (header) => header === `Bearer ${productAccessToken}`
)).toBe(true);
expect(consoleErrors).toEqual([]);
});
@@ -502,41 +533,6 @@ async function openAgentPanel(
}
});
page.on("pageerror", (error) => consoleErrors.push(error.message));
- await page.addInitScript(({ ownerId, presentationId, source, createdAt }) => {
- localStorage.setItem("slidex_local_auth_session_v1", JSON.stringify({
- createdAt,
- provider: "google",
- user: {
- displayName: "Agent Test User",
- email: "agent-test@example.com",
- id: ownerId
- }
- }));
- const presentationsKey = `slidex_local_presentations_v1:${ownerId}`;
- if (!localStorage.getItem(presentationsKey)) {
- localStorage.setItem(presentationsKey, JSON.stringify([{
- createdAt,
- id: presentationId,
- kind: "presentation",
- lastOpenedAt: createdAt,
- ownerId,
- source,
- title: "Untitled",
- updatedAt: createdAt
- }]));
- }
- }, {
- createdAt: timestamp,
- ownerId: workspaceOwnerId,
- presentationId: workspacePresentationId,
- source: defaultMdx
- });
- await page.route("**/auth/v1/signup", async (route) => {
- await route.fulfill({
- json: createAnonymousSessionResponse(),
- status: 200
- });
- });
await page.route("**/api/agent/**", (route) => agent.handle(route));
await page.goto(`/workspace/pitch/?presentation=${workspacePresentationId}`);
await page.getByRole("button", { name: "Toggle SlideX agent" }).click();
@@ -751,7 +747,7 @@ class DeterministicAgentApi {
const url = new URL(request.url());
const authorization = request.headers()["authorization"] ?? "";
this.authorizationHeaders.push(authorization);
- if (authorization !== `Bearer ${anonymousAccessToken}`) {
+ if (authorization !== `Bearer ${productAccessToken}`) {
await route.fulfill({
json: { error: { code: "auth_required", message: "Authentication required" } },
status: 401
@@ -1164,28 +1160,3 @@ class DeterministicAgentApi {
return session;
}
}
-
-function createAnonymousSessionResponse() {
- const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
- const user = {
- id: "anonymous-user",
- aud: "authenticated",
- role: "authenticated",
- email: "",
- phone: "",
- app_metadata: { provider: "anonymous", providers: [] },
- user_metadata: {},
- identities: [],
- created_at: timestamp,
- updated_at: timestamp,
- is_anonymous: true
- };
- return {
- access_token: anonymousAccessToken,
- token_type: "bearer",
- expires_in: 24 * 60 * 60,
- expires_at: expiresAt,
- refresh_token: "test-anonymous-refresh-token",
- user
- };
-}
diff --git a/tests/browser/mcp-activity/mcp-activity.spec.ts b/tests/browser/mcp-activity/mcp-activity.spec.ts
new file mode 100644
index 0000000..a580d02
--- /dev/null
+++ b/tests/browser/mcp-activity/mcp-activity.spec.ts
@@ -0,0 +1,138 @@
+import { expect, test } from "@playwright/test";
+import {
+ createSupabaseFixtureSession,
+ installSupabaseRealtimeFixture,
+ supabaseFixtureURL
+} from "../supabaseFixtureClient";
+
+const ownerId = "729c3ccc-09e6-47d8-a49f-66a8395c041c";
+const presentationId = "3ffb8bd0-f055-415d-8ec5-29c7effdecd2";
+const timestamp = "2026-07-19T00:00:00.000Z";
+const presentationSource = `# MCP visual test
+
+
+ Remote target
+ `;
+const user = {
+ id: ownerId,
+ aud: "authenticated",
+ role: "authenticated",
+ email: "visual-test@example.com",
+ phone: "",
+ app_metadata: { provider: "google", providers: ["google"] },
+ user_metadata: { full_name: "Visual Test" },
+ identities: [],
+ created_at: timestamp,
+ updated_at: timestamp,
+ is_anonymous: false
+};
+const productSession = createSupabaseFixtureSession(user);
+
+test("purple MCP frames stay visual-only while selection and dragging remain interactive", async ({ context, page, request }) => {
+ const resetResponse = await request.post(`${supabaseFixtureURL}/test/reset`, {
+ data: {
+ mcpOperationEvents: operationRows(),
+ presentation: {
+ createdAt: timestamp,
+ id: presentationId,
+ ownerId,
+ source: presentationSource,
+ sourceRevision: 5,
+ title: "MCP visual test"
+ },
+ user
+ }
+ });
+ expect(resetResponse.ok()).toBe(true);
+ await context.addCookies([{
+ domain: "127.0.0.1",
+ name: "sb-127-auth-token",
+ path: "/",
+ sameSite: "Lax",
+ value: productSession.cookie
+ }]);
+ await installSupabaseRealtimeFixture(page);
+
+ await page.goto(`/workspace/pitch/?presentation=${presentationId}`);
+
+ const runningFrame = page.locator('[data-mcp-node-id="block-target"][data-mcp-operation-status="running"]');
+ await expect(runningFrame).toBeVisible();
+ await expect(runningFrame).toContainText("AI · Codex");
+ await expect(runningFrame).toHaveCSS("border-top-color", "rgb(139, 92, 246)");
+ await expect(runningFrame).toHaveCSS("border-top-style", "solid");
+ expect(await runningFrame.evaluate((element) => getComputedStyle(element.parentElement!).pointerEvents)).toBe("none");
+
+ const completedFrame = page.locator('[data-mcp-node-id="block-target"][data-mcp-operation-status="completed"]');
+ await expect(completedFrame).toBeVisible();
+ await expect(completedFrame).toHaveAttribute("data-mcp-completed-revision", "5");
+
+ const blockControl = page.locator('[data-frame-control][data-block-index="0"]');
+ await blockControl.click({ position: { x: 20, y: 20 } });
+ const beforeLeft = await blockControl.evaluate((element) => (element as HTMLElement).style.left);
+ const moveHalo = blockControl.locator("span.cursor-move").first();
+ const box = await moveHalo.boundingBox();
+ expect(box).not.toBeNull();
+ await page.mouse.move(box!.x + 2, box!.y + box!.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box!.x + 26, box!.y + box!.height / 2 + 8, { steps: 4 });
+ await page.mouse.up();
+ await expect.poll(async () => blockControl.evaluate((element) => (element as HTMLElement).style.left))
+ .not.toBe(beforeLeft);
+ await page.keyboard.press("ArrowRight");
+ await expect(runningFrame).toBeVisible();
+});
+
+function operationRows() {
+ const now = new Date();
+ const completedAt = new Date(now.getTime() - 500).toISOString();
+ const createdAt = new Date(now.getTime() - 1_000).toISOString();
+ return [
+ operationRow({
+ completedAt: null,
+ completedRevision: null,
+ createdAt: now.toISOString(),
+ id: "1d256aac-2138-4e55-8d95-eb879bc451dc",
+ status: "running"
+ }),
+ operationRow({
+ completedAt,
+ completedRevision: 5,
+ createdAt,
+ id: "3c9adb49-b004-4421-b506-e067e87f453c",
+ status: "completed"
+ })
+ ];
+}
+
+function operationRow({
+ completedAt,
+ completedRevision,
+ createdAt,
+ id,
+ status
+}: {
+ completedAt: string | null;
+ completedRevision: number | null;
+ createdAt: string;
+ id: string;
+ status: "completed" | "running";
+}) {
+ return {
+ client_id: "slx_client_codex",
+ client_name: "Codex",
+ completed_at: completedAt,
+ completed_revision: completedRevision,
+ created_at: createdAt,
+ error_code: null,
+ expires_at: new Date(Date.now() + 7 * 86_400_000).toISOString(),
+ id,
+ node_id: "block-target",
+ presentation_id: presentationId,
+ slide_index: 0,
+ status,
+ target_kind: "block",
+ tool_name: "slidex_update_canvas_node",
+ updated_at: completedAt ?? createdAt,
+ user_id: ownerId
+ };
+}
diff --git a/tests/browser/supabase-fixture.mjs b/tests/browser/supabase-fixture.mjs
new file mode 100644
index 0000000..d2999b4
--- /dev/null
+++ b/tests/browser/supabase-fixture.mjs
@@ -0,0 +1,158 @@
+import http from "node:http";
+
+const port = numericArgument("--port", 54328);
+const appOrigin = stringArgument("--app-origin", "http://127.0.0.1:3100");
+const presentations = new Map();
+let mcpOperationEvents = [];
+let user;
+
+const server = http.createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
+ addCorsHeaders(response);
+
+ if (request.method === "OPTIONS") {
+ response.writeHead(204).end();
+ return;
+ }
+ if (url.pathname === "/health") {
+ json(response, { ok: true });
+ return;
+ }
+ if (request.method === "POST" && url.pathname === "/test/reset") {
+ const input = await readJson(request);
+ user = input.user;
+ mcpOperationEvents = Array.isArray(input.mcpOperationEvents)
+ ? input.mcpOperationEvents
+ : [];
+ presentations.clear();
+ if (!upsertPresentation(input.presentation)) {
+ json(response, { message: "A test presentation requires id, ownerId, and source" }, 400);
+ return;
+ }
+ json(response, { ok: true });
+ return;
+ }
+ if (request.method === "POST" && url.pathname === "/test/presentations") {
+ if (!upsertPresentation(await readJson(request))) {
+ json(response, { message: "A test presentation requires id, ownerId, and source" }, 400);
+ return;
+ }
+ json(response, { ok: true });
+ return;
+ }
+ if (url.pathname === "/auth/v1/user") {
+ json(response, user ?? { message: "Test user is not initialized" }, user ? 200 : 401);
+ return;
+ }
+ if (url.pathname === "/rest/v1/rpc/touch_presentation_opened") {
+ const input = await readJson(request);
+ const presentation = presentations.get(input.target_presentation_id);
+ if (presentation) {
+ presentation.last_opened_at = new Date().toISOString();
+ }
+ json(response, null);
+ return;
+ }
+ if (url.pathname === "/rest/v1/rpc/compare_and_swap_presentation_document") {
+ const input = await readJson(request);
+ const presentation = presentations.get(input.target_presentation_id);
+ if (!presentation) {
+ json(response, { code: "PGRST116", message: "Presentation not found" }, 404);
+ return;
+ }
+ if (presentation.source_revision !== input.expected_source_revision) {
+ json(response, { code: "40001", message: "Presentation revision conflict" }, 409);
+ return;
+ }
+ const updatedAt = new Date().toISOString();
+ Object.assign(presentation, {
+ editor_template_id: input.next_editor_template_id ?? null,
+ source: input.next_source,
+ source_revision: presentation.source_revision + 1,
+ title: input.next_title,
+ updated_at: updatedAt
+ });
+ json(response, [{
+ editor_template_id: presentation.editor_template_id,
+ presentation_id: presentation.id,
+ source_revision: presentation.source_revision,
+ updated_at: updatedAt
+ }]);
+ return;
+ }
+ if (request.method === "GET" && url.pathname === "/rest/v1/presentations") {
+ const presentationId = url.searchParams.get("id")?.replace(/^eq\./, "");
+ json(response, presentationId ? presentations.get(presentationId) ?? null : null);
+ return;
+ }
+ if (url.pathname === "/rest/v1/mcp_operation_events") {
+ json(response, mcpOperationEvents);
+ return;
+ }
+ if (url.pathname === "/rest/v1/slide_comments") {
+ json(response, []);
+ return;
+ }
+
+ json(response, { message: "Not found" }, 404);
+});
+
+server.listen(port, "127.0.0.1");
+
+function upsertPresentation(input) {
+ if (!input?.id || !input.ownerId || typeof input.source !== "string") {
+ return false;
+ }
+ const existing = presentations.get(input.id);
+ const now = input.updatedAt ?? input.createdAt ?? new Date().toISOString();
+ presentations.set(input.id, {
+ created_at: input.createdAt ?? existing?.created_at ?? now,
+ editor_template_id: input.editorTemplateId ?? existing?.editor_template_id ?? null,
+ id: input.id,
+ kind: "presentation",
+ last_opened_at: input.lastOpenedAt ?? existing?.last_opened_at ?? now,
+ source: input.source,
+ source_revision: input.sourceRevision ?? existing?.source_revision ?? 0,
+ template_id: input.templateId ?? existing?.template_id ?? null,
+ title: input.title ?? existing?.title ?? "Untitled",
+ updated_at: now,
+ user_id: input.ownerId
+ });
+ return true;
+}
+
+function addCorsHeaders(response) {
+ response.setHeader("Access-Control-Allow-Credentials", "true");
+ response.setHeader(
+ "Access-Control-Allow-Headers",
+ "accept-profile, authorization, apikey, content-profile, content-type, prefer, range, x-client-info, x-retry-count"
+ );
+ response.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
+ response.setHeader("Access-Control-Allow-Origin", appOrigin);
+ response.setHeader("Access-Control-Expose-Headers", "content-range");
+}
+
+function json(response, body, status = 200) {
+ response.setHeader("Content-Type", "application/json");
+ response.writeHead(status);
+ response.end(JSON.stringify(body));
+}
+
+async function readJson(request) {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(chunk);
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch {
+ return {};
+ }
+}
+
+function numericArgument(name, fallback) {
+ return Number(stringArgument(name, String(fallback)));
+}
+
+function stringArgument(name, fallback) {
+ const index = process.argv.indexOf(name);
+ return index === -1 ? fallback : process.argv[index + 1] ?? fallback;
+}
diff --git a/tests/browser/supabaseFixtureClient.ts b/tests/browser/supabaseFixtureClient.ts
new file mode 100644
index 0000000..89fb397
--- /dev/null
+++ b/tests/browser/supabaseFixtureClient.ts
@@ -0,0 +1,80 @@
+import type { Page } from "@playwright/test";
+
+export const supabaseFixtureURL = "http://127.0.0.1:54328";
+
+type SupabaseFixtureUser = {
+ id: string;
+ [key: string]: unknown;
+};
+
+/** Creates the cookie and access token shape consumed by @supabase/ssr. */
+export function createSupabaseFixtureSession(user: SupabaseFixtureUser) {
+ const issuedAt = Math.floor(Date.now() / 1000);
+ const expiresAt = issuedAt + 24 * 60 * 60;
+ const accessToken = [
+ base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" })),
+ base64Url(JSON.stringify({
+ aud: "authenticated",
+ exp: expiresAt,
+ iat: issuedAt,
+ iss: `${supabaseFixtureURL}/auth/v1`,
+ role: "authenticated",
+ sub: user.id
+ })),
+ "test-signature"
+ ].join(".");
+ const session = {
+ access_token: accessToken,
+ expires_at: expiresAt,
+ expires_in: 24 * 60 * 60,
+ refresh_token: "test-authenticated-refresh-token",
+ token_type: "bearer",
+ user
+ };
+ return {
+ accessToken,
+ cookie: `base64-${base64Url(JSON.stringify(session))}`,
+ user
+ };
+}
+
+/** Keeps production Realtime subscriptions active without a real Supabase socket. */
+export async function installSupabaseRealtimeFixture(page: Page) {
+ await page.routeWebSocket(`${supabaseFixtureURL.replace("http", "ws")}/realtime/v1/websocket**`, (socket) => {
+ socket.onMessage((message) => {
+ const frame: unknown = JSON.parse(message.toString());
+ if (!Array.isArray(frame) || frame.length !== 5) return;
+ const [joinReference, reference, topic, event, payload] = frame;
+ if (typeof reference !== "string" || typeof topic !== "string") return;
+ if (event !== "phx_join" && event !== "heartbeat") return;
+ socket.send(JSON.stringify([
+ joinReference,
+ reference,
+ topic,
+ "phx_reply",
+ {
+ response: {
+ postgres_changes: event === "phx_join"
+ ? realtimePostgresChanges(payload)
+ : []
+ },
+ status: "ok"
+ }
+ ]));
+ });
+ });
+}
+
+function realtimePostgresChanges(payload: unknown) {
+ if (!payload || typeof payload !== "object" || !("config" in payload)) return [];
+ const config = payload.config;
+ if (!config || typeof config !== "object" || !("postgres_changes" in config)) return [];
+ const changes = config.postgres_changes;
+ return Array.isArray(changes)
+ ? changes.map((change, index) => ({ ...change, id: index + 1 }))
+ : [];
+}
+
+function base64Url(value: string) {
+ return Buffer.from(value, "utf8").toString("base64url");
+}