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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion src/components/viewer-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import dynamic from "next/dynamic";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { LucideIcon } from "lucide-react";
import {
Expand Down Expand Up @@ -44,9 +44,19 @@ import { LinkCreator } from "@/components/home/link-creator";
import { ArtifactSelector } from "@/components/viewer/artifact-selector";
import { FragmentDetailsDisclosure } from "@/components/viewer/fragment-details-disclosure";
import { ThemeToggle } from "@/components/theme-toggle";
import {
type AgentRenderWebMcpActions,
type WebMcpViewerState,
WEBMCP_EXAMPLE_KEYS,
buildExampleHashByKey,
registerAgentRenderWebMcpTools,
} from "@/lib/webmcp/register-agent-render-tools";

const numberFormatter = new Intl.NumberFormat("en-US");

const webmcpExampleHashByKey = buildExampleHashByKey(sampleLinks.map((link) => link.hash));
const webmcpExampleTitles = sampleLinks.map((link) => link.title);

const kindIcons: Record<ArtifactKind, LucideIcon> = {
markdown: FileText,
code: FileCode2,
Expand Down Expand Up @@ -456,6 +466,86 @@ export function ViewerShell() {
});
}, [markdownArtifact]);

const webmcpActionsRef = useRef<AgentRenderWebMcpActions | null>(null);

useLayoutEffect(() => {
webmcpActionsRef.current = {
getViewerState: (): WebMcpViewerState => {
const hasFragment = Boolean(hash && hash !== "#");
const fl = hash.startsWith("#") ? Math.max(0, hash.length - 1) : hash.length;
if (!parsed.ok) {
return {
hasFragment,
fragmentLength: fl,
decodeOk: false,
parseMessage: parsed.message,
artifactIds: [],
exampleKeys: WEBMCP_EXAMPLE_KEYS,
exampleTitles: webmcpExampleTitles,
};
}

const env = parsed.envelope;
const active = env.artifacts.find((a) => a.id === env.activeArtifactId) ?? env.artifacts[0];

return {
hasFragment,
fragmentLength: fl,
decodeOk: true,
envelopeTitle: env.title,
codec: env.codec,
artifactIds: env.artifacts.map((a) => a.id),
activeArtifactId: active?.id,
activeArtifactKind: active?.kind,
activeArtifactTitle: active?.title ?? active?.filename,
exampleKeys: WEBMCP_EXAMPLE_KEYS,
exampleTitles: webmcpExampleTitles,
};
},
loadSampleByKey: (key: string) => {
const nextHash = webmcpExampleHashByKey[key as keyof typeof webmcpExampleHashByKey];
if (!nextHash) {
return false;
}

if (window.location.hash === nextHash) {
return true;
}

window.location.hash = nextHash;
return true;
},
loadSampleByTitle: (substring: string) => {
const needle = substring.toLowerCase();
const index = webmcpExampleTitles.findIndex((title) => title.toLowerCase().includes(needle));
if (index === -1) {
return false;
}

const nextHash = sampleLinks[index]?.hash;
if (!nextHash) {
return false;
}

if (window.location.hash === nextHash) {
return true;
}

window.location.hash = nextHash;
return true;
},
selectArtifact: handleArtifactSelect,
copyActiveArtifact: handleArtifactCopy,
downloadActiveArtifact: handleArtifactDownload,
printActiveMarkdown: handleMarkdownPrint,
goHome: handleGoHome,
};
});

useEffect(() => {
return registerAgentRenderWebMcpTools(webmcpActionsRef);
}, []);

return (
<main
className="app-shell min-h-screen"
Expand Down
277 changes: 277 additions & 0 deletions src/lib/webmcp/register-agent-render-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import type { RefObject } from "react";

/** Stable keys for built-in example fragments (order matches `sampleLinks` in examples). */
export const WEBMCP_EXAMPLE_KEYS = [
"maintainer-kickoff",
"viewer-bootstrap",
"phase-1-sample-diff",
"data-export-preview",
"arx-showcase",
"malformed-manifest",
] as const;

export type WebMcpExampleKey = (typeof WEBMCP_EXAMPLE_KEYS)[number];

export type WebMcpViewerState = {
hasFragment: boolean;
fragmentLength: number;
decodeOk: boolean;
parseMessage?: string;
envelopeTitle?: string;
codec?: string;
artifactIds: string[];
activeArtifactId?: string;
activeArtifactKind?: string;
activeArtifactTitle?: string;
exampleKeys: readonly string[];
exampleTitles: readonly string[];
};

/**
* Latest imperative actions for WebMCP tool callbacks. Updated each render so tools always
* invoke current viewer behavior without re-registering.
*/
export type AgentRenderWebMcpActions = {
getViewerState: () => WebMcpViewerState;
loadSampleByKey: (key: string) => boolean;
loadSampleByTitle: (substring: string) => boolean;
selectArtifact: (artifactId: string) => void;
copyActiveArtifact: () => Promise<void>;
downloadActiveArtifact: () => void;
printActiveMarkdown: () => void;
goHome: () => void;
};

/**
* Registers agent-render tools on `navigator.modelContext` when the WebMCP API is present.
* Uses one `AbortController` so all tools unregister together on cleanup.
*
* @param actionsRef Ref updated each render with fresh callbacks and `getViewerState`.
* @returns Cleanup to run on unmount (aborts registration).
*/
export function registerAgentRenderWebMcpTools(actionsRef: RefObject<AgentRenderWebMcpActions | null>): () => void {
if (typeof window === "undefined" || !window.isSecureContext) {
return () => {};
}

const modelContext = navigator.modelContext;
if (!modelContext || typeof modelContext.registerTool !== "function") {
return () => {};
}

const abort = new AbortController();
const { signal } = abort;

const read = () => {
const current = actionsRef.current;
if (!current) {
throw new Error("agent-render WebMCP actions are not initialized");
}
return current;
};

modelContext.registerTool(
{
name: "agent_render.get_viewer_state",
title: "Get viewer state",
description:
"Returns the current agent-render URL fragment status: decode result, envelope summary, active artifact, and available example keys. Read-only.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
annotations: { readOnlyHint: true },
execute: async () => read().getViewerState(),
},
{ signal },
);

modelContext.registerTool(
{
name: "agent_render.list_examples",
title: "List example fragments",
description:
"Returns the stable example keys and titles for built-in sample fragments users can load into the viewer.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
annotations: { readOnlyHint: true },
execute: async () => {
const state = read().getViewerState();
return { exampleKeys: [...state.exampleKeys], titles: [...state.exampleTitles] };
},
},
{ signal },
);

modelContext.registerTool(
{
name: "agent_render.load_example_fragment",
title: "Load example fragment",
description:
"Navigates the viewer to a built-in sample by stable example key (preferred), by title substring, or by index 0–5. Updates the URL hash.",
inputSchema: {
type: "object",
properties: {
exampleKey: {
type: "string",
description:
"Stable key: maintainer-kickoff, viewer-bootstrap, phase-1-sample-diff, data-export-preview, arx-showcase, malformed-manifest",
enum: [...WEBMCP_EXAMPLE_KEYS],
},
titleContains: {
type: "string",
description: "Case-insensitive substring match against sample titles",
},
index: {
type: "integer",
minimum: 0,
maximum: 5,
description: "Zero-based index into the sample list (same order as the homepage)",
},
},
additionalProperties: false,
},
execute: async (input) => {
const obj = input as Record<string, unknown>;
if (typeof obj.exampleKey === "string") {
const ok = read().loadSampleByKey(obj.exampleKey);
return ok ? { ok: true, method: "exampleKey", exampleKey: obj.exampleKey } : { ok: false, error: "unknown_example_key" };
}
if (typeof obj.titleContains === "string" && obj.titleContains.trim()) {
const ok = read().loadSampleByTitle(obj.titleContains.trim());
return ok ? { ok: true, method: "titleContains" } : { ok: false, error: "no_matching_title" };
}
if (typeof obj.index === "number" && Number.isInteger(obj.index)) {
const key = WEBMCP_EXAMPLE_KEYS[obj.index];
if (!key) {
return { ok: false, error: "bad_index" };
}
const ok = read().loadSampleByKey(key);
return ok ? { ok: true, method: "index", exampleKey: key } : { ok: false, error: "bad_index" };
}
return { ok: false, error: "provide_exampleKey_titleContains_or_index" };
},
},
{ signal },
);

modelContext.registerTool(
{
name: "agent_render.select_artifact",
title: "Select artifact",
description:
"When a multi-artifact bundle is loaded, switches the active artifact by id and rewrites the URL fragment. No-op if the id is already active or the bundle is missing.",
inputSchema: {
type: "object",
properties: {
artifactId: { type: "string", minLength: 1, description: "Artifact id from the decoded envelope" },
},
required: ["artifactId"],
additionalProperties: false,
},
execute: async (input) => {
const id = (input as { artifactId?: string }).artifactId;
if (!id) {
return { ok: false, error: "missing_artifactId" };
}
read().selectArtifact(id);
return { ok: true, artifactId: id };
Comment on lines +181 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate artifact ID before claiming selection succeeded

agent_render.select_artifact always returns { ok: true } for any non-empty artifactId, but it never verifies that the ID exists in the current bundle before calling selectArtifact. With a typo or stale ID, the viewer path normalizes activeArtifactId to the first artifact (via envelope normalization) rather than selecting the requested artifact, so the tool reports success while navigating to an unintended artifact. This can mislead agent workflows that rely on the response to confirm state changes.

Useful? React with 👍 / 👎.

},
Comment on lines +176 to +183

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate artifactId before mutating the fragment.

This tool accepts arbitrary agent input but returns { ok: true } even when the ID is not in the decoded envelope. With the current viewer handler, that can rewrite the URL with an invalid activeArtifactId.

🛡️ Proposed validation
       execute: async (input) => {
         const id = (input as { artifactId?: string }).artifactId;
         if (!id) {
           return { ok: false, error: "missing_artifactId" };
         }
-        read().selectArtifact(id);
+        const actions = read();
+        const state = actions.getViewerState();
+        if (!state.decodeOk) {
+          return { ok: false, error: "no_decoded_envelope" };
+        }
+        if (!state.artifactIds.includes(id)) {
+          return { ok: false, error: "unknown_artifact_id" };
+        }
+        actions.selectArtifact(id);
         return { ok: true, artifactId: id };
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
execute: async (input) => {
const id = (input as { artifactId?: string }).artifactId;
if (!id) {
return { ok: false, error: "missing_artifactId" };
}
read().selectArtifact(id);
return { ok: true, artifactId: id };
},
execute: async (input) => {
const id = (input as { artifactId?: string }).artifactId;
if (!id) {
return { ok: false, error: "missing_artifactId" };
}
const actions = read();
const state = actions.getViewerState();
if (!state.decodeOk) {
return { ok: false, error: "no_decoded_envelope" };
}
if (!state.artifactIds.includes(id)) {
return { ok: false, error: "unknown_artifact_id" };
}
actions.selectArtifact(id);
return { ok: true, artifactId: id };
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/webmcp/register-agent-render-tools.ts` around lines 176 - 183, The
execute handler currently calls read().selectArtifact(id) and returns { ok: true
} without verifying the artifactId exists; before mutating, check that the given
artifactId is present in the viewer's decoded envelope or artifact list (e.g.
via a non-mutating lookup such as read().getArtifactById(id) or checking
read().decodedEnvelope.artifacts/includes(id)), and if it is missing return {
ok: false, error: "unknown_artifactId" } instead of calling
read().selectArtifact; only call read().selectArtifact(id) and return success
when that lookup confirms the id exists.

},
{ signal },
);

modelContext.registerTool(
{
name: "agent_render.copy_active_artifact",
title: "Copy active artifact",
description: "Copies the current artifact body (text) to the clipboard, same as the Copy button.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
execute: async () => {
await read().copyActiveArtifact();
return { ok: true };
},
Comment on lines +198 to +201

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 copy_active_artifact tool always returns { ok: true } even when clipboard copy fails

The execute handler for agent_render.copy_active_artifact unconditionally returns { ok: true } after awaiting copyActiveArtifact(). However, the underlying handleArtifactCopy at src/components/viewer-shell.tsx:397-419 catches clipboard errors internally (setting UI state to "failed") without re-throwing them. It also silently returns when there is no active artifact. This means the tool reports success to the agent caller in all cases—including when the clipboard write actually failed or when no artifact was loaded—giving incorrect feedback that could mislead an agent into thinking the content was copied when it was not.

Prompt for agents
The copy_active_artifact tool always returns { ok: true } because handleArtifactCopy (viewer-shell.tsx:397-419) catches clipboard errors internally and never propagates them. Two failure modes are silently swallowed: (1) no active artifact loaded, and (2) copyTextToClipboard throws a clipboard permission/API error.

To fix this, the tool needs visibility into the copy outcome. One approach: refactor handleArtifactCopy (or add a variant for the WebMCP path) so it returns or throws on failure, letting the tool execute handler return { ok: false, error: 'no_active_artifact' } or { ok: false, error: 'clipboard_failed' } as appropriate. Alternatively, the AgentRenderWebMcpActions.copyActiveArtifact contract could be changed to return a boolean or a result object instead of Promise<void>.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

},
{ signal },
);

modelContext.registerTool(
{
name: "agent_render.download_active_artifact",
title: "Download active artifact",
description: "Downloads the active artifact as a file, same as the Download button.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
execute: async () => {
read().downloadActiveArtifact();
return { ok: true };
},
},
{ signal },
);

modelContext.registerTool(
{
name: "agent_render.print_markdown_artifact",
title: "Print markdown",
description:
"If the active artifact is markdown, opens the browser print dialog for print-to-PDF. No-op for other kinds.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
execute: async () => {
read().printActiveMarkdown();
return { ok: true };
},
},
{ signal },
Comment on lines +188 to +240

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

WebMCP navigator.modelContext registerTool requestUserInteraction clipboard download print user activation

💡 Result:

WebMCP (Web Model Context Protocol) is a W3C draft browser API that extends navigator with navigator.modelContext, allowing websites to register tools callable by AI agents. Key methods include navigator.modelContext.registerTool to register tools with name, description, inputSchema, and execute callback. Tools receive input and a ModelContextClient during execution. requestUserInteraction is a method on ModelContextClient: client.requestUserInteraction(callback) pauses tool execution to request user input via an async callback (e.g., confirm dialog), resolving with the result for human-in-the-loop flows like confirmations before destructive actions. clipboard, download, and print relate to standard Navigator APIs (navigator.clipboard for Clipboard API; downloads/print via a[download] or window.print) and navigator.userActivation (UserActivation state for checking user gestures, e.g., navigator.userActivation.isActive). These are separate from WebMCP but relevant for tools needing permissions: user activation gates many browser features (autoplay, clipboard writes), so WebMCP tools performing such actions must check/await user activation, often via requestUserInteraction. No direct integration found between WebMCP and these specific APIs; tools can use them internally if activation allows. Spec is evolving (Chrome 146+ preview), requires secure contexts (HTTPS).

Citations:


🏁 Script executed:

# First, check the file structure and current implementation
head -20 src/lib/webmcp/register-agent-render-tools.ts

Repository: baanish/agent-render

Length of output: 611


🏁 Script executed:

# Check if requestUserInteraction is used elsewhere in the codebase
rg "requestUserInteraction" --type ts --type tsx -B 2 -A 2

Repository: baanish/agent-render

Length of output: 91


🏁 Script executed:

# Search for other tool execute callbacks to see the current pattern
rg "execute:\s*async" src/lib/webmcp/ --type ts --type tsx -A 3

Repository: baanish/agent-render

Length of output: 91


🏁 Script executed:

# Check the actual implementation of the methods being called
rg "copyActiveArtifact|downloadActiveArtifact|printActiveMarkdown" --type ts --type tsx -B 2 -A 5

Repository: baanish/agent-render

Length of output: 91


🏁 Script executed:

# Look for any WebMCP-related imports or documentation
rg "registerTool|modelContext" src/lib/webmcp/ --type ts --type tsx -B 1 -A 1 | head -40

Repository: baanish/agent-render

Length of output: 91


🏁 Script executed:

# Find TypeScript files in the webmcp directory
fd "\.ts$" src/lib/webmcp/

Repository: baanish/agent-render

Length of output: 109


🏁 Script executed:

# Search for requestUserInteraction usage
rg "requestUserInteraction" -A 3 -B 2

Repository: baanish/agent-render

Length of output: 400


🏁 Script executed:

# Read the actual file to see the tool registrations
wc -l src/lib/webmcp/register-agent-render-tools.ts

Repository: baanish/agent-render

Length of output: 113


🏁 Script executed:

# Get the specific lines mentioned in the review (188-240)
sed -n '180,245p' src/lib/webmcp/register-agent-render-tools.ts

Repository: baanish/agent-render

Length of output: 1727


🏁 Script executed:

# Search for other tool registrations to see the callback pattern
rg "execute:" src/lib/webmcp/register-agent-render-tools.ts -A 4 -B 2

Repository: baanish/agent-render

Length of output: 1691


🏁 Script executed:

# Search for the implementation of copyActiveArtifact
rg "copyActiveArtifact" -B 3 -A 10

Repository: baanish/agent-render

Length of output: 4259


🏁 Script executed:

# Search for downloadActiveArtifact implementation
rg "downloadActiveArtifact" -B 3 -A 10

Repository: baanish/agent-render

Length of output: 4282


🏁 Script executed:

# Search for printActiveMarkdown implementation
rg "printActiveMarkdown" -B 3 -A 10

Repository: baanish/agent-render

Length of output: 4191


🏁 Script executed:

# Check the file structure to find where read() comes from
head -50 src/lib/webmcp/register-agent-render-tools.ts

Repository: baanish/agent-render

Length of output: 1673


🏁 Script executed:

# Find and read the handler implementations
rg "handleArtifactCopy|handleArtifactDownload|handleMarkdownPrint" src/components/viewer-shell.tsx -B 2 -A 15

Repository: baanish/agent-render

Length of output: 4359


🏁 Script executed:

# Find the copyTextToClipboard implementation to confirm it uses Clipboard API
rg "copyTextToClipboard" -B 2 -A 8

Repository: baanish/agent-render

Length of output: 3940


Update tool callbacks to accept the client parameter and wrap user-interaction operations with requestUserInteraction.

The execute callbacks have an incorrect signature. Per the WebMCP spec (defined in src/types/webmcp.d.ts), callbacks must accept (input: object, client: ModelContextClient). The three operations—clipboard write, download, and print—use browser APIs that require user activation and will fail silently without proper gating. Wrap them with client.requestUserInteraction() as the host provides the activation gate.

Required changes
-      execute: async () => {
-        await read().copyActiveArtifact();
+      execute: async (_input, client) => {
+        await client.requestUserInteraction(async () => {
+          await read().copyActiveArtifact();
+        });
         return { ok: true };
       },

-      execute: async () => {
-        read().downloadActiveArtifact();
+      execute: async (_input, client) => {
+        await client.requestUserInteraction(async () => {
+          read().downloadActiveArtifact();
+        });
         return { ok: true };
       },

-      execute: async () => {
-        read().printActiveMarkdown();
+      execute: async (_input, client) => {
+        await client.requestUserInteraction(async () => {
+          read().printActiveMarkdown();
+        });
         return { ok: true };
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/webmcp/register-agent-render-tools.ts` around lines 188 - 240, The
three registered tools use an incorrect execute signature and need to accept
(input: object, client: ModelContextClient); update each execute to async
(input, client) => { await client.requestUserInteraction(() =>
read().copyActiveArtifact()) ; return { ok: true } } (and similarly for
read().downloadActiveArtifact and read().printActiveMarkdown) so the clipboard,
download and print calls run inside client.requestUserInteraction() and are
properly user-activation gated while preserving the existing return shape.

);

modelContext.registerTool(
{
name: "agent_render.clear_fragment",
title: "Clear fragment / home",
description: "Clears the URL hash and returns to the empty state and link creator, like the site logo.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
execute: async () => {
read().goHome();
return { ok: true };
},
},
{ signal },
);

return () => {
abort.abort();
};
}

/** Maps example keys to sample link hashes (same order as `WEBMCP_EXAMPLE_KEYS`). */
export function buildExampleHashByKey(sampleHashes: readonly string[]): Record<WebMcpExampleKey, string> {
const out = {} as Record<WebMcpExampleKey, string>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Return type incorrect - buildExampleHashByKey returns Record<WebMcpExampleKey, string> (all keys required) but only assigns keys conditionally when h !== undefined. If sampleHashes is shorter than WEBMCP_EXAMPLE_KEYS, the returned object is missing keys, violating the type contract. Change return type to Partial<Record<WebMcpExampleKey, string>> or ensure assignment always happens.

for (let i = 0; i < WEBMCP_EXAMPLE_KEYS.length; i += 1) {
const key = WEBMCP_EXAMPLE_KEYS[i];
const h = sampleHashes[i];
if (h !== undefined) {
out[key] = h;
}
}
return out;
}
Loading
Loading