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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
"private": true,
"type": "module",
"scripts": {
"dev": "STORYBOOK_DISABLE_TELEMETRY=1 storybook dev -p 6006 --disable-telemetry",
"dev": "STORYBOOK_DISABLE_TELEMETRY=1 storybook dev -p 6006 --disable-telemetry --no-open",
"build": "STORYBOOK_DISABLE_TELEMETRY=1 storybook build --disable-telemetry",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.2.13",
"@storybook/addon-docs": "^10.2.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.11",
"@types/react-dom": "^19.2.3",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-grab": "workspace:*",
Expand Down
11 changes: 3 additions & 8 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,7 @@
"dist"
],
"type": "module",
"browser": "dist/client.global.js",
"exports": {
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.cjs"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js",
Expand All @@ -25,16 +19,17 @@
},
"scripts": {
"dev": "vp pack --watch",
"build": "rm -rf dist && NODE_ENV=production vp pack && cp dist/client.iife.js dist/client.global.js",
"build": "rm -rf dist && NODE_ENV=production vp pack",
"test": "vp test run",
"lint": "vp lint",
"format": "vp fmt",
"format:check": "vp fmt --check",
"check": "vp check"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.0",
"clipboardy": "^5.3.1",
"fkill": "^9.0.0",
"react-grab": "workspace:*",
"zod": "^3.25.0"
},
"devDependencies": {
Expand Down
98 changes: 0 additions & 98 deletions packages/mcp/src/client.ts

This file was deleted.

3 changes: 2 additions & 1 deletion packages/mcp/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const CONTEXT_TTL_MS = 5 * 60 * 1000;
export const DEFAULT_MCP_PORT = 4723;
export const HEALTH_CHECK_TIMEOUT_MS = 1000;
export const POST_KILL_DELAY_MS = 100;
export const REACT_GRAB_CLIPBOARD_END_MARKER = "--- /x-react-grab ---";
export const REACT_GRAB_CLIPBOARD_START_MARKER = "--- x-react-grab ---";
51 changes: 51 additions & 0 deletions packages/mcp/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vite-plus/test";
import {
REACT_GRAB_CLIPBOARD_END_MARKER,
REACT_GRAB_CLIPBOARD_START_MARKER,
} from "./constants.js";
import { parseClipboardContext } from "./server.js";

describe("parseClipboardContext", () => {
it("should parse a React Grab clipboard envelope", () => {
const clipboardText = [
"<button>Save</button>",
"",
REACT_GRAB_CLIPBOARD_START_MARKER,
JSON.stringify({
content: ["<button>Save</button>\n in SaveButton"],
prompt: "Make this primary",
}),
REACT_GRAB_CLIPBOARD_END_MARKER,
].join("\n");

expect(parseClipboardContext(clipboardText)).toEqual({
content: ["<button>Save</button>\n in SaveButton"],
prompt: "Make this primary",
});
});

it("should parse React Grab metadata from the clipboard envelope", () => {
const clipboardText = [
REACT_GRAB_CLIPBOARD_START_MARKER,
JSON.stringify({
content: "copied content",
entries: [
{
content: "<button>Save</button>",
commentText: "Use a filled variant",
},
],
}),
REACT_GRAB_CLIPBOARD_END_MARKER,
].join("\n");

expect(parseClipboardContext(clipboardText)).toEqual({
content: ["<button>Save</button>"],
prompt: "Use a filled variant",
});
});

it("should ignore unrelated clipboard text", () => {
expect(parseClipboardContext("plain clipboard text")).toBe(null);
});
});
101 changes: 55 additions & 46 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { createServer, type Server } from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import clipboard from "clipboardy";
import fkill from "fkill";
import { z } from "zod";
import {
CONTEXT_TTL_MS,
DEFAULT_MCP_PORT,
HEALTH_CHECK_TIMEOUT_MS,
POST_KILL_DELAY_MS,
REACT_GRAB_CLIPBOARD_END_MARKER,
REACT_GRAB_CLIPBOARD_START_MARKER,
} from "./constants.js";

const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
Expand All @@ -19,14 +21,17 @@ const agentContextSchema = z.object({
prompt: z.string().optional().describe("User prompt or instruction"),
});

type AgentContext = z.infer<typeof agentContextSchema>;
interface AgentContext extends z.infer<typeof agentContextSchema> {}

interface StoredContext {
context: AgentContext;
submittedAt: number;
}
const reactGrabEntrySchema = z.object({
content: z.string(),
commentText: z.string().optional(),
});

let latestContext: StoredContext | null = null;
const reactGrabMetadataSchema = z.object({
content: z.string(),
entries: z.array(reactGrabEntrySchema).optional(),
});

const textResult = (text: string) => ({
content: [{ type: "text" as const, text }],
Expand All @@ -41,6 +46,45 @@ const formatContext = (context: AgentContext): string => {
return parts.join("\n\n");
};

const normalizeReactGrabPayload = (payload: unknown): AgentContext | null => {
const agentContext = agentContextSchema.safeParse(payload);
if (agentContext.success) return agentContext.data;

const reactGrabMetadata = reactGrabMetadataSchema.safeParse(payload);
if (!reactGrabMetadata.success) return null;

const entries = reactGrabMetadata.data.entries;
return {
content: entries?.map((entry) => entry.content) ?? [reactGrabMetadata.data.content],
prompt: entries?.find((entry) => entry.commentText)?.commentText,
};
};

export const parseClipboardContext = (clipboardText: string): AgentContext | null => {
const startIndex = clipboardText.indexOf(REACT_GRAB_CLIPBOARD_START_MARKER);
if (startIndex === -1) return null;

const jsonStartIndex = startIndex + REACT_GRAB_CLIPBOARD_START_MARKER.length;
const endIndex = clipboardText.indexOf(REACT_GRAB_CLIPBOARD_END_MARKER, jsonStartIndex);
if (endIndex === -1) return null;

try {
const jsonText = clipboardText.slice(jsonStartIndex, endIndex).trim();
return normalizeReactGrabPayload(JSON.parse(jsonText));
} catch {
return null;
}
};

const readClipboardContext = async (): Promise<AgentContext | null> => {
try {
const clipboardText = await clipboard.read();
return parseClipboardContext(clipboardText);
} catch {
return null;
}
};

const createMcpServer = (): McpServer => {
const server = new McpServer(
{ name: "react-grab-mcp", version: "0.1.0" },
Expand All @@ -51,22 +95,15 @@ const createMcpServer = (): McpServer => {
"get_element_context",
{
description:
"Get the latest React Grab context that was submitted. Returns the most recent UI element selection with its prompt.",
"Read React Grab context from the clipboard. Returns the most recent copied UI element selection with its prompt.",
},
async () => {
if (!latestContext) {
const clipboardContext = await readClipboardContext();
if (!clipboardContext) {
return textResult("No context has been submitted yet.");
}

const isExpired = Date.now() - latestContext.submittedAt > CONTEXT_TTL_MS;
if (isExpired) {
latestContext = null;
return textResult("No context has been submitted yet.");
}

const result = textResult(formatContext(latestContext.context));
latestContext = null;
return result;
return textResult(formatContext(clipboardContext));
},
);

Expand Down Expand Up @@ -112,29 +149,6 @@ const createHttpServer = (port: number): Server => {
return;
}

if (url.pathname === "/context" && request.method === "POST") {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(chunk as Buffer);
}

try {
const body = JSON.parse(Buffer.concat(chunks).toString());
latestContext = {
context: agentContextSchema.parse(body),
submittedAt: Date.now(),
};
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ status: "ok" }));
} catch {
response
.writeHead(400, { "Content-Type": "application/json" })
.end(JSON.stringify({ error: "Invalid context payload" }));
}
return;
}

if (url.pathname === "/mcp") {
const sessionId = request.headers["mcp-session-id"] as string | undefined;
const existingSession = sessionId ? sessions.get(sessionId) : undefined;
Expand Down Expand Up @@ -230,11 +244,6 @@ export const startMcpServer = async ({
const mcpServer = createMcpServer();
const transport = new StdioServerTransport();
await mcpServer.server.connect(transport);

startHttpServer(port).then(
() => console.error(`React Grab context server listening on port ${port}`),
(error) => console.error(`Failed to start context server: ${error}`),
);
return;
}

Expand Down
21 changes: 0 additions & 21 deletions packages/mcp/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,6 @@ const nodeBuiltins = [

export default defineConfig({
pack: [
{
entry: ["src/client.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
sourcemap: false,
platform: "browser",
},
{
entry: ["src/client.ts"],
format: ["iife"],
globalName: "ReactGrabMcp",
dts: false,
clean: false,
minify: process.env.NODE_ENV === "production",
sourcemap: false,
platform: "browser",
deps: {
alwaysBundle: [/.*/],
},
},
{
entry: ["src/server.ts", "src/cli.ts"],
format: ["cjs", "esm"],
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,4 @@ The five built-in plugins are registered during `init()` through the same `regis

## Notes about MCP integration

The `@react-grab/mcp` package provides a plugin that bridges react-grab with AI coding assistants via the Model Context Protocol. The plugin hooks into `transformAgentContext` and `onCopySuccess` to POST element context to a local MCP server whenever the user copies or submits a prompt. The MCP server in turn exposes this context as MCP resources that coding assistants like Cursor and Claude Code can read. The plugin is registered like any other plugin and has no special privileges in the core.
The `@react-grab/mcp` package bridges react-grab with AI coding assistants via the Model Context Protocol. React Grab writes an `x-react-grab` JSON envelope into the plain-text clipboard output whenever context is copied. The MCP server exposes a `get_element_context` tool that reads the clipboard with `clipboardy`, extracts that envelope, and returns the copied context to agents like Cursor and Claude Code. This avoids a browser-side MCP client and does not require the page to POST context to a local server.
Loading
Loading