@atezer/figma-mcp-bridge 1.2.1 → 1.2.2

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.
@@ -17,7 +17,11 @@ import { createChildLogger } from "./core/logger.js";
17
17
  import { testBrowserRendering } from "./test-browser.js";
18
18
  import { FigmaAPI, extractFileKey } from "./core/figma-api.js";
19
19
  import { registerFigmaAPITools } from "./core/figma-tools.js";
20
+ import { FmcpRelaySession } from "./cloud-relay-session.js";
21
+ import { handleCloudModeRoutes, maybeTightenMcpCors } from "./cloud-mode-routes.js";
22
+ import { clientIp, deleteBind, deletePairing, FMCP_RL_PREFIX, generatePairingCode, generatePairingSecret, getBind, getPairing, PAIRING_TTL_SEC, putBind, putPairing, rateLimitAllow, } from "./cloud-mode-kv.js";
20
23
  const logger = createChildLogger({ component: "mcp-server" });
24
+ export { FmcpRelaySession };
21
25
  /**
22
26
  * F-MCP ATezer Agent
23
27
  * Extends McpAgent to provide Figma-specific debugging tools
@@ -25,6 +29,8 @@ const logger = createChildLogger({ component: "mcp-server" });
25
29
  export class FigmaMCP extends McpAgent {
26
30
  constructor() {
27
31
  super(...arguments);
32
+ // Root @modelcontextprotocol/sdk vs agents' nested copy — types diverge; runtime is compatible.
33
+ // @ts-expect-error TS2416 — McpServer duplicate package resolution
28
34
  this.server = new McpServer({
29
35
  name: "F-MCP ATezer",
30
36
  version: "0.1.0",
@@ -135,6 +141,44 @@ export class FigmaMCP extends McpAgent {
135
141
  }
136
142
  return this.sessionId;
137
143
  }
144
+ /**
145
+ * Streamable HTTP transport session id (isolated per MCP client). Distinct from OAuth FIXED_SESSION_ID.
146
+ */
147
+ getMcpTransportSessionId() {
148
+ const raw = this.name;
149
+ if (!raw?.includes(":"))
150
+ return null;
151
+ const [prefix, sid] = raw.split(":");
152
+ if (prefix !== "streamable-http" || !sid)
153
+ return null;
154
+ return sid;
155
+ }
156
+ async relayPluginRpc(options) {
157
+ const env = this.env;
158
+ const sid = this.getMcpTransportSessionId();
159
+ if (!sid) {
160
+ throw new Error("Cloud relay requires streamable HTTP MCP session (e.g. claude.ai remote connector).");
161
+ }
162
+ const bind = await getBind(env.OAUTH_STATE, sid);
163
+ if (!bind?.code) {
164
+ throw new Error("No cloud relay bound. Run fmcp_generate_pairing_code, enter code+secret in the Figma plugin (Cloud Mode), then call fmcp_cloud_bind with the same code and secret.");
165
+ }
166
+ const stub = env.FMCP_RELAY.get(env.FMCP_RELAY.idFromName(`pair:${bind.code}`));
167
+ const res = await stub.fetch(new Request("http://relay/rpc", {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({
171
+ method: options.method,
172
+ params: options.params ?? {},
173
+ fileKey: options.fileKey,
174
+ }),
175
+ }));
176
+ const j = (await res.json());
177
+ if (!res.ok || !j.ok) {
178
+ throw new Error(j.error || "relay_rpc_failed");
179
+ }
180
+ return j.result;
181
+ }
138
182
  /**
139
183
  * Get or create Figma API client with OAuth token from session
140
184
  */
@@ -650,6 +694,176 @@ export class FigmaMCP extends McpAgent {
650
694
  };
651
695
  }
652
696
  });
697
+ // ---- FMCP Cloud Mode (plugin relay via FMCP_RELAY Durable Object) ----
698
+ this.server.tool("fmcp_generate_pairing_code", "Generate a 6-character pairing code and secret for Cloud Mode. User pastes code + secret into the F-MCP Bridge plugin (Cloud Mode) so the plugin connects to this deployment via WebSocket. Then call fmcp_cloud_bind with the same values. Codes expire in 5 minutes. Isolated per MCP streamable-http session after bind.", {}, async () => {
699
+ const env = this.env;
700
+ const sid = this.getMcpTransportSessionId();
701
+ if (!sid) {
702
+ return {
703
+ content: [
704
+ {
705
+ type: "text",
706
+ text: JSON.stringify({
707
+ error: "Pairing tool requires streamable HTTP MCP session (remote connector).",
708
+ }),
709
+ },
710
+ ],
711
+ isError: true,
712
+ };
713
+ }
714
+ const rlOk = await rateLimitAllow(env.OAUTH_STATE, `${FMCP_RL_PREFIX}tool_pair:${sid}`, 15, 3600);
715
+ if (!rlOk) {
716
+ return {
717
+ content: [
718
+ {
719
+ type: "text",
720
+ text: JSON.stringify({ error: "Rate limited: too many pairing codes for this session." }),
721
+ },
722
+ ],
723
+ isError: true,
724
+ };
725
+ }
726
+ const code = generatePairingCode();
727
+ const secret = generatePairingSecret();
728
+ const record = { secret, createdAt: Date.now() };
729
+ await putPairing(env.OAUTH_STATE, code, record);
730
+ let origin = (env.MCP_OAUTH_BASE_URL || "").replace(/\/$/, "").trim();
731
+ if (!origin)
732
+ origin = "https://your-worker.workers.dev";
733
+ const wsOrigin = origin.startsWith("https://")
734
+ ? `wss://${origin.slice("https://".length)}`
735
+ : origin.startsWith("http://")
736
+ ? `ws://${origin.slice("http://".length)}`
737
+ : `wss://${origin}`;
738
+ const pluginWsUrl = `${wsOrigin}/fmcp-cloud/plugin?code=${encodeURIComponent(code)}&secret=${encodeURIComponent(secret)}`;
739
+ return {
740
+ content: [
741
+ {
742
+ type: "text",
743
+ text: JSON.stringify({
744
+ success: true,
745
+ code,
746
+ secret,
747
+ expiresInSeconds: PAIRING_TTL_SEC,
748
+ pluginWebSocketUrl: pluginWsUrl,
749
+ nextStep: "1) Open F-MCP Bridge plugin → enable Cloud Mode → paste code and secret. 2) Call fmcp_cloud_bind with the same code and secret.",
750
+ }, null, 2),
751
+ },
752
+ ],
753
+ };
754
+ });
755
+ this.server.tool("fmcp_cloud_bind", "Bind this MCP session to a pairing code after the Figma plugin has connected (or is about to connect) using fmcp_generate_pairing_code output. Validates secret; pairing slot is consumed.", {
756
+ code: z.string().min(4).max(8).describe("6-character pairing code (case-insensitive)"),
757
+ secret: z.string().min(8).describe("Secret string returned with the pairing code"),
758
+ }, async ({ code, secret }) => {
759
+ const env = this.env;
760
+ const sid = this.getMcpTransportSessionId();
761
+ if (!sid) {
762
+ return {
763
+ content: [{ type: "text", text: JSON.stringify({ error: "Requires streamable HTTP session." }) }],
764
+ isError: true,
765
+ };
766
+ }
767
+ const c = code.trim().toUpperCase();
768
+ const pair = await getPairing(env.OAUTH_STATE, c);
769
+ if (!pair || pair.secret !== secret) {
770
+ return {
771
+ content: [
772
+ {
773
+ type: "text",
774
+ text: JSON.stringify({ error: "Invalid or expired pairing code / secret." }),
775
+ },
776
+ ],
777
+ isError: true,
778
+ };
779
+ }
780
+ await putBind(env.OAUTH_STATE, sid, { code: c, boundAt: Date.now() });
781
+ await deletePairing(env.OAUTH_STATE, c);
782
+ return {
783
+ content: [
784
+ {
785
+ type: "text",
786
+ text: JSON.stringify({
787
+ success: true,
788
+ message: "Session bound to relay. Use fmcp_cloud_status or fmcp_plugin_bridge_request / REST tools as needed.",
789
+ }),
790
+ },
791
+ ],
792
+ };
793
+ });
794
+ this.server.tool("fmcp_cloud_status", "Return whether this MCP session is bound to a cloud relay and whether the Figma plugin WebSocket is connected.", {}, async () => {
795
+ const env = this.env;
796
+ const sid = this.getMcpTransportSessionId();
797
+ if (!sid) {
798
+ return {
799
+ content: [{ type: "text", text: JSON.stringify({ bound: false, reason: "not_streamable_http" }) }],
800
+ };
801
+ }
802
+ const bind = await getBind(env.OAUTH_STATE, sid);
803
+ if (!bind?.code) {
804
+ return {
805
+ content: [{ type: "text", text: JSON.stringify({ bound: false, pluginConnected: false }) }],
806
+ };
807
+ }
808
+ const stub = env.FMCP_RELAY.get(env.FMCP_RELAY.idFromName(`pair:${bind.code}`));
809
+ const res = await stub.fetch(new Request("http://relay/status", { method: "GET" }));
810
+ const st = (await res.json());
811
+ return {
812
+ content: [
813
+ {
814
+ type: "text",
815
+ text: JSON.stringify({
816
+ bound: true,
817
+ pairingCodeSuffix: bind.code.slice(-2),
818
+ pluginConnected: !!st.pluginConnected,
819
+ }),
820
+ },
821
+ ],
822
+ };
823
+ });
824
+ this.server.tool("fmcp_cloud_disconnect", "Unbind this MCP session from the cloud relay and ask the relay to close plugin WebSockets.", {}, async () => {
825
+ const env = this.env;
826
+ const sid = this.getMcpTransportSessionId();
827
+ if (!sid) {
828
+ return {
829
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "not_streamable_http" }) }],
830
+ isError: true,
831
+ };
832
+ }
833
+ const bind = await getBind(env.OAUTH_STATE, sid);
834
+ if (bind?.code) {
835
+ const stub = env.FMCP_RELAY.get(env.FMCP_RELAY.idFromName(`pair:${bind.code}`));
836
+ await stub.fetch(new Request("http://relay/disconnect", { method: "POST" }));
837
+ }
838
+ await deleteBind(env.OAUTH_STATE, sid);
839
+ return {
840
+ content: [
841
+ {
842
+ type: "text",
843
+ text: JSON.stringify({ success: true, message: "Cloud relay unbound." }),
844
+ },
845
+ ],
846
+ };
847
+ });
848
+ this.server.tool("fmcp_plugin_bridge_request", "Forward a single Plugin Bridge RPC to the connected Figma plugin (same protocol as local WebSocket bridge). Requires fmcp_cloud_bind and an active plugin connection. Example methods: getVariables, executeCodeViaUI, getDocumentStructure — see PluginBridgeConnector.", {
849
+ method: z.string().describe("Bridge method name, e.g. getVariables, executeCodeViaUI"),
850
+ params: z.record(z.unknown()).optional().describe("Method parameters object"),
851
+ fileKey: z.string().optional().describe("Optional file key for multi-file routing (same as local bridge)"),
852
+ }, async ({ method, params, fileKey }) => {
853
+ try {
854
+ const result = await this.relayPluginRpc({ method, params: params ?? {}, fileKey });
855
+ return {
856
+ content: [{ type: "text", text: JSON.stringify({ success: true, result }, null, 2) }],
857
+ };
858
+ }
859
+ catch (e) {
860
+ const msg = e instanceof Error ? e.message : String(e);
861
+ return {
862
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: msg }) }],
863
+ isError: true,
864
+ };
865
+ }
866
+ });
653
867
  // Register Figma API tools (Tools 8-14)
654
868
  registerFigmaAPITools(this.server, async () => await this.getFigmaAPI(), () => this.browserManager?.getCurrentUrl() || null, () => this.consoleMonitor || null, () => this.browserManager || null, () => this.ensureInitialized());
655
869
  }
@@ -661,13 +875,29 @@ export class FigmaMCP extends McpAgent {
661
875
  export default {
662
876
  async fetch(request, env, ctx) {
663
877
  const url = new URL(request.url);
878
+ const cloudRes = await handleCloudModeRoutes(request, env);
879
+ if (cloudRes)
880
+ return cloudRes;
664
881
  // SSE endpoint for remote MCP clients
665
882
  if (url.pathname === "/sse" || url.pathname === "/sse/message") {
666
- return FigmaMCP.serveSSE("/sse").fetch(request, env, ctx);
883
+ const r = await FigmaMCP.serveSSE("/sse").fetch(request, env, ctx);
884
+ return maybeTightenMcpCors(request, r);
667
885
  }
668
- // HTTP endpoint for direct MCP communication
886
+ // HTTP endpoint for direct MCP communication (Streamable HTTP — default in agents McpAgent.serve)
669
887
  if (url.pathname === "/mcp") {
670
- return FigmaMCP.serve("/mcp").fetch(request, env, ctx);
888
+ if (request.method === "POST" || request.method === "GET") {
889
+ const ip = clientIp(request);
890
+ const mcpOk = await rateLimitAllow(env.OAUTH_STATE, `${FMCP_RL_PREFIX}mcp:${ip}`, 180, 60);
891
+ if (!mcpOk) {
892
+ return new Response(JSON.stringify({
893
+ jsonrpc: "2.0",
894
+ error: { code: -32_603, message: "Rate limited" },
895
+ id: null,
896
+ }), { status: 429, headers: { "Content-Type": "application/json" } });
897
+ }
898
+ }
899
+ const r = await FigmaMCP.serve("/mcp").fetch(request, env, ctx);
900
+ return maybeTightenMcpCors(request, r);
671
901
  }
672
902
  // OAuth authorization initiation
673
903
  if (url.pathname === "/oauth/authorize") {
@@ -898,7 +1128,16 @@ export default {
898
1128
  status: "healthy",
899
1129
  service: "F-MCP ATezer",
900
1130
  version: "0.1.0",
901
- endpoints: ["/sse", "/mcp", "/test-browser", "/oauth/authorize", "/oauth/callback"],
1131
+ endpoints: [
1132
+ "/sse",
1133
+ "/mcp",
1134
+ "/fmcp-cloud/plugin",
1135
+ "/fmcp-cloud/pairing",
1136
+ "/fmcp-cloud/health",
1137
+ "/test-browser",
1138
+ "/oauth/authorize",
1139
+ "/oauth/callback",
1140
+ ],
902
1141
  oauth_configured: !!env.FIGMA_OAUTH_CLIENT_ID
903
1142
  }), {
904
1143
  headers: { "Content-Type": "application/json" },
@@ -51,6 +51,14 @@ You can use MCP **without** launching Figma with a debug port. When the plugin r
51
51
 
52
52
  Port is configurable via `FIGMA_PLUGIN_BRIDGE_PORT` or config `local.pluginBridgePort` (default 5454).
53
53
 
54
+ #### Figma Desktop, FigJam, and browser Figma — same port
55
+
56
+ The bridge is **one** WebSocket server on **one** port (default **5454**). It supports **multiple simultaneous plugin connections** (`multiClient: true`): Desktop, FigJam, and Figma-in-browser can all connect to `ws://localhost:5454` at the same time. MCP tools route by `fileKey` / `figma_list_connected_files`.
57
+
58
+ **Do not** assign different ports per app (e.g. 5454 Desktop, 5455 FigJam, 5456 browser) unless you run **separate** Node bridge processes with different `FIGMA_PLUGIN_BRIDGE_PORT` values. If only one MCP server is running on 5454, plugins pointed at 5455 or 5456 will show **no server**.
59
+
60
+ **Recommended:** Use **localhost** and **5454** everywhere (or leave Advanced closed so auto-scan finds the live port).
61
+
54
62
  ### Plugin-only mode (recommended: no REST API, no token)
55
63
 
56
64
  You can run **without** the full MCP server (figma-mcp-bridge) and **without** any Figma REST API token:
@@ -28,8 +28,10 @@
28
28
  "http://localhost:5467", "ws://localhost:5467",
29
29
  "http://localhost:5468", "ws://localhost:5468",
30
30
  "http://localhost:5469", "ws://localhost:5469",
31
- "http://localhost:5470", "ws://localhost:5470"
31
+ "http://localhost:5470", "ws://localhost:5470",
32
+ "https://figma-mcp-bridge.workers.dev",
33
+ "wss://figma-mcp-bridge.workers.dev"
32
34
  ],
33
- "reasoning": "Connect to local MCP server (no Figma debug port needed)"
35
+ "reasoning": "Local MCP WebSocket (5454–5470) and optional FMCP Cloud Mode (Workers); add your custom worker host here if different."
34
36
  }
35
37
  }