From e722e136b72f8fa9dc8c0568c853f8306ac91095 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sun, 6 Sep 2026 15:17:33 +0500 Subject: [PATCH 1/2] feat(mcp): select library media and follow indexing progress in the MCP App The MCP App covered uploading and inspecting evidence. Two items from the issue's first useful version were missing: selecting a video that is already registered, and following indexing progress. Attach the existing app template to list_media and get_job_status, and add the two matching views to the widget. list_media pages through the library and records the chosen media in the model context; get_job_status renders stage, message and step counts, and re-polls itself using the job's own poll_after_seconds until the job is terminal. No new tools and no separate application backend: both views drive tools that already exist, and the reused media and job lifecycle stays the source of truth. Closes #73. --- src/vidxp/assets/mcp_app/index.html | 176 ++++++++++++++++++++++++++++ src/vidxp/mcp.py | 8 ++ 2 files changed, 184 insertions(+) diff --git a/src/vidxp/assets/mcp_app/index.html b/src/vidxp/assets/mcp_app/index.html index 3a46add5..73f08b17 100644 --- a/src/vidxp/assets/mcp_app/index.html +++ b/src/vidxp/assets/mcp_app/index.html @@ -54,6 +54,8 @@ .notice { min-height: 20px; color: var(--muted); font-size: 13px; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } @media (max-width: 560px) { .app { padding: 12px; } .header { align-items: stretch; flex-direction: column; } .header .button { align-self: flex-start; } } + .track { height: 6px; border-radius: 999px; background: var(--surface-2, rgba(127, 127, 127, 0.2)); overflow: hidden; } + .track > span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #4f7cff); transition: width 160ms ease; } @@ -82,6 +84,7 @@

Preparing video workspace…

const selected = new Set(); let requestId = 1; let latestResult = null; + let progressTimer = null; let uploadPageUrl = null; let connected = false; let hostCapabilities = {}; @@ -403,12 +406,185 @@

Preparing video workspace…

content.replaceChildren(wrapper); }; + const persistMediaSelection = (mediaId) => { + if (connected) { + void request("ui/update-model-context", { + structuredContent: { selectedMediaId: mediaId }, + }).catch(() => undefined); + } + if (window.openai?.setWidgetState) { + void Promise.resolve(window.openai.setWidgetState({ selectedMediaId: mediaId })).catch(() => undefined); + } + }; + + const humanBytes = (value) => { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`; + }; + + const humanDuration = (value) => { + const total = Number(value); + if (!Number.isFinite(total) || total <= 0) return ""; + const seconds = Math.round(total); + const minutes = Math.floor(seconds / 60); + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; + }; + + const renderLibrary = (data) => { + const items = asArray(data.items).map(asObject); + title.textContent = "Select a video"; + lede.textContent = items.length + ? "Choose a registered video to work with, or load more from the library." + : "No registered videos yet. Upload one first."; + const wrapper = document.createDocumentFragment(); + + const overview = panel("Library"); + const metrics = element("div", "metrics"); + metrics.append( + metric(data.total ?? items.length, "registered"), + metric(items.length, "shown"), + metric(items.filter((item) => item.state === "ready").length, "ready") + ); + overview.append(metrics); + wrapper.append(overview); + + if (items.length) { + const listing = panel("Videos"); + const list = element("div", "tile-list"); + for (const item of items) { + if (typeof item.media_id !== "string") continue; + const tile = element("div", "tile"); + const copy = element("div", "tile-copy"); + copy.append(element("p", "tile-title", item.original_filename || item.media_id)); + const facts = [item.state, humanDuration(item.duration_seconds), humanBytes(item.byte_size), item.container] + .filter((fact) => typeof fact === "string" && fact.length > 0); + copy.append(element("p", "tile-meta", facts.join(" · "))); + tile.append(copy); + + const choose = element("button", "button primary", "Use this video"); + choose.type = "button"; + choose.addEventListener("click", async () => { + choose.disabled = true; + notice.textContent = ""; + try { + persistMediaSelection(item.media_id); + const next = await callTool("get_media", { media_id: item.media_id }); + const detail = next.structuredContent; + lede.textContent = `Selected ${detail.original_filename || item.original_filename || item.media_id}.`; + } catch (_error) { + notice.textContent = "Could not select that video."; + } finally { + choose.disabled = false; + } + }); + tile.append(choose); + list.append(tile); + } + listing.append(list); + wrapper.append(listing); + } + + if (typeof data.next_cursor === "string" && data.next_cursor) { + const actions = element("div", "toolbar"); + const more = element("button", "button", "Load more"); + more.type = "button"; + more.addEventListener("click", async () => { + more.disabled = true; + notice.textContent = ""; + try { + render(await callTool("list_media", { cursor: data.next_cursor })); + } catch (_error) { + notice.textContent = "Could not load more videos."; + more.disabled = false; + } + }); + actions.append(more); + wrapper.append(actions); + } + + content.replaceChildren(wrapper); + }; + + const renderProgress = (data) => { + if (progressTimer !== null) { + window.clearTimeout(progressTimer); + progressTimer = null; + } + const progress = asObject(data.progress); + const state = typeof data.state === "string" ? data.state : "unknown"; + const kind = typeof data.kind === "string" ? data.kind : "job"; + title.textContent = kind === "index" ? "Indexing progress" : `VidXP ${kind}`; + lede.textContent = progress.message || `Job is ${state}.`; + const wrapper = document.createDocumentFragment(); + + const overview = panel("Status"); + const metrics = element("div", "metrics"); + metrics.append(metric(state, "state"), metric(progress.stage || "—", "stage")); + const current = Number(progress.current); + const total = Number(progress.total); + const measured = Number.isFinite(current) && Number.isFinite(total) && total > 0; + if (measured) metrics.append(metric(`${current}/${total}`, "steps")); + overview.append(metrics); + if (measured) { + const track = element("div", "track"); + const fill = element("span"); + fill.style.width = `${Math.min(100, Math.round((current / total) * 100))}%`; + track.append(fill); + overview.append(track); + } + wrapper.append(overview); + + const error = asObject(data.error); + if (error.message) { + const failure = panel("Error"); + failure.append(element("p", "error", error.message)); + wrapper.append(failure); + } + + const jobId = typeof data.job_id === "string" ? data.job_id : ""; + if (jobId) { + const poll = async () => { + try { + render(await callTool("get_job_status", { job_id: jobId })); + } catch (_error) { + notice.textContent = "Could not refresh job status."; + } + }; + const actions = element("div", "toolbar"); + const refresh = element("button", "button", "Refresh status"); + refresh.type = "button"; + refresh.addEventListener("click", async () => { + refresh.disabled = true; + notice.textContent = ""; + await poll(); + }); + actions.append(refresh); + wrapper.append(actions); + if (data.terminal === false) { + const delay = Number(data.poll_after_seconds); + progressTimer = window.setTimeout(poll, (Number.isFinite(delay) && delay > 0 ? delay : 1) * 1000); + } + } + + content.replaceChildren(wrapper); + }; + const render = (result) => { latestResult = normalizeResult(result); const data = latestResult.structuredContent; notice.textContent = latestResult.isError ? "VidXP returned an error." : ""; if (data.view === "upload" || data.upload_session_url || data.aggregate_state) renderUpload(data); else if (data.view === "evidence" || data.board) renderEvidence(data, latestResult); + else if (data.view === "library" || asArray(data.items).some((item) => typeof asObject(item).media_id === "string")) renderLibrary(data); + else if (data.view === "job" || typeof data.job_id === "string") renderProgress(data); else { title.textContent = "VidXP"; lede.textContent = "This tool result does not include an interactive view."; diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index c359c335..11b974fa 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -1352,6 +1352,10 @@ async def get_runtime_readiness() -> RuntimeReadiness: "that a video is present in the active index snapshot." ), annotations=_READ_ONLY, + meta=_mcp_app_tool_meta( + "Listing VidXP media…", + "VidXP media library ready.", + ), structured_output=True, ) async def list_media( @@ -2060,6 +2064,10 @@ def completed_evidence_job(_actor: Principal) -> Job: "initial observation." ), annotations=_READ_ONLY, + meta=_mcp_app_tool_meta( + "Checking VidXP job status…", + "VidXP job status ready.", + ), structured_output=True, ) async def get_job_status(job_id: JobId) -> JobSummary: From fcb19474816758c8d3b95c5bb6be63361385276c Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 15 Sep 2026 00:02:11 +0500 Subject: [PATCH 2/2] fix(mcp): address MCP App review findings on library and progress views Four corrections from review, plus behavioural coverage for each. Load more sent only the cursor, so a filtered page failed with an invalid media cursor. The widget now carries the query it issued and merges the cursor into it. MediaPage returns items, total and next_cursor only, so an agent-issued first page leaves no recoverable filter context; pagination is withheld there rather than sending a cursor that cannot honour the filters. An empty media page has items: [] and no view field, so the dispatcher fell through to the no-interactive-view message. It now recognises a MediaPage by shape. The integer total is what separates it from JobPage, which carries items and next_cursor but no total. Progress polling cleared its timer only when another progress view rendered, so a library or evidence result left it running and a late response could replace the current screen. A generation token is now taken when the view renders and rechecked before any response touches the DOM; every render and the resource-teardown handler stop polling. A failed manual refresh left its button disabled with no way to retry. The button is now restored in a finally block. Add web/mcp-app with node --test coverage driving the shipped widget in a DOM stub: six cases, one per behaviour. They fail against the pre-fix widget and pass after. --- src/vidxp/assets/mcp_app/index.html | 48 +++-- web/mcp-app/package.json | 13 ++ web/mcp-app/test/widget-behavior.test.mjs | 206 ++++++++++++++++++++++ 3 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 web/mcp-app/package.json create mode 100644 web/mcp-app/test/widget-behavior.test.mjs diff --git a/src/vidxp/assets/mcp_app/index.html b/src/vidxp/assets/mcp_app/index.html index 73f08b17..d4b970e7 100644 --- a/src/vidxp/assets/mcp_app/index.html +++ b/src/vidxp/assets/mcp_app/index.html @@ -89,6 +89,7 @@

Preparing video workspace…

let connected = false; let hostCapabilities = {}; let hostContext = {}; + let progressGeneration = 0; const element = (tag, className, text) => { const node = document.createElement(tag); @@ -170,6 +171,14 @@

Preparing video workspace…

return normalizeResult(await request("tools/call", { name, arguments: args })); }; + const stopProgressPolling = () => { + if (progressTimer !== null) { + window.clearTimeout(progressTimer); + progressTimer = null; + } + progressGeneration += 1; + }; + const imageBlocks = (result) => asArray(result?.content).filter((block) => { const mime = String(block?.mimeType ?? block?.mime_type ?? "").toLowerCase(); return block?.type === "image" && (mime === "image/jpeg" || mime === "image/png") && typeof block?.data === "string"; @@ -438,7 +447,7 @@

Preparing video workspace…

return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; }; - const renderLibrary = (data) => { + const renderLibrary = (data, query) => { const items = asArray(data.items).map(asObject); title.textContent = "Select a video"; lede.textContent = items.length @@ -492,7 +501,7 @@

Preparing video workspace…

wrapper.append(listing); } - if (typeof data.next_cursor === "string" && data.next_cursor) { + if (typeof data.next_cursor === "string" && data.next_cursor && query) { const actions = element("div", "toolbar"); const more = element("button", "button", "Load more"); more.type = "button"; @@ -500,7 +509,8 @@

Preparing video workspace…

more.disabled = true; notice.textContent = ""; try { - render(await callTool("list_media", { cursor: data.next_cursor })); + const nextQuery = { ...query, cursor: data.next_cursor }; + render(await callTool("list_media", nextQuery), { mediaQuery: nextQuery }); } catch (_error) { notice.textContent = "Could not load more videos."; more.disabled = false; @@ -514,10 +524,7 @@

Preparing video workspace…

}; const renderProgress = (data) => { - if (progressTimer !== null) { - window.clearTimeout(progressTimer); - progressTimer = null; - } + const generation = progressGeneration; const progress = asObject(data.progress); const state = typeof data.state === "string" ? data.state : "unknown"; const kind = typeof data.kind === "string" ? data.kind : "job"; @@ -552,10 +559,13 @@

Preparing video workspace…

const jobId = typeof data.job_id === "string" ? data.job_id : ""; if (jobId) { const poll = async () => { + if (generation !== progressGeneration) return; try { - render(await callTool("get_job_status", { job_id: jobId })); + const next = await callTool("get_job_status", { job_id: jobId }); + if (generation !== progressGeneration) return; + render(next); } catch (_error) { - notice.textContent = "Could not refresh job status."; + if (generation === progressGeneration) notice.textContent = "Could not refresh job status."; } }; const actions = element("div", "toolbar"); @@ -564,26 +574,37 @@

Preparing video workspace…

refresh.addEventListener("click", async () => { refresh.disabled = true; notice.textContent = ""; - await poll(); + try { + await poll(); + } finally { + refresh.disabled = false; + } }); actions.append(refresh); wrapper.append(actions); if (data.terminal === false) { const delay = Number(data.poll_after_seconds); - progressTimer = window.setTimeout(poll, (Number.isFinite(delay) && delay > 0 ? delay : 1) * 1000); + progressTimer = window.setTimeout(() => { + progressTimer = null; + void poll(); + }, (Number.isFinite(delay) && delay > 0 ? delay : 1) * 1000); } } content.replaceChildren(wrapper); }; - const render = (result) => { + const render = (result, context = {}) => { latestResult = normalizeResult(result); const data = latestResult.structuredContent; + stopProgressPolling(); notice.textContent = latestResult.isError ? "VidXP returned an error." : ""; if (data.view === "upload" || data.upload_session_url || data.aggregate_state) renderUpload(data); else if (data.view === "evidence" || data.board) renderEvidence(data, latestResult); - else if (data.view === "library" || asArray(data.items).some((item) => typeof asObject(item).media_id === "string")) renderLibrary(data); + else if ( + data.view === "library" || + (Array.isArray(data.items) && Number.isInteger(data.total) && (data.next_cursor === null || typeof data.next_cursor === "string")) + ) renderLibrary(data, context.mediaQuery); else if (data.view === "job" || typeof data.job_id === "string") renderProgress(data); else { title.textContent = "VidXP"; @@ -608,6 +629,7 @@

Preparing video workspace…

if (message.method === "ui/notifications/host-context-changed") updateHostContext(message.params); if (message.method === "ui/notifications/tool-cancelled") notice.textContent = message.params?.reason || "The tool call was cancelled."; if (message.method === "ui/resource-teardown" && message.id !== undefined) { + stopProgressPolling(); window.parent.postMessage({ jsonrpc: "2.0", id: message.id, result: {} }, "*"); } }, { passive: true }); diff --git a/web/mcp-app/package.json b/web/mcp-app/package.json new file mode 100644 index 00000000..bd5357a8 --- /dev/null +++ b/web/mcp-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vidxp/mcp-app", + "private": true, + "version": "0.0.0", + "description": "Behavioural tests for the MCP App widget. The widget itself is hand-authored at src/vidxp/assets/mcp_app/index.html and has no build step, so this package carries tests only.", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test test/*.test.mjs" + } +} diff --git a/web/mcp-app/test/widget-behavior.test.mjs b/web/mcp-app/test/widget-behavior.test.mjs new file mode 100644 index 00000000..8152c7f2 --- /dev/null +++ b/web/mcp-app/test/widget-behavior.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const widgetPath = + process.env.WIDGET_HTML ?? + fileURLToPath(new URL("../../../src/vidxp/assets/mcp_app/index.html", import.meta.url)); +const html = fs.readFileSync(widgetPath, "utf8"); +const script = html.match(/