@atezer/figma-mcp-bridge 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -9
- package/README.md +8 -10
- package/dist/cloudflare/index.js +4 -243
- package/dist/core/plugin-bridge-server.d.ts +16 -0
- package/dist/core/plugin-bridge-server.d.ts.map +1 -1
- package/dist/core/plugin-bridge-server.js +49 -12
- package/dist/core/plugin-bridge-server.js.map +1 -1
- package/dist/local-plugin-only.d.ts.map +1 -1
- package/dist/local-plugin-only.js +69 -6
- package/dist/local-plugin-only.js.map +1 -1
- package/f-mcp-plugin/README.md +0 -8
- package/f-mcp-plugin/manifest.json +2 -4
- package/f-mcp-plugin/ui.html +193 -450
- package/package.json +7 -3
- package/dist/cloudflare/cloud-cors.js +0 -40
- package/dist/cloudflare/cloud-mode-kv.js +0 -86
- package/dist/cloudflare/cloud-mode-routes.js +0 -97
- package/dist/cloudflare/cloud-relay-session.js +0 -141
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atezer/figma-mcp-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "F-MCP ATezer: MCP server and Figma plugin bridge for Claude/Cursor. No REST token required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/local.js",
|
|
@@ -36,8 +36,7 @@
|
|
|
36
36
|
"cf-typegen": "wrangler types",
|
|
37
37
|
"type-check": "tsc --noEmit",
|
|
38
38
|
"validate:fmcp-skills": "node scripts/validate-fmcp-skills-tools.mjs",
|
|
39
|
-
"check-ports": "bash scripts/check-ports.sh"
|
|
40
|
-
"smoke:cloud": "node scripts/smoke-fmcp-cloud.mjs"
|
|
39
|
+
"check-ports": "bash scripts/check-ports.sh"
|
|
41
40
|
},
|
|
42
41
|
"keywords": [
|
|
43
42
|
"mcp",
|
|
@@ -60,6 +59,11 @@
|
|
|
60
59
|
"engines": {
|
|
61
60
|
"node": ">=18.0.0"
|
|
62
61
|
},
|
|
62
|
+
"overrides": {
|
|
63
|
+
"agents": {
|
|
64
|
+
"@modelcontextprotocol/sdk": "1.25.3"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
63
67
|
"dependencies": {
|
|
64
68
|
"@cloudflare/puppeteer": "^1.0.4",
|
|
65
69
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CORS allowlist for Cloud Mode + remote MCP (claude.ai, v0, Lovable, etc.).
|
|
3
|
-
*/
|
|
4
|
-
const DEFAULT_ALLOWED_ORIGINS = [
|
|
5
|
-
"https://claude.ai",
|
|
6
|
-
"https://www.claude.ai",
|
|
7
|
-
"https://v0.dev",
|
|
8
|
-
"https://www.v0.dev",
|
|
9
|
-
"https://lovable.dev",
|
|
10
|
-
"https://www.lovable.dev",
|
|
11
|
-
];
|
|
12
|
-
export function getAllowedCorsOrigin(request, extra) {
|
|
13
|
-
const origin = request.headers.get("Origin");
|
|
14
|
-
if (!origin)
|
|
15
|
-
return null;
|
|
16
|
-
const set = new Set([...DEFAULT_ALLOWED_ORIGINS, ...(extra ?? [])]);
|
|
17
|
-
return set.has(origin) ? origin : null;
|
|
18
|
-
}
|
|
19
|
-
export function corsHeaders(request, extraOrigins) {
|
|
20
|
-
const o = getAllowedCorsOrigin(request, extraOrigins);
|
|
21
|
-
const base = {
|
|
22
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
23
|
-
"Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, mcp-session-id, Mcp-Protocol-Version, mcp-protocol-version",
|
|
24
|
-
"Access-Control-Expose-Headers": "mcp-session-id",
|
|
25
|
-
"Access-Control-Max-Age": "86400",
|
|
26
|
-
};
|
|
27
|
-
if (o)
|
|
28
|
-
base["Access-Control-Allow-Origin"] = o;
|
|
29
|
-
else
|
|
30
|
-
base["Access-Control-Allow-Origin"] = "*";
|
|
31
|
-
return base;
|
|
32
|
-
}
|
|
33
|
-
export function withCors(request, response, extraOrigins) {
|
|
34
|
-
const h = new Headers(response.headers);
|
|
35
|
-
const ch = corsHeaders(request, extraOrigins);
|
|
36
|
-
for (const [k, v] of Object.entries(ch)) {
|
|
37
|
-
h.set(k, v);
|
|
38
|
-
}
|
|
39
|
-
return new Response(response.body, { status: response.status, headers: h });
|
|
40
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cloud Mode pairing / bind / rate-limit helpers (KV: reuses OAUTH_STATE binding).
|
|
3
|
-
* Key prefixes are reserved to avoid collisions with OAuth state tokens (64+ hex).
|
|
4
|
-
*/
|
|
5
|
-
export const FMCP_PAIR_PREFIX = "fmcp_pair:";
|
|
6
|
-
export const FMCP_BIND_PREFIX = "fmcp_bind:";
|
|
7
|
-
export const FMCP_RL_PREFIX = "fmcp_rl:";
|
|
8
|
-
export const PAIRING_TTL_SEC = 300;
|
|
9
|
-
export const BIND_TTL_SEC = 86400;
|
|
10
|
-
export const PAIRING_CODE_LENGTH = 6;
|
|
11
|
-
const CODE_CHARS = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
12
|
-
export function generatePairingCode() {
|
|
13
|
-
let out = "";
|
|
14
|
-
const arr = new Uint8Array(PAIRING_CODE_LENGTH);
|
|
15
|
-
crypto.getRandomValues(arr);
|
|
16
|
-
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
|
|
17
|
-
out += CODE_CHARS[arr[i] % CODE_CHARS.length];
|
|
18
|
-
}
|
|
19
|
-
return out;
|
|
20
|
-
}
|
|
21
|
-
export function generatePairingSecret() {
|
|
22
|
-
const a = new Uint8Array(16);
|
|
23
|
-
crypto.getRandomValues(a);
|
|
24
|
-
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
-
}
|
|
26
|
-
export async function putPairing(kv, code, record) {
|
|
27
|
-
await kv.put(`${FMCP_PAIR_PREFIX}${code}`, JSON.stringify(record), {
|
|
28
|
-
expirationTtl: PAIRING_TTL_SEC,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
export async function getPairing(kv, code) {
|
|
32
|
-
const raw = await kv.get(`${FMCP_PAIR_PREFIX}${code}`, "text");
|
|
33
|
-
if (!raw)
|
|
34
|
-
return null;
|
|
35
|
-
try {
|
|
36
|
-
return JSON.parse(raw);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
export async function deletePairing(kv, code) {
|
|
43
|
-
await kv.delete(`${FMCP_PAIR_PREFIX}${code}`);
|
|
44
|
-
}
|
|
45
|
-
export async function putBind(kv, mcpSessionId, record) {
|
|
46
|
-
await kv.put(`${FMCP_BIND_PREFIX}${mcpSessionId}`, JSON.stringify(record), {
|
|
47
|
-
expirationTtl: BIND_TTL_SEC,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
export async function getBind(kv, mcpSessionId) {
|
|
51
|
-
const raw = await kv.get(`${FMCP_BIND_PREFIX}${mcpSessionId}`, "text");
|
|
52
|
-
if (!raw)
|
|
53
|
-
return null;
|
|
54
|
-
try {
|
|
55
|
-
return JSON.parse(raw);
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
export async function deleteBind(kv, mcpSessionId) {
|
|
62
|
-
await kv.delete(`${FMCP_BIND_PREFIX}${mcpSessionId}`);
|
|
63
|
-
}
|
|
64
|
-
export async function rateLimitAllow(kv, key, limit, windowSec) {
|
|
65
|
-
const now = Date.now();
|
|
66
|
-
const raw = await kv.get(key, "text");
|
|
67
|
-
let stamps = [];
|
|
68
|
-
if (raw) {
|
|
69
|
-
try {
|
|
70
|
-
stamps = JSON.parse(raw).t ?? [];
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
stamps = [];
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const windowMs = windowSec * 1000;
|
|
77
|
-
stamps = stamps.filter((ts) => now - ts < windowMs);
|
|
78
|
-
if (stamps.length >= limit)
|
|
79
|
-
return false;
|
|
80
|
-
stamps.push(now);
|
|
81
|
-
await kv.put(key, JSON.stringify({ t: stamps }), { expirationTtl: Math.max(windowSec * 2, 120) });
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
84
|
-
export function clientIp(request) {
|
|
85
|
-
return request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || "unknown";
|
|
86
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP + WebSocket routes for FMCP Cloud Mode (pairing, plugin bridge).
|
|
3
|
-
*/
|
|
4
|
-
import { corsHeaders, getAllowedCorsOrigin, withCors } from "./cloud-cors.js";
|
|
5
|
-
import { clientIp, FMCP_RL_PREFIX, generatePairingCode, generatePairingSecret, PAIRING_TTL_SEC, putPairing, rateLimitAllow, getPairing, } from "./cloud-mode-kv.js";
|
|
6
|
-
const PAIRING_HTTP_LIMIT = 20;
|
|
7
|
-
const PAIRING_HTTP_WINDOW_SEC = 60;
|
|
8
|
-
function json(data, status = 200, request) {
|
|
9
|
-
const headers = { "Content-Type": "application/json" };
|
|
10
|
-
if (request)
|
|
11
|
-
Object.assign(headers, corsHeaders(request));
|
|
12
|
-
return new Response(JSON.stringify(data), { status, headers });
|
|
13
|
-
}
|
|
14
|
-
function baseUrl(request) {
|
|
15
|
-
const u = new URL(request.url);
|
|
16
|
-
return `${u.protocol}//${u.host}`;
|
|
17
|
-
}
|
|
18
|
-
export async function handleCloudModeRoutes(request, env) {
|
|
19
|
-
const url = new URL(request.url);
|
|
20
|
-
if (url.pathname === "/fmcp-cloud/plugin") {
|
|
21
|
-
if (request.method === "OPTIONS") {
|
|
22
|
-
return new Response(null, { headers: corsHeaders(request) });
|
|
23
|
-
}
|
|
24
|
-
if (request.headers.get("Upgrade") !== "websocket") {
|
|
25
|
-
return json({ error: "expected_websocket_upgrade" }, 426, request);
|
|
26
|
-
}
|
|
27
|
-
const code = url.searchParams.get("code")?.trim().toUpperCase();
|
|
28
|
-
const secret = url.searchParams.get("secret");
|
|
29
|
-
if (!code || !secret) {
|
|
30
|
-
return json({ error: "missing_code_or_secret" }, 400, request);
|
|
31
|
-
}
|
|
32
|
-
const pair = await getPairing(env.OAUTH_STATE, code);
|
|
33
|
-
if (!pair || pair.secret !== secret) {
|
|
34
|
-
return json({ error: "invalid_or_expired_pairing" }, 401, request);
|
|
35
|
-
}
|
|
36
|
-
const id = env.FMCP_RELAY.idFromName(`pair:${code}`);
|
|
37
|
-
const res = await env.FMCP_RELAY.get(id).fetch(request);
|
|
38
|
-
return withCors(request, res);
|
|
39
|
-
}
|
|
40
|
-
if (url.pathname === "/fmcp-cloud/pairing") {
|
|
41
|
-
if (request.method === "OPTIONS") {
|
|
42
|
-
return new Response(null, { headers: corsHeaders(request) });
|
|
43
|
-
}
|
|
44
|
-
if (request.method !== "POST") {
|
|
45
|
-
return json({ error: "method_not_allowed" }, 405, request);
|
|
46
|
-
}
|
|
47
|
-
const token = env.FMCP_PAIRING_TOKEN;
|
|
48
|
-
if (token) {
|
|
49
|
-
const auth = request.headers.get("Authorization");
|
|
50
|
-
const expected = `Bearer ${token}`;
|
|
51
|
-
if (auth !== expected) {
|
|
52
|
-
return json({ error: "unauthorized" }, 401, request);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const ip = clientIp(request);
|
|
56
|
-
const rlKey = `${FMCP_RL_PREFIX}pair:${ip}`;
|
|
57
|
-
const ok = await rateLimitAllow(env.OAUTH_STATE, rlKey, PAIRING_HTTP_LIMIT, PAIRING_HTTP_WINDOW_SEC);
|
|
58
|
-
if (!ok) {
|
|
59
|
-
return json({ error: "rate_limited" }, 429, request);
|
|
60
|
-
}
|
|
61
|
-
const code = generatePairingCode();
|
|
62
|
-
const secret = generatePairingSecret();
|
|
63
|
-
const record = { secret, createdAt: Date.now() };
|
|
64
|
-
await putPairing(env.OAUTH_STATE, code, record);
|
|
65
|
-
const origin = baseUrl(request);
|
|
66
|
-
const wsProto = url.protocol === "https:" ? "wss:" : "ws:";
|
|
67
|
-
const wsHost = url.host;
|
|
68
|
-
const pluginWsUrl = `${wsProto}//${wsHost}/fmcp-cloud/plugin?code=${encodeURIComponent(code)}&secret=${encodeURIComponent(secret)}`;
|
|
69
|
-
return json({
|
|
70
|
-
ok: true,
|
|
71
|
-
code,
|
|
72
|
-
secret,
|
|
73
|
-
expiresInSeconds: PAIRING_TTL_SEC,
|
|
74
|
-
pluginWebSocketUrl: pluginWsUrl,
|
|
75
|
-
hint: "Paste code and secret into F-MCP plugin Cloud Mode, or share code + secret with your AI to call fmcp_cloud_bind.",
|
|
76
|
-
}, 200, request);
|
|
77
|
-
}
|
|
78
|
-
if (url.pathname === "/fmcp-cloud/health") {
|
|
79
|
-
return json({ ok: true, cloudMode: true }, 200, request);
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
/** Merge restrictive CORS onto MCP / SSE responses when Origin matches allowlist. */
|
|
84
|
-
export function maybeTightenMcpCors(request, response) {
|
|
85
|
-
const origin = request.headers.get("Origin");
|
|
86
|
-
if (!origin)
|
|
87
|
-
return response;
|
|
88
|
-
const allowed = getAllowedCorsOrigin(request);
|
|
89
|
-
if (!allowed)
|
|
90
|
-
return response;
|
|
91
|
-
const h = new Headers(response.headers);
|
|
92
|
-
h.set("Access-Control-Allow-Origin", allowed);
|
|
93
|
-
if (!h.has("Access-Control-Expose-Headers")) {
|
|
94
|
-
h.set("Access-Control-Expose-Headers", "mcp-session-id");
|
|
95
|
-
}
|
|
96
|
-
return new Response(response.body, { status: response.status, headers: h });
|
|
97
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FMCP Cloud Mode — per-pairing-code Durable Object.
|
|
3
|
-
* Holds the Figma plugin WebSocket and forwards PluginBridge RPC to the plugin.
|
|
4
|
-
*/
|
|
5
|
-
const RPC_TIMEOUT_MS = 120_000;
|
|
6
|
-
export class FmcpRelaySession {
|
|
7
|
-
constructor(ctx, env) {
|
|
8
|
-
this.ctx = ctx;
|
|
9
|
-
this.pending = new Map();
|
|
10
|
-
this.env = env;
|
|
11
|
-
}
|
|
12
|
-
relayNameFromId() {
|
|
13
|
-
return this.ctx.id.toString();
|
|
14
|
-
}
|
|
15
|
-
async fetch(request) {
|
|
16
|
-
const url = new URL(request.url);
|
|
17
|
-
if (request.method === "POST" && url.pathname.endsWith("/disconnect")) {
|
|
18
|
-
for (const s of this.ctx.getWebSockets()) {
|
|
19
|
-
try {
|
|
20
|
-
s.close(1000, "disconnect");
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
/* ignore */
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return Response.json({ ok: true, closed: true });
|
|
27
|
-
}
|
|
28
|
-
if (request.method === "POST" && url.pathname.endsWith("/rpc")) {
|
|
29
|
-
return this.handleRpc(request);
|
|
30
|
-
}
|
|
31
|
-
if (request.method === "GET" && url.pathname.endsWith("/status")) {
|
|
32
|
-
const sockets = this.ctx.getWebSockets();
|
|
33
|
-
const connected = sockets.length > 0;
|
|
34
|
-
return Response.json({
|
|
35
|
-
ok: true,
|
|
36
|
-
pluginConnected: connected,
|
|
37
|
-
relayId: this.relayNameFromId(),
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
if (request.headers.get("Upgrade") !== "websocket") {
|
|
41
|
-
return new Response("Not found", { status: 404 });
|
|
42
|
-
}
|
|
43
|
-
const pair = new WebSocketPair();
|
|
44
|
-
const [client, server] = Object.values(pair);
|
|
45
|
-
this.ctx.acceptWebSocket(server);
|
|
46
|
-
return new Response(null, { status: 101, webSocket: client });
|
|
47
|
-
}
|
|
48
|
-
async handleRpc(request) {
|
|
49
|
-
let body;
|
|
50
|
-
try {
|
|
51
|
-
body = (await request.json());
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return Response.json({ ok: false, error: "invalid_json" }, { status: 400 });
|
|
55
|
-
}
|
|
56
|
-
const method = body.method;
|
|
57
|
-
if (!method || typeof method !== "string") {
|
|
58
|
-
return Response.json({ ok: false, error: "missing_method" }, { status: 400 });
|
|
59
|
-
}
|
|
60
|
-
const sockets = this.ctx.getWebSockets();
|
|
61
|
-
if (!sockets || sockets.length === 0) {
|
|
62
|
-
return Response.json({ ok: false, error: "plugin_not_connected" }, { status: 503 });
|
|
63
|
-
}
|
|
64
|
-
const ws = sockets[0];
|
|
65
|
-
const id = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
66
|
-
const payload = JSON.stringify({
|
|
67
|
-
id,
|
|
68
|
-
method,
|
|
69
|
-
params: body.params ?? {},
|
|
70
|
-
});
|
|
71
|
-
try {
|
|
72
|
-
const result = await new Promise((resolve, reject) => {
|
|
73
|
-
const timeout = setTimeout(() => {
|
|
74
|
-
this.pending.delete(id);
|
|
75
|
-
reject(new Error(`Plugin bridge request '${method}' timed out after ${RPC_TIMEOUT_MS}ms`));
|
|
76
|
-
}, RPC_TIMEOUT_MS);
|
|
77
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
78
|
-
ws.send(payload);
|
|
79
|
-
});
|
|
80
|
-
return Response.json({ ok: true, result });
|
|
81
|
-
}
|
|
82
|
-
catch (e) {
|
|
83
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
84
|
-
return Response.json({ ok: false, error: msg }, { status: 500 });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
async webSocketMessage(ws, message) {
|
|
88
|
-
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
89
|
-
let msg;
|
|
90
|
-
try {
|
|
91
|
-
msg = JSON.parse(text);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const t = msg.type;
|
|
97
|
-
if (t === "pong" || t === "keepalive")
|
|
98
|
-
return;
|
|
99
|
-
if (t === "ready") {
|
|
100
|
-
try {
|
|
101
|
-
ws.send(JSON.stringify({
|
|
102
|
-
type: "welcome",
|
|
103
|
-
bridgeVersion: "cloud-1.0.0",
|
|
104
|
-
port: 0,
|
|
105
|
-
clientId: `cloud_${Date.now()}`,
|
|
106
|
-
multiClient: false,
|
|
107
|
-
}));
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
/* ignore */
|
|
111
|
-
}
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
const mid = msg.id;
|
|
115
|
-
if (mid && this.pending.has(mid)) {
|
|
116
|
-
const p = this.pending.get(mid);
|
|
117
|
-
this.pending.delete(mid);
|
|
118
|
-
clearTimeout(p.timeout);
|
|
119
|
-
if (msg.error) {
|
|
120
|
-
p.reject(new Error(String(msg.error)));
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
p.resolve(msg.result);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
async webSocketClose(_ws, _code, _reason, _wasClean) {
|
|
128
|
-
for (const [rid, p] of this.pending) {
|
|
129
|
-
clearTimeout(p.timeout);
|
|
130
|
-
p.reject(new Error("Plugin disconnected"));
|
|
131
|
-
this.pending.delete(rid);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
async webSocketError(_ws, _error) {
|
|
135
|
-
for (const [rid, p] of this.pending) {
|
|
136
|
-
clearTimeout(p.timeout);
|
|
137
|
-
p.reject(new Error("Plugin WebSocket error"));
|
|
138
|
-
this.pending.delete(rid);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|