From 970a86318d82487fd03577ee3e23f85ab027edc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 02:43:01 +0000 Subject: [PATCH] feat: register WebMCP tools for agent-render viewer Expose navigator.modelContext.registerTool definitions for decode state, example fragments, artifact selection, copy/download/print, and clearing the hash. Use a ref updated each frame so execute callbacks stay fresh, and AbortController to unregister on unmount. Add minimal TS typings and unit tests with a mocked ModelContext. Co-authored-by: Aanish Bhirud --- src/components/viewer-shell.tsx | 92 +++++- src/lib/webmcp/register-agent-render-tools.ts | 277 ++++++++++++++++++ src/types/webmcp.d.ts | 31 ++ .../register-agent-render-tools.test.ts | 66 +++++ tsconfig.json | 1 + 5 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 src/lib/webmcp/register-agent-render-tools.ts create mode 100644 src/types/webmcp.d.ts create mode 100644 tests/webmcp/register-agent-render-tools.test.ts diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index da7a583..6fef960 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -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 { @@ -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 = { markdown: FileText, code: FileCode2, @@ -456,6 +466,86 @@ export function ViewerShell() { }); }, [markdownArtifact]); + const webmcpActionsRef = useRef(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 (
WebMcpViewerState; + loadSampleByKey: (key: string) => boolean; + loadSampleByTitle: (substring: string) => boolean; + selectArtifact: (artifactId: string) => void; + copyActiveArtifact: () => Promise; + 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): () => 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; + 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 }; + }, + }, + { 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 }; + }, + }, + { 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 }, + ); + + 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 { + const out = {} as Record; + 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; +} diff --git a/src/types/webmcp.d.ts b/src/types/webmcp.d.ts new file mode 100644 index 0000000..167d2e6 --- /dev/null +++ b/src/types/webmcp.d.ts @@ -0,0 +1,31 @@ +/** + * Minimal TypeScript surface for the WebMCP `navigator.modelContext` API (draft). + * @see https://webmachinelearning.github.io/webmcp/ + */ + +interface ModelContextClient { + requestUserInteraction(callback: () => Promise): Promise; +} + +type ToolExecuteCallback = (input: object, client: ModelContextClient) => Promise; + +interface ModelContextTool { + name: string; + title?: string; + description: string; + inputSchema?: object; + execute: ToolExecuteCallback; + annotations?: { readOnlyHint?: boolean }; +} + +interface ModelContextRegisterToolOptions { + signal?: AbortSignal; +} + +interface ModelContext { + registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): undefined; +} + +interface Navigator { + readonly modelContext?: ModelContext; +} diff --git a/tests/webmcp/register-agent-render-tools.test.ts b/tests/webmcp/register-agent-render-tools.test.ts new file mode 100644 index 0000000..c2143cd --- /dev/null +++ b/tests/webmcp/register-agent-render-tools.test.ts @@ -0,0 +1,66 @@ +import type { RefObject } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerAgentRenderWebMcpTools, WEBMCP_EXAMPLE_KEYS, type AgentRenderWebMcpActions } from "@/lib/webmcp/register-agent-render-tools"; + +describe("registerAgentRenderWebMcpTools", () => { + const originalModelContext = navigator.modelContext; + + afterEach(() => { + Object.defineProperty(navigator, "modelContext", { + configurable: true, + value: originalModelContext, + }); + vi.restoreAllMocks(); + }); + + it("registers tools when modelContext.registerTool exists", () => { + vi.stubGlobal("isSecureContext", true); + + const registerTool = vi.fn(); + Object.defineProperty(navigator, "modelContext", { + configurable: true, + value: { registerTool }, + }); + + const actions: RefObject = { + current: { + getViewerState: () => ({ + hasFragment: false, + fragmentLength: 0, + decodeOk: true, + artifactIds: [], + exampleKeys: WEBMCP_EXAMPLE_KEYS, + exampleTitles: [], + }), + loadSampleByKey: () => true, + loadSampleByTitle: () => true, + selectArtifact: () => {}, + copyActiveArtifact: async () => {}, + downloadActiveArtifact: () => {}, + printActiveMarkdown: () => {}, + goHome: () => {}, + }, + }; + + const cleanup = registerAgentRenderWebMcpTools(actions); + expect(registerTool).toHaveBeenCalledTimes(8); + const names = registerTool.mock.calls.map((call) => (call[0] as { name: string }).name); + expect(names).toContain("agent_render.get_viewer_state"); + expect(names).toContain("agent_render.load_example_fragment"); + + cleanup(); + expect(registerTool.mock.calls[0][1]).toEqual({ signal: expect.any(AbortSignal) }); + }); + + it("is a no-op when registerTool is missing", () => { + Object.defineProperty(navigator, "modelContext", { + configurable: true, + value: {}, + }); + + const actions: RefObject = { current: null }; + const cleanup = registerAgentRenderWebMcpTools(actions); + expect(cleanup).toBeInstanceOf(Function); + cleanup(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 646e697..ab7f116 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ "include": [ "**/*.ts", "**/*.tsx", + "src/types/**/*.d.ts", "next-env.d.ts", ".next/types/**/*.ts" ],