@mp3wizard/figma-console-mcp 1.15.2 → 1.15.4

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.
@@ -29,19 +29,6 @@ export { PluginRelayDO } from "./core/cloud-websocket-relay.js";
29
29
  // Note: MCP Apps (Token Browser, Dashboard) are only available in local mode
30
30
  // They require Node.js file system APIs for serving HTML that don't work in Cloudflare Workers
31
31
  const logger = createChildLogger({ component: "mcp-server" });
32
- /** Escape HTML special characters to prevent XSS in server-rendered HTML responses */
33
- function htmlEscape(s) {
34
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
35
- }
36
- /** Apply baseline HTTP security headers to any Response */
37
- function withSecurityHeaders(response) {
38
- const headers = new Headers(response.headers);
39
- headers.set('X-Content-Type-Options', 'nosniff');
40
- headers.set('X-Frame-Options', 'DENY');
41
- headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
42
- headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
43
- return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
44
- }
45
32
  /**
46
33
  * Figma Console MCP Agent
47
34
  * Extends McpAgent to provide Figma-specific debugging tools
@@ -51,7 +38,7 @@ export class FigmaConsoleMCPv3 extends McpAgent {
51
38
  super(...arguments);
52
39
  this.server = new McpServer({
53
40
  name: "Figma Console MCP",
54
- version: "1.13.0",
41
+ version: "1.15.0",
55
42
  });
56
43
  this.browserManager = null;
57
44
  this.consoleMonitor = null;
@@ -124,8 +111,11 @@ export class FigmaConsoleMCPv3 extends McpAgent {
124
111
  if (this.sessionId) {
125
112
  return; // Already loaded
126
113
  }
127
- // Use a unique, per-Durable-Object session ID so that each MCP client
128
- // (identified by Cloudflare's DO routing) gets its own OAuth token.
114
+ // IMPORTANT: Use a fixed session ID for all MCP connections
115
+ // This ensures OAuth tokens persist across MCP server reconnections
116
+ // Each user of this MCP server will share the same OAuth token
117
+ const FIXED_SESSION_ID = "figma-console-mcp-default-session";
118
+ // Try to load from Durable Object storage
129
119
  // @ts-ignore - this.ctx is available in Durable Object context
130
120
  const storage = this.ctx?.storage;
131
121
  if (storage) {
@@ -133,14 +123,14 @@ export class FigmaConsoleMCPv3 extends McpAgent {
133
123
  const storedSessionId = await storage.get('sessionId');
134
124
  if (storedSessionId) {
135
125
  this.sessionId = storedSessionId;
136
- logger.info({ hasSessionId: true }, "Loaded persistent session ID from storage");
126
+ logger.info({ sessionId: this.sessionId }, "Loaded persistent session ID from storage");
137
127
  return;
138
128
  }
139
129
  else {
140
- // Generate a unique session ID for this Durable Object instance
141
- this.sessionId = FigmaConsoleMCPv3.generateStateToken();
130
+ // Store the fixed session ID
131
+ this.sessionId = FIXED_SESSION_ID;
142
132
  await storage.put('sessionId', this.sessionId);
143
- logger.info({ hasSessionId: true }, "Initialized unique session ID");
133
+ logger.info({ sessionId: this.sessionId }, "Initialized fixed session ID");
144
134
  return;
145
135
  }
146
136
  }
@@ -148,9 +138,9 @@ export class FigmaConsoleMCPv3 extends McpAgent {
148
138
  logger.warn({ error: e }, "Failed to access Durable Object storage for session ID");
149
139
  }
150
140
  }
151
- // Fallback: generate unique session ID without persistence
152
- this.sessionId = FigmaConsoleMCPv3.generateStateToken();
153
- logger.info({ hasSessionId: true }, "Using generated session ID (storage unavailable)");
141
+ // Fallback: use fixed session ID directly
142
+ this.sessionId = FIXED_SESSION_ID;
143
+ logger.info({ sessionId: this.sessionId }, "Using fixed session ID (storage unavailable)");
154
144
  }
155
145
  /**
156
146
  * Get session ID for this Durable Object instance
@@ -777,746 +767,710 @@ export class FigmaConsoleMCPv3 extends McpAgent {
777
767
  */
778
768
  export default {
779
769
  async fetch(request, env, ctx) {
780
- const response = await handleRequest(request, env, ctx);
781
- return withSecurityHeaders(response);
782
- }
783
- };
784
- async function handleRequest(request, env, ctx) {
785
- const url = new URL(request.url);
786
- // Use canonical origin for OAuth redirect URIs so they match the Figma OAuth app config
787
- // regardless of whether the request comes via workers.dev or custom domain
788
- const oauthOrigin = env.CANONICAL_ORIGIN || url.origin;
789
- // Redirect /docs to subdomain
790
- if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) {
791
- const newPath = url.pathname.replace(/^\/docs\/?/, "/");
792
- const redirectUrl = `https://docs.figma-console-mcp.southleft.com${newPath}${url.search}`;
793
- return Response.redirect(redirectUrl, 301);
794
- }
795
- // ================================================================
796
- // Cloud Write Relay — Plugin WebSocket pairing endpoint
797
- // ================================================================
798
- if (url.pathname === "/ws/pair") {
799
- const code = url.searchParams.get("code");
800
- if (!code) {
801
- return new Response(JSON.stringify({ error: "Missing pairing code" }), {
802
- status: 400,
803
- headers: { "Content-Type": "application/json" },
804
- });
805
- }
806
- // Look up pairing code in KV
807
- const pairingKey = `pairing:${code.toUpperCase()}`;
808
- const relayDoId = await env.OAUTH_TOKENS.get(pairingKey);
809
- if (!relayDoId) {
810
- return new Response(JSON.stringify({ error: "Invalid or expired pairing code" }), {
811
- status: 404,
812
- headers: { "Content-Type": "application/json" },
813
- });
770
+ const url = new URL(request.url);
771
+ // Use canonical origin for OAuth redirect URIs so they match the Figma OAuth app config
772
+ // regardless of whether the request comes via workers.dev or custom domain
773
+ const oauthOrigin = env.CANONICAL_ORIGIN || url.origin;
774
+ // Redirect /docs to subdomain
775
+ if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) {
776
+ const newPath = url.pathname.replace(/^\/docs\/?/, "/");
777
+ const redirectUrl = `https://docs.figma-console-mcp.southleft.com${newPath}${url.search}`;
778
+ return Response.redirect(redirectUrl, 301);
814
779
  }
815
- // Delete used code (one-time use)
816
- await env.OAUTH_TOKENS.delete(pairingKey);
817
- // Forward WebSocket upgrade to the relay DO
818
- const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
819
- const stub = env.PLUGIN_RELAY.get(doId);
820
- // Rewrite URL to the relay DO's /ws/connect path
821
- const relayUrl = new URL(request.url);
822
- relayUrl.pathname = "/ws/connect";
823
- const relayRequest = new Request(relayUrl.toString(), request);
824
- return stub.fetch(relayRequest);
825
- }
826
- // SSE endpoint for remote MCP clients
827
- // Per MCP spec, we MUST validate Bearer tokens on every HTTP request
828
- if (url.pathname === "/sse" || url.pathname === "/sse/message") {
829
- // Validate Authorization header per MCP OAuth 2.1 spec
830
- const authHeader = request.headers.get("Authorization");
831
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
832
- logger.warn({ pathname: url.pathname }, "SSE request missing Authorization header - returning 401 with resource_metadata");
833
- // MCP spec requires resource_metadata URL in WWW-Authenticate header (RFC9728)
834
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
835
- return new Response(JSON.stringify({
836
- error: "unauthorized",
837
- error_description: "Authorization header with Bearer token is required"
838
- }), {
839
- status: 401,
840
- headers: {
841
- "Content-Type": "application/json",
842
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
843
- }
844
- });
780
+ // ================================================================
781
+ // Cloud Write Relay — Plugin WebSocket pairing endpoint
782
+ // ================================================================
783
+ if (url.pathname === "/ws/pair") {
784
+ const code = url.searchParams.get("code");
785
+ if (!code) {
786
+ return new Response(JSON.stringify({ error: "Missing pairing code" }), {
787
+ status: 400,
788
+ headers: { "Content-Type": "application/json" },
789
+ });
790
+ }
791
+ // Look up pairing code in KV
792
+ const pairingKey = `pairing:${code.toUpperCase()}`;
793
+ const relayDoId = await env.OAUTH_TOKENS.get(pairingKey);
794
+ if (!relayDoId) {
795
+ return new Response(JSON.stringify({ error: "Invalid or expired pairing code" }), {
796
+ status: 404,
797
+ headers: { "Content-Type": "application/json" },
798
+ });
799
+ }
800
+ // Delete used code (one-time use)
801
+ await env.OAUTH_TOKENS.delete(pairingKey);
802
+ // Forward WebSocket upgrade to the relay DO
803
+ const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
804
+ const stub = env.PLUGIN_RELAY.get(doId);
805
+ // Rewrite URL to the relay DO's /ws/connect path
806
+ const relayUrl = new URL(request.url);
807
+ relayUrl.pathname = "/ws/connect";
808
+ const relayRequest = new Request(relayUrl.toString(), request);
809
+ return stub.fetch(relayRequest);
845
810
  }
846
- const bearerToken = authHeader.substring(7); // Remove "Bearer " prefix
847
- const bearerKey = `bearer_token:${bearerToken}`;
848
- try {
849
- const tokenDataJson = await env.OAUTH_TOKENS.get(bearerKey);
850
- if (!tokenDataJson) {
851
- logger.warn({ pathname: url.pathname }, "SSE request with invalid Bearer token");
811
+ // SSE endpoint for remote MCP clients
812
+ // Per MCP spec, we MUST validate Bearer tokens on every HTTP request
813
+ if (url.pathname === "/sse" || url.pathname === "/sse/message") {
814
+ // Validate Authorization header per MCP OAuth 2.1 spec
815
+ const authHeader = request.headers.get("Authorization");
816
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
817
+ logger.warn({ pathname: url.pathname }, "SSE request missing Authorization header - returning 401 with resource_metadata");
818
+ // MCP spec requires resource_metadata URL in WWW-Authenticate header (RFC9728)
852
819
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
853
820
  return new Response(JSON.stringify({
854
- error: "invalid_token",
855
- error_description: "Bearer token is invalid or expired"
821
+ error: "unauthorized",
822
+ error_description: "Authorization header with Bearer token is required"
856
823
  }), {
857
824
  status: 401,
858
825
  headers: {
859
826
  "Content-Type": "application/json",
860
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
827
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
861
828
  }
862
829
  });
863
830
  }
864
- const tokenData = JSON.parse(tokenDataJson);
865
- // Check if token is expired
866
- if (tokenData.expiresAt < Date.now()) {
867
- logger.warn({ pathname: url.pathname, sessionId: tokenData.sessionId }, "SSE request with expired Bearer token");
868
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
831
+ const bearerToken = authHeader.substring(7); // Remove "Bearer " prefix
832
+ const bearerKey = `bearer_token:${bearerToken}`;
833
+ try {
834
+ const tokenDataJson = await env.OAUTH_TOKENS.get(bearerKey);
835
+ if (!tokenDataJson) {
836
+ logger.warn({ pathname: url.pathname }, "SSE request with invalid Bearer token");
837
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
838
+ return new Response(JSON.stringify({
839
+ error: "invalid_token",
840
+ error_description: "Bearer token is invalid or expired"
841
+ }), {
842
+ status: 401,
843
+ headers: {
844
+ "Content-Type": "application/json",
845
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
846
+ }
847
+ });
848
+ }
849
+ const tokenData = JSON.parse(tokenDataJson);
850
+ // Check if token is expired
851
+ if (tokenData.expiresAt < Date.now()) {
852
+ logger.warn({ pathname: url.pathname, sessionId: tokenData.sessionId }, "SSE request with expired Bearer token");
853
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
854
+ return new Response(JSON.stringify({
855
+ error: "invalid_token",
856
+ error_description: "Bearer token has expired"
857
+ }), {
858
+ status: 401,
859
+ headers: {
860
+ "Content-Type": "application/json",
861
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
862
+ }
863
+ });
864
+ }
865
+ logger.info({ pathname: url.pathname, sessionId: tokenData.sessionId }, "SSE request authenticated successfully");
866
+ }
867
+ catch (error) {
868
+ logger.error({ error, pathname: url.pathname }, "Error validating Bearer token");
869
869
  return new Response(JSON.stringify({
870
- error: "invalid_token",
871
- error_description: "Bearer token has expired"
870
+ error: "server_error",
871
+ error_description: "Failed to validate authorization"
872
872
  }), {
873
- status: 401,
874
- headers: {
875
- "Content-Type": "application/json",
876
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
877
- }
873
+ status: 500,
874
+ headers: { "Content-Type": "application/json" }
878
875
  });
879
876
  }
880
- logger.info({ pathname: url.pathname, sessionId: tokenData.sessionId }, "SSE request authenticated successfully");
881
- }
882
- catch (error) {
883
- logger.error({ error, pathname: url.pathname }, "Error validating Bearer token");
884
- return new Response(JSON.stringify({
885
- error: "server_error",
886
- error_description: "Failed to validate authorization"
887
- }), {
888
- status: 500,
889
- headers: { "Content-Type": "application/json" }
890
- });
877
+ // Token is valid, proceed with SSE connection
878
+ return FigmaConsoleMCPv3.serveSSE("/sse").fetch(request, env, ctx);
891
879
  }
892
- // Token is valid, proceed with SSE connection
893
- return FigmaConsoleMCPv3.serveSSE("/sse").fetch(request, env, ctx);
894
- }
895
- // Streamable HTTP endpoint for MCP communication (current spec)
896
- // Supports POST (client→server) and optional GET (server→client SSE)
897
- if (url.pathname === "/mcp") {
898
- // Validate Authorization header per MCP OAuth 2.1 spec
899
- const authHeader = request.headers.get("Authorization");
900
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
901
- logger.warn({ pathname: url.pathname }, "MCP request missing Authorization header - returning 401 with resource_metadata");
902
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
903
- return new Response(JSON.stringify({
904
- error: "unauthorized",
905
- error_description: "Authorization header with Bearer token is required"
906
- }), {
907
- status: 401,
908
- headers: {
909
- "Content-Type": "application/json",
910
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
911
- }
912
- });
913
- }
914
- const bearerToken = authHeader.substring(7);
915
- const bearerKey = `bearer_token:${bearerToken}`;
916
- try {
917
- const tokenDataJson = await env.OAUTH_TOKENS.get(bearerKey);
918
- if (!tokenDataJson) {
919
- logger.warn({ pathname: url.pathname }, "MCP request with invalid Bearer token");
880
+ // Streamable HTTP endpoint for MCP communication (current spec)
881
+ // Supports POST (client→server) and optional GET (server→client SSE)
882
+ if (url.pathname === "/mcp") {
883
+ // Validate Authorization header per MCP OAuth 2.1 spec
884
+ const authHeader = request.headers.get("Authorization");
885
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
886
+ logger.warn({ pathname: url.pathname }, "MCP request missing Authorization header - returning 401 with resource_metadata");
920
887
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
921
888
  return new Response(JSON.stringify({
922
- error: "invalid_token",
923
- error_description: "Bearer token is invalid or expired"
889
+ error: "unauthorized",
890
+ error_description: "Authorization header with Bearer token is required"
924
891
  }), {
925
892
  status: 401,
926
893
  headers: {
927
894
  "Content-Type": "application/json",
928
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
895
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
929
896
  }
930
897
  });
931
898
  }
932
- const tokenData = JSON.parse(tokenDataJson);
933
- if (tokenData.expiresAt < Date.now()) {
934
- logger.warn({ pathname: url.pathname, sessionId: tokenData.sessionId }, "MCP request with expired Bearer token");
935
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
899
+ const bearerToken = authHeader.substring(7);
900
+ const bearerKey = `bearer_token:${bearerToken}`;
901
+ try {
902
+ const tokenDataJson = await env.OAUTH_TOKENS.get(bearerKey);
903
+ if (!tokenDataJson) {
904
+ logger.warn({ pathname: url.pathname }, "MCP request with invalid Bearer token");
905
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
906
+ return new Response(JSON.stringify({
907
+ error: "invalid_token",
908
+ error_description: "Bearer token is invalid or expired"
909
+ }), {
910
+ status: 401,
911
+ headers: {
912
+ "Content-Type": "application/json",
913
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
914
+ }
915
+ });
916
+ }
917
+ const tokenData = JSON.parse(tokenDataJson);
918
+ if (tokenData.expiresAt < Date.now()) {
919
+ logger.warn({ pathname: url.pathname, sessionId: tokenData.sessionId }, "MCP request with expired Bearer token");
920
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
921
+ return new Response(JSON.stringify({
922
+ error: "invalid_token",
923
+ error_description: "Bearer token has expired"
924
+ }), {
925
+ status: 401,
926
+ headers: {
927
+ "Content-Type": "application/json",
928
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
929
+ }
930
+ });
931
+ }
932
+ logger.info({ pathname: url.pathname, sessionId: tokenData.sessionId }, "MCP request authenticated successfully");
933
+ }
934
+ catch (error) {
935
+ logger.error({ error, pathname: url.pathname }, "Error validating Bearer token for MCP endpoint");
936
936
  return new Response(JSON.stringify({
937
- error: "invalid_token",
938
- error_description: "Bearer token has expired"
937
+ error: "server_error",
938
+ error_description: "Failed to validate authorization"
939
939
  }), {
940
- status: 401,
941
- headers: {
942
- "Content-Type": "application/json",
943
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
944
- }
940
+ status: 500,
941
+ headers: { "Content-Type": "application/json" }
945
942
  });
946
943
  }
947
- logger.info({ pathname: url.pathname, sessionId: tokenData.sessionId }, "MCP request authenticated successfully");
948
- }
949
- catch (error) {
950
- logger.error({ error, pathname: url.pathname }, "Error validating Bearer token for MCP endpoint");
951
- return new Response(JSON.stringify({
952
- error: "server_error",
953
- error_description: "Failed to validate authorization"
954
- }), {
955
- status: 500,
956
- headers: { "Content-Type": "application/json" }
944
+ // Token is valid use stateless transport (no Durable Objects)
945
+ // The Bearer token IS the Figma access token, so we use it directly
946
+ const figmaAccessToken = bearerToken;
947
+ const statelessApi = new FigmaAPI({ accessToken: figmaAccessToken });
948
+ const transport = new WebStandardStreamableHTTPServerTransport({
949
+ sessionIdGenerator: undefined, // Stateless — no session persistence needed
957
950
  });
958
- }
959
- // Token is valid — use stateless transport (no Durable Objects)
960
- // The Bearer token IS the Figma access token, so we use it directly
961
- const figmaAccessToken = bearerToken;
962
- const statelessApi = new FigmaAPI({ accessToken: figmaAccessToken });
963
- const transport = new WebStandardStreamableHTTPServerTransport({
964
- sessionIdGenerator: undefined, // Stateless no session persistence needed
965
- });
966
- const statelessServer = new McpServer({
967
- name: "Figma Console MCP",
968
- version: "1.13.0",
969
- });
970
- // ================================================================
971
- // Cloud Write Relay — Pairing Tool (stateless /mcp path)
972
- // Uses KV keyed by bearer token instead of DO storage
973
- // ================================================================
974
- statelessServer.tool("figma_pair_plugin", "Pair the Figma Desktop Bridge plugin to this cloud session for write access. Returns a 6-character code the user enters in the plugin's Cloud Mode section.", {}, async () => {
951
+ const statelessServer = new McpServer({
952
+ name: "Figma Console MCP",
953
+ version: "1.15.0",
954
+ });
955
+ // ================================================================
956
+ // Cloud Write Relay — Pairing Tool (stateless /mcp path)
957
+ // Uses KV keyed by bearer token instead of DO storage
958
+ // ================================================================
959
+ statelessServer.tool("figma_pair_plugin", "Pair the Figma Desktop Bridge plugin to this cloud session for write access. Returns a 6-character code the user enters in the plugin's Cloud Mode section.", {}, async () => {
960
+ try {
961
+ const code = generatePairingCode();
962
+ const relayDoId = env.PLUGIN_RELAY.newUniqueId().toString();
963
+ // Store pairing code → relay DO ID in KV (5-min TTL, one-time use)
964
+ await env.OAUTH_TOKENS.put(`pairing:${code}`, relayDoId, {
965
+ expirationTtl: 300,
966
+ });
967
+ // Store relay DO ID keyed by bearer token for session persistence
968
+ await env.OAUTH_TOKENS.put(`relay:${bearerToken}`, relayDoId, {
969
+ expirationTtl: 86400, // 24h — matches typical session length
970
+ });
971
+ return {
972
+ content: [{
973
+ type: "text",
974
+ text: JSON.stringify({
975
+ pairingCode: code,
976
+ expiresIn: "5 minutes",
977
+ instructions: [
978
+ "1. Open the MCP Bridge plugin in Figma Desktop",
979
+ "2. Click the '▶ Cloud Mode' toggle in the plugin UI",
980
+ `3. Enter pairing code: ${code}`,
981
+ "4. Click 'Connect' — the plugin will connect to the cloud relay",
982
+ "5. Once paired, write tools (variables, components, nodes) work through the cloud"
983
+ ],
984
+ }, null, 2),
985
+ }],
986
+ };
987
+ }
988
+ catch (error) {
989
+ const errorMessage = error instanceof Error ? error.message : String(error);
990
+ return {
991
+ content: [{ type: "text", text: JSON.stringify({ error: errorMessage }) }],
992
+ isError: true,
993
+ };
994
+ }
995
+ });
996
+ // Cloud Desktop Connector factory (stateless /mcp path)
997
+ const getCloudDesktopConnector = async () => {
998
+ const relayDoId = await env.OAUTH_TOKENS.get(`relay:${bearerToken}`);
999
+ if (!relayDoId) {
1000
+ throw new Error('No cloud relay session. Call figma_pair_plugin first to pair the Desktop Bridge plugin.');
1001
+ }
1002
+ const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
1003
+ const stub = env.PLUGIN_RELAY.get(doId);
1004
+ const connector = new CloudWebSocketConnector(stub);
1005
+ await connector.initialize();
1006
+ return connector;
1007
+ };
1008
+ // Build a getCurrentUrl that resolves from the relay DO's file info
1009
+ const getCloudFileUrl = () => {
1010
+ // This is synchronous — we cache the file URL after first relay status check
1011
+ return cloudFileUrlCache;
1012
+ };
1013
+ let cloudFileUrlCache = null;
1014
+ // Pre-fetch file info from relay if paired
975
1015
  try {
976
- const code = generatePairingCode();
977
- const relayDoId = env.PLUGIN_RELAY.newUniqueId().toString();
978
- // Store pairing code → relay DO ID in KV (5-min TTL, one-time use)
979
- await env.OAUTH_TOKENS.put(`pairing:${code}`, relayDoId, {
980
- expirationTtl: 300,
981
- });
982
- // Store relay DO ID keyed by bearer token for session persistence
983
- await env.OAUTH_TOKENS.put(`relay:${bearerToken}`, relayDoId, {
984
- expirationTtl: 86400, // 24h — matches typical session length
985
- });
986
- return {
987
- content: [{
988
- type: "text",
989
- text: JSON.stringify({
990
- pairingCode: code,
991
- expiresIn: "5 minutes",
992
- instructions: [
993
- "1. Open the MCP Bridge plugin in Figma Desktop",
994
- "2. Click the '▶ Cloud Mode' toggle in the plugin UI",
995
- `3. Enter pairing code: ${code}`,
996
- "4. Click 'Connect' — the plugin will connect to the cloud relay",
997
- "5. Once paired, write tools (variables, components, nodes) work through the cloud"
998
- ],
999
- }, null, 2),
1000
- }],
1001
- };
1016
+ const relayDoId = await env.OAUTH_TOKENS.get(`relay:${bearerToken}`);
1017
+ if (relayDoId) {
1018
+ const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
1019
+ const stub = env.PLUGIN_RELAY.get(doId);
1020
+ const statusRes = await stub.fetch('https://relay/relay/status');
1021
+ const status = await statusRes.json();
1022
+ if (status.connected && status.fileInfo?.fileKey) {
1023
+ cloudFileUrlCache = `https://www.figma.com/design/${status.fileInfo.fileKey}`;
1024
+ }
1025
+ }
1002
1026
  }
1003
- catch (error) {
1004
- const errorMessage = error instanceof Error ? error.message : String(error);
1005
- return {
1006
- content: [{ type: "text", text: JSON.stringify({ error: errorMessage }) }],
1007
- isError: true,
1008
- };
1027
+ catch {
1028
+ // No relay session or not paired cloudFileUrlCache stays null
1009
1029
  }
1010
- });
1011
- // Cloud Desktop Connector factory (stateless /mcp path)
1012
- const getCloudDesktopConnector = async () => {
1013
- const relayDoId = await env.OAUTH_TOKENS.get(`relay:${bearerToken}`);
1014
- if (!relayDoId) {
1015
- throw new Error('No cloud relay session. Call figma_pair_plugin first to pair the Desktop Bridge plugin.');
1030
+ // Register all write/manipulation tools via shared function
1031
+ registerWriteTools(statelessServer, getCloudDesktopConnector);
1032
+ // Register REST API tools with the authenticated Figma API
1033
+ registerFigmaAPITools(statelessServer, async () => statelessApi, getCloudFileUrl, () => null, // No console monitor
1034
+ () => null, // No browser manager
1035
+ undefined, // No ensureInitialized
1036
+ new Map(), // Fresh variables cache per request
1037
+ { isRemoteMode: true }, getCloudDesktopConnector);
1038
+ registerDesignCodeTools(statelessServer, async () => statelessApi, getCloudFileUrl, new Map(), // Fresh variables cache per request
1039
+ { isRemoteMode: true }, getCloudDesktopConnector);
1040
+ registerCommentTools(statelessServer, async () => statelessApi, getCloudFileUrl);
1041
+ registerDesignSystemTools(statelessServer, async () => statelessApi, getCloudFileUrl, new Map(), // Fresh variables cache per request
1042
+ { isRemoteMode: true });
1043
+ await statelessServer.connect(transport);
1044
+ const response = await transport.handleRequest(request);
1045
+ if (response) {
1046
+ return response;
1016
1047
  }
1017
- const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
1018
- const stub = env.PLUGIN_RELAY.get(doId);
1019
- const connector = new CloudWebSocketConnector(stub);
1020
- await connector.initialize();
1021
- return connector;
1022
- };
1023
- // Register all write/manipulation tools via shared function
1024
- registerWriteTools(statelessServer, getCloudDesktopConnector);
1025
- // Register REST API tools with the authenticated Figma API
1026
- registerFigmaAPITools(statelessServer, async () => statelessApi, () => null, // No browser URL in stateless mode
1027
- () => null, // No console monitor
1028
- () => null, // No browser manager
1029
- undefined, // No ensureInitialized
1030
- new Map(), // Fresh variables cache per request
1031
- { isRemoteMode: true }, getCloudDesktopConnector);
1032
- registerDesignCodeTools(statelessServer, async () => statelessApi, () => null, new Map(), // Fresh variables cache per request
1033
- { isRemoteMode: true }, getCloudDesktopConnector);
1034
- registerCommentTools(statelessServer, async () => statelessApi, () => null);
1035
- registerDesignSystemTools(statelessServer, async () => statelessApi, () => null, new Map(), // Fresh variables cache per request
1036
- { isRemoteMode: true });
1037
- await statelessServer.connect(transport);
1038
- const response = await transport.handleRequest(request);
1039
- if (response) {
1040
- return response;
1048
+ return new Response("No response from MCP transport", { status: 500 });
1041
1049
  }
1042
- return new Response("No response from MCP transport", { status: 500 });
1043
- }
1044
- // ============================================================
1045
- // MCP OAuth 2.1 Spec-Compliant Endpoints
1046
- // These endpoints follow the MCP Authorization specification
1047
- // for compatibility with mcp-remote and Claude Code
1048
- // ============================================================
1049
- // Protected Resource Metadata (RFC9728)
1050
- // Required by MCP spec for OAuth discovery - tells clients where to find authorization server
1051
- if (url.pathname === "/.well-known/oauth-protected-resource" ||
1052
- url.pathname.startsWith("/.well-known/oauth-protected-resource/")) {
1053
- const metadata = {
1054
- resource: url.origin,
1055
- authorization_servers: [`${url.origin}/`],
1056
- scopes_supported: ["file_content:read", "library_content:read", "file_variables:read"],
1057
- bearer_methods_supported: ["header"],
1058
- resource_signing_alg_values_supported: ["RS256"]
1059
- };
1060
- return new Response(JSON.stringify(metadata, null, 2), {
1061
- headers: {
1062
- "Content-Type": "application/json",
1063
- "Cache-Control": "public, max-age=3600"
1064
- }
1065
- });
1066
- }
1067
- // OAuth 2.0 Authorization Server Metadata (RFC8414)
1068
- // Required by MCP spec for client discovery
1069
- if (url.pathname === "/.well-known/oauth-authorization-server") {
1070
- const metadata = {
1071
- issuer: url.origin,
1072
- authorization_endpoint: `${url.origin}/authorize`,
1073
- token_endpoint: `${url.origin}/token`,
1074
- registration_endpoint: `${url.origin}/oauth/register`,
1075
- scopes_supported: ["file_content:read", "library_content:read", "file_variables:read"],
1076
- response_types_supported: ["code"],
1077
- grant_types_supported: ["authorization_code", "refresh_token"],
1078
- token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
1079
- code_challenge_methods_supported: ["S256"],
1080
- service_documentation: "https://docs.figma-console-mcp.southleft.com",
1081
- };
1082
- return new Response(JSON.stringify(metadata, null, 2), {
1083
- headers: {
1084
- "Content-Type": "application/json",
1085
- "Cache-Control": "public, max-age=3600"
1086
- }
1087
- });
1088
- }
1089
- // MCP-compliant /authorize endpoint
1090
- // Handles authorization requests from MCP clients
1091
- if (url.pathname === "/authorize") {
1092
- const clientId = url.searchParams.get("client_id");
1093
- const redirectUri = url.searchParams.get("redirect_uri");
1094
- const state = url.searchParams.get("state");
1095
- const codeChallenge = url.searchParams.get("code_challenge");
1096
- const codeChallengeMethod = url.searchParams.get("code_challenge_method");
1097
- const scope = url.searchParams.get("scope");
1098
- // For MCP clients, use the client_id as the session identifier
1099
- // This allows token retrieval after the OAuth flow completes
1100
- const sessionId = clientId || FigmaConsoleMCPv3.generateStateToken();
1101
- // Store the MCP client's redirect_uri and state for the callback
1102
- if (redirectUri && state) {
1103
- // SEC-004: Validate redirect_uri against the registered client's allowed list
1104
- if (clientId) {
1105
- const clientJson = await env.OAUTH_STATE.get(`client:${clientId}`);
1106
- if (clientJson) {
1107
- const clientData = JSON.parse(clientJson);
1108
- if (clientData.redirect_uris.length > 0 && !clientData.redirect_uris.includes(redirectUri)) {
1109
- return new Response(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri does not match registered redirect URIs" }), {
1110
- status: 400, headers: { "Content-Type": "application/json" }
1111
- });
1112
- }
1113
- }
1114
- }
1115
- const mcpAuthData = {
1116
- redirectUri,
1117
- state,
1118
- codeChallenge,
1119
- codeChallengeMethod,
1120
- scope,
1121
- clientId,
1122
- sessionId
1050
+ // ============================================================
1051
+ // MCP OAuth 2.1 Spec-Compliant Endpoints
1052
+ // These endpoints follow the MCP Authorization specification
1053
+ // for compatibility with mcp-remote and Claude Code
1054
+ // ============================================================
1055
+ // Protected Resource Metadata (RFC9728)
1056
+ // Required by MCP spec for OAuth discovery - tells clients where to find authorization server
1057
+ if (url.pathname === "/.well-known/oauth-protected-resource" ||
1058
+ url.pathname.startsWith("/.well-known/oauth-protected-resource/")) {
1059
+ const metadata = {
1060
+ resource: url.origin,
1061
+ authorization_servers: [`${url.origin}/`],
1062
+ scopes_supported: ["file_content:read", "file_variables:read", "library_content:read"],
1063
+ bearer_methods_supported: ["header"],
1064
+ resource_signing_alg_values_supported: ["RS256"]
1123
1065
  };
1124
- // Store with 10 minute expiration
1125
- const mcpStateKey = `mcp_auth:${sessionId}`;
1126
- await env.OAUTH_STATE.put(mcpStateKey, JSON.stringify(mcpAuthData), {
1127
- expirationTtl: 600
1066
+ return new Response(JSON.stringify(metadata, null, 2), {
1067
+ headers: {
1068
+ "Content-Type": "application/json",
1069
+ "Cache-Control": "public, max-age=3600"
1070
+ }
1128
1071
  });
1129
1072
  }
1130
- // Check if OAuth credentials are configured
1131
- if (!env.FIGMA_OAUTH_CLIENT_ID) {
1132
- return new Response(JSON.stringify({
1133
- error: "server_error",
1134
- error_description: "OAuth not configured on server"
1135
- }), {
1136
- status: 500,
1137
- headers: { "Content-Type": "application/json" }
1073
+ // OAuth 2.0 Authorization Server Metadata (RFC8414)
1074
+ // Required by MCP spec for client discovery
1075
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
1076
+ const metadata = {
1077
+ issuer: url.origin,
1078
+ authorization_endpoint: `${url.origin}/authorize`,
1079
+ token_endpoint: `${url.origin}/token`,
1080
+ registration_endpoint: `${url.origin}/oauth/register`,
1081
+ scopes_supported: ["file_content:read", "file_variables:read", "library_content:read"],
1082
+ response_types_supported: ["code"],
1083
+ grant_types_supported: ["authorization_code", "refresh_token"],
1084
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
1085
+ code_challenge_methods_supported: ["S256"],
1086
+ service_documentation: "https://docs.figma-console-mcp.southleft.com",
1087
+ };
1088
+ return new Response(JSON.stringify(metadata, null, 2), {
1089
+ headers: {
1090
+ "Content-Type": "application/json",
1091
+ "Cache-Control": "public, max-age=3600"
1092
+ }
1138
1093
  });
1139
1094
  }
1140
- // Generate CSRF protection token
1141
- const stateToken = FigmaConsoleMCPv3.generateStateToken();
1142
- // Store state token with sessionId (10 minute expiration)
1143
- await env.OAUTH_STATE.put(stateToken, sessionId, {
1144
- expirationTtl: 600
1145
- });
1146
- // Redirect to Figma OAuth
1147
- const figmaAuthUrl = new URL("https://www.figma.com/oauth");
1148
- figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
1149
- figmaAuthUrl.searchParams.set("redirect_uri", `${oauthOrigin}/oauth/callback`);
1150
- figmaAuthUrl.searchParams.set("scope", "file_content:read,library_content:read,file_variables:read");
1151
- figmaAuthUrl.searchParams.set("state", stateToken);
1152
- figmaAuthUrl.searchParams.set("response_type", "code");
1153
- return Response.redirect(figmaAuthUrl.toString(), 302);
1154
- }
1155
- // MCP-compliant /token endpoint
1156
- // Handles token exchange and refresh requests
1157
- if (url.pathname === "/token" && request.method === "POST") {
1158
- const contentType = request.headers.get("content-type") || "";
1159
- let params;
1160
- if (contentType.includes("application/x-www-form-urlencoded")) {
1161
- params = new URLSearchParams(await request.text());
1162
- }
1163
- else if (contentType.includes("application/json")) {
1164
- const body = await request.json();
1165
- params = new URLSearchParams(body);
1166
- }
1167
- else {
1168
- params = new URLSearchParams(await request.text());
1169
- }
1170
- const grantType = params.get("grant_type");
1171
- const clientId = params.get("client_id");
1172
- const code = params.get("code");
1173
- const codeVerifier = params.get("code_verifier");
1174
- const refreshToken = params.get("refresh_token");
1175
- // For authorization_code grant, exchange the code for tokens
1176
- if (grantType === "authorization_code" && code) {
1177
- // The code here is actually our session-based token
1178
- // Look up the stored token by session/client ID
1179
- const sessionId = clientId || code;
1180
- const tokenKey = `oauth_token:${sessionId}`;
1181
- logger.info({ grantType, clientId, hasCode: !!code, sessionId }, "Token exchange request");
1182
- // PKCE verification (SEC-003): if a code_challenge was stored, verify code_verifier now
1183
- const mcpStateKey = `mcp_auth:${sessionId}`;
1184
- const mcpAuthJson = await env.OAUTH_STATE.get(mcpStateKey);
1185
- if (mcpAuthJson) {
1186
- const mcpAuthData = JSON.parse(mcpAuthJson);
1187
- if (mcpAuthData.codeChallenge) {
1188
- if (!codeVerifier) {
1189
- return new Response(JSON.stringify({ error: "invalid_request", error_description: "code_verifier required" }), {
1190
- status: 400, headers: { "Content-Type": "application/json" }
1191
- });
1192
- }
1193
- // Verify S256: BASE64URL(SHA-256(code_verifier)) === code_challenge
1194
- const encoder = new TextEncoder();
1195
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(codeVerifier));
1196
- const computed = btoa(String.fromCharCode(...new Uint8Array(digest)))
1197
- .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1198
- if (computed !== mcpAuthData.codeChallenge) {
1199
- return new Response(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }), {
1200
- status: 400, headers: { "Content-Type": "application/json" }
1201
- });
1202
- }
1203
- }
1204
- }
1205
- const tokenJson = await env.OAUTH_TOKENS.get(tokenKey);
1206
- logger.info({ tokenKey, hasToken: !!tokenJson }, "Token lookup result");
1207
- if (tokenJson) {
1208
- const tokenData = JSON.parse(tokenJson);
1209
- // Return tokens in OAuth 2.0 format
1210
- return new Response(JSON.stringify({
1211
- access_token: tokenData.accessToken,
1212
- token_type: "Bearer",
1213
- expires_in: Math.max(0, Math.floor((tokenData.expiresAt - Date.now()) / 1000)),
1214
- refresh_token: tokenData.refreshToken,
1215
- scope: "file_content:read library_content:read file_variables:read"
1216
- }), {
1217
- headers: {
1218
- "Content-Type": "application/json",
1219
- "Cache-Control": "no-store"
1220
- }
1095
+ // MCP-compliant /authorize endpoint
1096
+ // Handles authorization requests from MCP clients
1097
+ if (url.pathname === "/authorize") {
1098
+ const clientId = url.searchParams.get("client_id");
1099
+ const redirectUri = url.searchParams.get("redirect_uri");
1100
+ const state = url.searchParams.get("state");
1101
+ const codeChallenge = url.searchParams.get("code_challenge");
1102
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
1103
+ const scope = url.searchParams.get("scope");
1104
+ // For MCP clients, use the client_id as the session identifier
1105
+ // This allows token retrieval after the OAuth flow completes
1106
+ const sessionId = clientId || FigmaConsoleMCPv3.generateStateToken();
1107
+ // Store the MCP client's redirect_uri and state for the callback
1108
+ if (redirectUri && state) {
1109
+ const mcpAuthData = {
1110
+ redirectUri,
1111
+ state,
1112
+ codeChallenge,
1113
+ codeChallengeMethod,
1114
+ scope,
1115
+ clientId,
1116
+ sessionId
1117
+ };
1118
+ // Store with 10 minute expiration
1119
+ const mcpStateKey = `mcp_auth:${sessionId}`;
1120
+ await env.OAUTH_STATE.put(mcpStateKey, JSON.stringify(mcpAuthData), {
1121
+ expirationTtl: 600
1221
1122
  });
1222
1123
  }
1223
- logger.error({ sessionId, clientId, hasCode: !!code }, "Token not found for exchange");
1224
- return new Response(JSON.stringify({
1225
- error: "invalid_grant",
1226
- error_description: "Authorization code not found or expired. Please re-authenticate."
1227
- }), {
1228
- status: 400,
1229
- headers: { "Content-Type": "application/json" }
1230
- });
1231
- }
1232
- // For refresh_token grant
1233
- if (grantType === "refresh_token" && refreshToken) {
1234
- if (!env.FIGMA_OAUTH_CLIENT_ID || !env.FIGMA_OAUTH_CLIENT_SECRET) {
1124
+ // Check if OAuth credentials are configured
1125
+ if (!env.FIGMA_OAUTH_CLIENT_ID) {
1235
1126
  return new Response(JSON.stringify({
1236
1127
  error: "server_error",
1237
- error_description: "OAuth not configured"
1128
+ error_description: "OAuth not configured on server"
1238
1129
  }), {
1239
1130
  status: 500,
1240
1131
  headers: { "Content-Type": "application/json" }
1241
1132
  });
1242
1133
  }
1243
- const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
1244
- const tokenParams = new URLSearchParams({
1245
- grant_type: "refresh_token",
1246
- refresh_token: refreshToken
1247
- });
1248
- const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
1249
- method: "POST",
1250
- headers: {
1251
- "Content-Type": "application/x-www-form-urlencoded",
1252
- "Authorization": `Basic ${credentials}`
1253
- },
1254
- body: tokenParams.toString()
1134
+ // Generate CSRF protection token
1135
+ const stateToken = FigmaConsoleMCPv3.generateStateToken();
1136
+ // Store state token with sessionId (10 minute expiration)
1137
+ await env.OAUTH_STATE.put(stateToken, sessionId, {
1138
+ expirationTtl: 600
1255
1139
  });
1256
- if (!tokenResponse.ok) {
1140
+ // Redirect to Figma OAuth
1141
+ const figmaAuthUrl = new URL("https://www.figma.com/oauth");
1142
+ figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
1143
+ figmaAuthUrl.searchParams.set("redirect_uri", `${oauthOrigin}/oauth/callback`);
1144
+ figmaAuthUrl.searchParams.set("scope", "file_content:read,file_variables:read,library_content:read");
1145
+ figmaAuthUrl.searchParams.set("state", stateToken);
1146
+ figmaAuthUrl.searchParams.set("response_type", "code");
1147
+ return Response.redirect(figmaAuthUrl.toString(), 302);
1148
+ }
1149
+ // MCP-compliant /token endpoint
1150
+ // Handles token exchange and refresh requests
1151
+ if (url.pathname === "/token" && request.method === "POST") {
1152
+ const contentType = request.headers.get("content-type") || "";
1153
+ let params;
1154
+ if (contentType.includes("application/x-www-form-urlencoded")) {
1155
+ params = new URLSearchParams(await request.text());
1156
+ }
1157
+ else if (contentType.includes("application/json")) {
1158
+ const body = await request.json();
1159
+ params = new URLSearchParams(body);
1160
+ }
1161
+ else {
1162
+ params = new URLSearchParams(await request.text());
1163
+ }
1164
+ const grantType = params.get("grant_type");
1165
+ const clientId = params.get("client_id");
1166
+ const code = params.get("code");
1167
+ const refreshToken = params.get("refresh_token");
1168
+ // For authorization_code grant, exchange the code for tokens
1169
+ if (grantType === "authorization_code" && code) {
1170
+ // The code here is actually our session-based token
1171
+ // Look up the stored token by session/client ID
1172
+ const sessionId = clientId || code;
1173
+ const tokenKey = `oauth_token:${sessionId}`;
1174
+ logger.info({ grantType, clientId, code, sessionId, tokenKey }, "Token exchange request");
1175
+ const tokenJson = await env.OAUTH_TOKENS.get(tokenKey);
1176
+ logger.info({ tokenKey, hasToken: !!tokenJson }, "Token lookup result");
1177
+ if (tokenJson) {
1178
+ const tokenData = JSON.parse(tokenJson);
1179
+ // Return tokens in OAuth 2.0 format
1180
+ return new Response(JSON.stringify({
1181
+ access_token: tokenData.accessToken,
1182
+ token_type: "Bearer",
1183
+ expires_in: Math.max(0, Math.floor((tokenData.expiresAt - Date.now()) / 1000)),
1184
+ refresh_token: tokenData.refreshToken,
1185
+ scope: "file_content:read file_variables:read library_content:read"
1186
+ }), {
1187
+ headers: {
1188
+ "Content-Type": "application/json",
1189
+ "Cache-Control": "no-store"
1190
+ }
1191
+ });
1192
+ }
1193
+ logger.error({ tokenKey, sessionId, clientId, code }, "Token not found for exchange");
1257
1194
  return new Response(JSON.stringify({
1258
1195
  error: "invalid_grant",
1259
- error_description: "Failed to refresh token"
1196
+ error_description: "Authorization code not found or expired. Please re-authenticate."
1260
1197
  }), {
1261
1198
  status: 400,
1262
1199
  headers: { "Content-Type": "application/json" }
1263
1200
  });
1264
1201
  }
1265
- const tokenData = await tokenResponse.json();
1266
- // Store the refreshed token
1267
- if (clientId) {
1268
- const tokenKey = `oauth_token:${clientId}`;
1269
- const expiresAt = Date.now() + (tokenData.expires_in * 1000);
1270
- const storedToken = {
1271
- accessToken: tokenData.access_token,
1272
- refreshToken: tokenData.refresh_token || refreshToken,
1273
- expiresAt
1274
- };
1275
- await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
1276
- expirationTtl: tokenData.expires_in
1202
+ // For refresh_token grant
1203
+ if (grantType === "refresh_token" && refreshToken) {
1204
+ if (!env.FIGMA_OAUTH_CLIENT_ID || !env.FIGMA_OAUTH_CLIENT_SECRET) {
1205
+ return new Response(JSON.stringify({
1206
+ error: "server_error",
1207
+ error_description: "OAuth not configured"
1208
+ }), {
1209
+ status: 500,
1210
+ headers: { "Content-Type": "application/json" }
1211
+ });
1212
+ }
1213
+ const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
1214
+ const tokenParams = new URLSearchParams({
1215
+ grant_type: "refresh_token",
1216
+ refresh_token: refreshToken
1277
1217
  });
1278
- // Store reverse lookup for Bearer token validation on SSE endpoint
1279
- const bearerKey = `bearer_token:${tokenData.access_token}`;
1280
- await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
1281
- sessionId: clientId,
1282
- expiresAt
1283
- }), {
1284
- expirationTtl: tokenData.expires_in
1218
+ const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
1219
+ method: "POST",
1220
+ headers: {
1221
+ "Content-Type": "application/x-www-form-urlencoded",
1222
+ "Authorization": `Basic ${credentials}`
1223
+ },
1224
+ body: tokenParams.toString()
1285
1225
  });
1286
- }
1287
- return new Response(JSON.stringify({
1288
- access_token: tokenData.access_token,
1289
- token_type: "Bearer",
1290
- expires_in: tokenData.expires_in,
1291
- refresh_token: tokenData.refresh_token || refreshToken,
1292
- scope: "file_content:read library_content:read file_variables:read"
1293
- }), {
1294
- headers: {
1295
- "Content-Type": "application/json",
1296
- "Cache-Control": "no-store"
1297
- }
1298
- });
1299
- }
1300
- return new Response(JSON.stringify({
1301
- error: "unsupported_grant_type",
1302
- error_description: "Only authorization_code and refresh_token grants are supported"
1303
- }), {
1304
- status: 400,
1305
- headers: { "Content-Type": "application/json" }
1306
- });
1307
- }
1308
- // Dynamic Client Registration (RFC7591)
1309
- // Required by MCP spec for clients to register
1310
- if (url.pathname === "/oauth/register" && request.method === "POST") {
1311
- const body = await request.json();
1312
- // Validate redirect_uris (SEC-011): reject non-HTTPS URIs and fragments
1313
- const rawUris = body.redirect_uris || [];
1314
- const validatedUris = [];
1315
- for (const uri of rawUris) {
1316
- try {
1317
- const parsed = new URL(uri);
1318
- if (parsed.hash) {
1319
- return new Response(JSON.stringify({ error: "invalid_redirect_uri", error_description: "redirect_uri must not contain a fragment" }), {
1320
- status: 400, headers: { "Content-Type": "application/json" }
1226
+ if (!tokenResponse.ok) {
1227
+ return new Response(JSON.stringify({
1228
+ error: "invalid_grant",
1229
+ error_description: "Failed to refresh token"
1230
+ }), {
1231
+ status: 400,
1232
+ headers: { "Content-Type": "application/json" }
1321
1233
  });
1322
1234
  }
1323
- if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && parsed.hostname === "localhost")) {
1324
- return new Response(JSON.stringify({ error: "invalid_redirect_uri", error_description: "redirect_uri must use https (or http://localhost)" }), {
1325
- status: 400, headers: { "Content-Type": "application/json" }
1235
+ const tokenData = await tokenResponse.json();
1236
+ // Store the refreshed token
1237
+ if (clientId) {
1238
+ const tokenKey = `oauth_token:${clientId}`;
1239
+ const expiresAt = Date.now() + (tokenData.expires_in * 1000);
1240
+ const storedToken = {
1241
+ accessToken: tokenData.access_token,
1242
+ refreshToken: tokenData.refresh_token || refreshToken,
1243
+ expiresAt
1244
+ };
1245
+ await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
1246
+ expirationTtl: tokenData.expires_in
1247
+ });
1248
+ // Store reverse lookup for Bearer token validation on SSE endpoint
1249
+ const bearerKey = `bearer_token:${tokenData.access_token}`;
1250
+ await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
1251
+ sessionId: clientId,
1252
+ expiresAt
1253
+ }), {
1254
+ expirationTtl: tokenData.expires_in
1326
1255
  });
1327
1256
  }
1328
- validatedUris.push(uri);
1329
- }
1330
- catch {
1331
- return new Response(JSON.stringify({ error: "invalid_redirect_uri", error_description: "redirect_uri is not a valid URL" }), {
1332
- status: 400, headers: { "Content-Type": "application/json" }
1257
+ return new Response(JSON.stringify({
1258
+ access_token: tokenData.access_token,
1259
+ token_type: "Bearer",
1260
+ expires_in: tokenData.expires_in,
1261
+ refresh_token: tokenData.refresh_token || refreshToken,
1262
+ scope: "file_content:read file_variables:read library_content:read"
1263
+ }), {
1264
+ headers: {
1265
+ "Content-Type": "application/json",
1266
+ "Cache-Control": "no-store"
1267
+ }
1333
1268
  });
1334
1269
  }
1270
+ return new Response(JSON.stringify({
1271
+ error: "unsupported_grant_type",
1272
+ error_description: "Only authorization_code and refresh_token grants are supported"
1273
+ }), {
1274
+ status: 400,
1275
+ headers: { "Content-Type": "application/json" }
1276
+ });
1335
1277
  }
1336
- // Generate a client ID for this registration
1337
- const clientId = `mcp_${FigmaConsoleMCPv3.generateStateToken().substring(0, 16)}`;
1338
- // Store client registration (30 day expiration)
1339
- await env.OAUTH_STATE.put(`client:${clientId}`, JSON.stringify({
1340
- client_name: body.client_name || "MCP Client",
1341
- redirect_uris: validatedUris,
1342
- created_at: Date.now()
1343
- }), {
1344
- expirationTtl: 30 * 24 * 60 * 60
1345
- });
1346
- return new Response(JSON.stringify({
1347
- client_id: clientId,
1348
- client_name: body.client_name || "MCP Client",
1349
- redirect_uris: body.redirect_uris || [],
1350
- token_endpoint_auth_method: "none",
1351
- grant_types: ["authorization_code", "refresh_token"],
1352
- response_types: ["code"]
1353
- }), {
1354
- status: 201,
1355
- headers: { "Content-Type": "application/json" }
1356
- });
1357
- }
1358
- // ============================================================
1359
- // Original Figma OAuth Endpoints (kept for backwards compatibility)
1360
- // ============================================================
1361
- // OAuth authorization initiation
1362
- if (url.pathname === "/oauth/authorize") {
1363
- const sessionId = url.searchParams.get("session_id");
1364
- if (!sessionId) {
1365
- return new Response("Missing session_id parameter", { status: 400 });
1366
- }
1367
- // Check if OAuth credentials are configured
1368
- if (!env.FIGMA_OAUTH_CLIENT_ID) {
1278
+ // Dynamic Client Registration (RFC7591)
1279
+ // Required by MCP spec for clients to register
1280
+ if (url.pathname === "/oauth/register" && request.method === "POST") {
1281
+ const body = await request.json();
1282
+ // Generate a client ID for this registration
1283
+ const clientId = `mcp_${FigmaConsoleMCPv3.generateStateToken().substring(0, 16)}`;
1284
+ // Store client registration (30 day expiration)
1285
+ await env.OAUTH_STATE.put(`client:${clientId}`, JSON.stringify({
1286
+ client_name: body.client_name || "MCP Client",
1287
+ redirect_uris: body.redirect_uris || [],
1288
+ created_at: Date.now()
1289
+ }), {
1290
+ expirationTtl: 30 * 24 * 60 * 60
1291
+ });
1369
1292
  return new Response(JSON.stringify({
1370
- error: "OAuth not configured",
1371
- message: "Server administrator needs to configure FIGMA_OAUTH_CLIENT_ID",
1372
- docs: "https://github.com/southleft/figma-console-mcp#oauth-setup"
1293
+ client_id: clientId,
1294
+ client_name: body.client_name || "MCP Client",
1295
+ redirect_uris: body.redirect_uris || [],
1296
+ token_endpoint_auth_method: "none",
1297
+ grant_types: ["authorization_code", "refresh_token"],
1298
+ response_types: ["code"]
1373
1299
  }), {
1374
- status: 500,
1300
+ status: 201,
1375
1301
  headers: { "Content-Type": "application/json" }
1376
1302
  });
1377
1303
  }
1378
- // Generate cryptographically secure state token for CSRF protection
1379
- const stateToken = FigmaConsoleMCPv3.generateStateToken();
1380
- // Store state token with sessionId in KV (10 minute expiration)
1381
- await env.OAUTH_STATE.put(stateToken, sessionId, {
1382
- expirationTtl: 600 // 10 minutes
1383
- });
1384
- const redirectUri = `${oauthOrigin}/oauth/callback`;
1385
- const figmaAuthUrl = new URL("https://www.figma.com/oauth");
1386
- figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
1387
- figmaAuthUrl.searchParams.set("redirect_uri", redirectUri);
1388
- figmaAuthUrl.searchParams.set("scope", "file_content:read,library_content:read,file_variables:read");
1389
- figmaAuthUrl.searchParams.set("state", stateToken);
1390
- figmaAuthUrl.searchParams.set("response_type", "code");
1391
- return Response.redirect(figmaAuthUrl.toString(), 302);
1392
- }
1393
- // OAuth callback handler
1394
- if (url.pathname === "/oauth/callback") {
1395
- const code = url.searchParams.get("code");
1396
- const stateToken = url.searchParams.get("state");
1397
- const error = url.searchParams.get("error");
1398
- // Handle OAuth errors
1399
- if (error) {
1400
- const safeError = htmlEscape(error);
1401
- const safeDesc = htmlEscape(url.searchParams.get("error_description") || "Unknown error");
1402
- return new Response(`<html><head><meta charset="utf-8"></head><body>
1304
+ // ============================================================
1305
+ // Original Figma OAuth Endpoints (kept for backwards compatibility)
1306
+ // ============================================================
1307
+ // OAuth authorization initiation
1308
+ if (url.pathname === "/oauth/authorize") {
1309
+ const sessionId = url.searchParams.get("session_id");
1310
+ if (!sessionId) {
1311
+ return new Response("Missing session_id parameter", { status: 400 });
1312
+ }
1313
+ // Check if OAuth credentials are configured
1314
+ if (!env.FIGMA_OAUTH_CLIENT_ID) {
1315
+ return new Response(JSON.stringify({
1316
+ error: "OAuth not configured",
1317
+ message: "Server administrator needs to configure FIGMA_OAUTH_CLIENT_ID",
1318
+ docs: "https://github.com/southleft/figma-console-mcp#oauth-setup"
1319
+ }), {
1320
+ status: 500,
1321
+ headers: { "Content-Type": "application/json" }
1322
+ });
1323
+ }
1324
+ // Generate cryptographically secure state token for CSRF protection
1325
+ const stateToken = FigmaConsoleMCPv3.generateStateToken();
1326
+ // Store state token with sessionId in KV (10 minute expiration)
1327
+ await env.OAUTH_STATE.put(stateToken, sessionId, {
1328
+ expirationTtl: 600 // 10 minutes
1329
+ });
1330
+ const redirectUri = `${oauthOrigin}/oauth/callback`;
1331
+ const figmaAuthUrl = new URL("https://www.figma.com/oauth");
1332
+ figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
1333
+ figmaAuthUrl.searchParams.set("redirect_uri", redirectUri);
1334
+ figmaAuthUrl.searchParams.set("scope", "file_content:read,file_variables:read,library_content:read");
1335
+ figmaAuthUrl.searchParams.set("state", stateToken);
1336
+ figmaAuthUrl.searchParams.set("response_type", "code");
1337
+ return Response.redirect(figmaAuthUrl.toString(), 302);
1338
+ }
1339
+ // OAuth callback handler
1340
+ if (url.pathname === "/oauth/callback") {
1341
+ const code = url.searchParams.get("code");
1342
+ const stateToken = url.searchParams.get("state");
1343
+ const error = url.searchParams.get("error");
1344
+ // Handle OAuth errors
1345
+ if (error) {
1346
+ return new Response(`<html><body>
1403
1347
  <h1>Authentication Failed</h1>
1404
- <p>Error: ${safeError}</p>
1405
- <p>Description: ${safeDesc}</p>
1348
+ <p>Error: ${error}</p>
1349
+ <p>Description: ${url.searchParams.get("error_description") || "Unknown error"}</p>
1406
1350
  <p>You can close this window and try again.</p>
1407
1351
  </body></html>`, {
1408
- status: 400,
1409
- headers: { "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": "default-src 'none'" }
1410
- });
1411
- }
1412
- if (!code || !stateToken) {
1413
- return new Response("Missing code or state parameter", { status: 400 });
1414
- }
1415
- // Validate state token (CSRF protection)
1416
- const sessionId = await env.OAUTH_STATE.get(stateToken);
1417
- logger.info({ hasStateToken: !!stateToken, hasSessionId: !!sessionId }, "OAuth callback - state token lookup");
1418
- if (!sessionId) {
1419
- return new Response(`<html><body>
1352
+ status: 400,
1353
+ headers: { "Content-Type": "text/html" }
1354
+ });
1355
+ }
1356
+ if (!code || !stateToken) {
1357
+ return new Response("Missing code or state parameter", { status: 400 });
1358
+ }
1359
+ // Validate state token (CSRF protection)
1360
+ const sessionId = await env.OAUTH_STATE.get(stateToken);
1361
+ logger.info({ stateToken, sessionId, hasSessionId: !!sessionId }, "OAuth callback - state token lookup");
1362
+ if (!sessionId) {
1363
+ return new Response(`<html><body>
1420
1364
  <h1>Invalid or Expired Request</h1>
1421
1365
  <p>The authentication request has expired or is invalid.</p>
1422
1366
  <p>Please try authenticating again.</p>
1423
1367
  </body></html>`, {
1424
- status: 400,
1425
- headers: { "Content-Type": "text/html" }
1426
- });
1427
- }
1428
- // Delete state token after validation (one-time use)
1429
- await env.OAUTH_STATE.delete(stateToken);
1430
- try {
1431
- // Exchange authorization code for access token
1432
- // Use Basic auth in Authorization header (Figma's recommended method)
1433
- const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
1434
- const tokenParams = new URLSearchParams({
1435
- redirect_uri: `${oauthOrigin}/oauth/callback`,
1436
- code,
1437
- grant_type: "authorization_code"
1438
- });
1439
- const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
1440
- method: "POST",
1441
- headers: {
1442
- "Content-Type": "application/x-www-form-urlencoded",
1443
- "Authorization": `Basic ${credentials}`
1444
- },
1445
- body: tokenParams.toString()
1446
- });
1447
- if (!tokenResponse.ok) {
1448
- const errorText = await tokenResponse.text();
1449
- let errorData;
1450
- try {
1451
- errorData = JSON.parse(errorText);
1452
- }
1453
- catch {
1454
- errorData = { error: "Unknown error", raw: errorText, status: tokenResponse.status };
1455
- }
1456
- logger.error({ errorData, status: tokenResponse.status }, "Token exchange failed");
1457
- throw new Error(`Token exchange failed: ${JSON.stringify(errorData)}`);
1368
+ status: 400,
1369
+ headers: { "Content-Type": "text/html" }
1370
+ });
1458
1371
  }
1459
- const tokenData = await tokenResponse.json();
1460
- const accessToken = tokenData.access_token;
1461
- const refreshToken = tokenData.refresh_token;
1462
- const expiresIn = tokenData.expires_in;
1463
- logger.info({
1464
- sessionId,
1465
- hasAccessToken: !!accessToken,
1466
- hasRefreshToken: !!refreshToken,
1467
- expiresIn
1468
- }, "Token exchange successful");
1469
- // IMPORTANT: Use KV storage for tokens since Durable Object storage is instance-specific
1470
- // Store token in Workers KV so it's accessible across all Durable Object instances
1471
- const tokenKey = `oauth_token:${sessionId}`;
1472
- const tokenExpiresAt = Date.now() + (expiresIn * 1000);
1473
- const storedToken = {
1474
- accessToken,
1475
- refreshToken,
1476
- expiresAt: tokenExpiresAt
1477
- };
1478
- // Store in KV with 90-day expiration (matching token lifetime)
1479
- await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
1480
- expirationTtl: expiresIn
1481
- });
1482
- // Token is stored only under the user-specific sessionId key (SEC-002)
1483
- // Store reverse lookup for Bearer token validation on SSE endpoint
1484
- // This allows us to validate Authorization: Bearer <token> headers
1485
- const bearerKey = `bearer_token:${accessToken}`;
1486
- await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
1487
- sessionId,
1488
- expiresAt: tokenExpiresAt
1489
- }), {
1490
- expirationTtl: expiresIn
1491
- });
1492
- // Verify the token was stored
1493
- const verifyToken = await env.OAUTH_TOKENS.get(tokenKey);
1494
- logger.info({ sessionId, tokenKey, storedSuccessfully: !!verifyToken }, "Token stored in KV");
1495
- // Check if this flow came from an MCP client (like mcp-remote)
1496
- // If so, we need to redirect back to the client with an authorization code
1497
- const mcpStateKey = `mcp_auth:${sessionId}`;
1498
- const mcpAuthJson = await env.OAUTH_STATE.get(mcpStateKey);
1499
- if (mcpAuthJson) {
1500
- // MCP client flow - redirect back with authorization code
1501
- const mcpAuthData = JSON.parse(mcpAuthJson);
1502
- // Clean up the MCP auth state
1503
- await env.OAUTH_STATE.delete(mcpStateKey);
1504
- // Generate an authorization code for the MCP client
1505
- // We use the sessionId as the code since we've already stored the token
1506
- const authCode = sessionId;
1507
- // Build the redirect URL back to the MCP client
1508
- const redirectUrl = new URL(mcpAuthData.redirectUri);
1509
- redirectUrl.searchParams.set("code", authCode);
1510
- redirectUrl.searchParams.set("state", mcpAuthData.state);
1372
+ // Delete state token after validation (one-time use)
1373
+ await env.OAUTH_STATE.delete(stateToken);
1374
+ try {
1375
+ // Exchange authorization code for access token
1376
+ // Use Basic auth in Authorization header (Figma's recommended method)
1377
+ const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
1378
+ const tokenParams = new URLSearchParams({
1379
+ redirect_uri: `${oauthOrigin}/oauth/callback`,
1380
+ code,
1381
+ grant_type: "authorization_code"
1382
+ });
1383
+ const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
1384
+ method: "POST",
1385
+ headers: {
1386
+ "Content-Type": "application/x-www-form-urlencoded",
1387
+ "Authorization": `Basic ${credentials}`
1388
+ },
1389
+ body: tokenParams.toString()
1390
+ });
1391
+ if (!tokenResponse.ok) {
1392
+ const errorText = await tokenResponse.text();
1393
+ let errorData;
1394
+ try {
1395
+ errorData = JSON.parse(errorText);
1396
+ }
1397
+ catch {
1398
+ errorData = { error: "Unknown error", raw: errorText, status: tokenResponse.status };
1399
+ }
1400
+ logger.error({ errorData, status: tokenResponse.status }, "Token exchange failed");
1401
+ throw new Error(`Token exchange failed: ${JSON.stringify(errorData)}`);
1402
+ }
1403
+ const tokenData = await tokenResponse.json();
1404
+ const accessToken = tokenData.access_token;
1405
+ const refreshToken = tokenData.refresh_token;
1406
+ const expiresIn = tokenData.expires_in;
1511
1407
  logger.info({
1512
1408
  sessionId,
1513
- redirectUri: mcpAuthData.redirectUri,
1514
- state: mcpAuthData.state
1515
- }, "Redirecting back to MCP client");
1516
- return Response.redirect(redirectUrl.toString(), 302);
1517
- }
1518
- // Direct browser flow - show success page
1519
- return new Response(`<!DOCTYPE html>
1409
+ hasAccessToken: !!accessToken,
1410
+ accessTokenPreview: accessToken ? accessToken.substring(0, 10) + "..." : null,
1411
+ hasRefreshToken: !!refreshToken,
1412
+ expiresIn
1413
+ }, "Token exchange successful");
1414
+ // IMPORTANT: Use KV storage for tokens since Durable Object storage is instance-specific
1415
+ // Store token in Workers KV so it's accessible across all Durable Object instances
1416
+ const tokenKey = `oauth_token:${sessionId}`;
1417
+ const tokenExpiresAt = Date.now() + (expiresIn * 1000);
1418
+ const storedToken = {
1419
+ accessToken,
1420
+ refreshToken,
1421
+ expiresAt: tokenExpiresAt
1422
+ };
1423
+ // Store in KV with 90-day expiration (matching token lifetime)
1424
+ await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
1425
+ expirationTtl: expiresIn
1426
+ });
1427
+ // CRITICAL: Also store under the fixed session ID that Durable Objects use
1428
+ // This ensures getFigmaAPI() can retrieve the token regardless of which
1429
+ // session ID was used during OAuth (e.g., mcp-remote's client_id)
1430
+ const fixedTokenKey = `oauth_token:figma-console-mcp-default-session`;
1431
+ if (tokenKey !== fixedTokenKey) {
1432
+ await env.OAUTH_TOKENS.put(fixedTokenKey, JSON.stringify(storedToken), {
1433
+ expirationTtl: expiresIn
1434
+ });
1435
+ logger.info({ fixedTokenKey }, "Token also stored under fixed session ID for Durable Object access");
1436
+ }
1437
+ // Store reverse lookup for Bearer token validation on SSE endpoint
1438
+ // This allows us to validate Authorization: Bearer <token> headers
1439
+ const bearerKey = `bearer_token:${accessToken}`;
1440
+ await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
1441
+ sessionId,
1442
+ expiresAt: tokenExpiresAt
1443
+ }), {
1444
+ expirationTtl: expiresIn
1445
+ });
1446
+ // Verify the token was stored
1447
+ const verifyToken = await env.OAUTH_TOKENS.get(tokenKey);
1448
+ logger.info({ sessionId, tokenKey, storedSuccessfully: !!verifyToken }, "Token stored in KV");
1449
+ // Check if this flow came from an MCP client (like mcp-remote)
1450
+ // If so, we need to redirect back to the client with an authorization code
1451
+ const mcpStateKey = `mcp_auth:${sessionId}`;
1452
+ const mcpAuthJson = await env.OAUTH_STATE.get(mcpStateKey);
1453
+ if (mcpAuthJson) {
1454
+ // MCP client flow - redirect back with authorization code
1455
+ const mcpAuthData = JSON.parse(mcpAuthJson);
1456
+ // Clean up the MCP auth state
1457
+ await env.OAUTH_STATE.delete(mcpStateKey);
1458
+ // Generate an authorization code for the MCP client
1459
+ // We use the sessionId as the code since we've already stored the token
1460
+ const authCode = sessionId;
1461
+ // Build the redirect URL back to the MCP client
1462
+ const redirectUrl = new URL(mcpAuthData.redirectUri);
1463
+ redirectUrl.searchParams.set("code", authCode);
1464
+ redirectUrl.searchParams.set("state", mcpAuthData.state);
1465
+ logger.info({
1466
+ sessionId,
1467
+ redirectUri: mcpAuthData.redirectUri,
1468
+ state: mcpAuthData.state
1469
+ }, "Redirecting back to MCP client");
1470
+ return Response.redirect(redirectUrl.toString(), 302);
1471
+ }
1472
+ // Direct browser flow - show success page
1473
+ return new Response(`<!DOCTYPE html>
1520
1474
  <html>
1521
1475
  <head>
1522
1476
  <meta charset="UTF-8">
@@ -1603,81 +1557,81 @@ async function handleRequest(request, env, ctx) {
1603
1557
  </script>
1604
1558
  </body>
1605
1559
  </html>`, {
1606
- headers: {
1607
- "Content-Type": "text/html; charset=utf-8"
1608
- }
1609
- });
1610
- }
1611
- catch (error) {
1612
- logger.error({ error, sessionId }, "OAuth callback failed");
1613
- return new Response(`<html><body>
1560
+ headers: {
1561
+ "Content-Type": "text/html; charset=utf-8"
1562
+ }
1563
+ });
1564
+ }
1565
+ catch (error) {
1566
+ logger.error({ error, sessionId }, "OAuth callback failed");
1567
+ return new Response(`<html><body>
1614
1568
  <h1>Authentication Error</h1>
1615
1569
  <p>Failed to complete authentication: ${error instanceof Error ? error.message : String(error)}</p>
1616
1570
  <p>Please try again or contact support.</p>
1617
1571
  </body></html>`, {
1618
- status: 500,
1619
- headers: { "Content-Type": "text/html" }
1572
+ status: 500,
1573
+ headers: { "Content-Type": "text/html" }
1574
+ });
1575
+ }
1576
+ }
1577
+ // Health check endpoint
1578
+ if (url.pathname === "/health") {
1579
+ return new Response(JSON.stringify({
1580
+ status: "healthy",
1581
+ service: "Figma Console MCP",
1582
+ version: "1.15.0",
1583
+ endpoints: {
1584
+ mcp: ["/sse", "/mcp"],
1585
+ oauth_mcp_spec: ["/.well-known/oauth-authorization-server", "/authorize", "/token", "/oauth/register"],
1586
+ oauth_legacy: ["/oauth/authorize", "/oauth/callback"],
1587
+ utility: ["/test-browser", "/health"]
1588
+ },
1589
+ oauth_configured: !!env.FIGMA_OAUTH_CLIENT_ID
1590
+ }), {
1591
+ headers: { "Content-Type": "application/json" },
1620
1592
  });
1621
1593
  }
1622
- }
1623
- // Health check endpoint
1624
- if (url.pathname === "/health") {
1625
- return new Response(JSON.stringify({
1626
- status: "healthy",
1627
- service: "Figma Console MCP",
1628
- version: "1.13.0",
1629
- endpoints: {
1630
- mcp: ["/sse", "/mcp"],
1631
- oauth_mcp_spec: ["/.well-known/oauth-authorization-server", "/authorize", "/token", "/oauth/register"],
1632
- oauth_legacy: ["/oauth/authorize", "/oauth/callback"],
1633
- utility: ["/test-browser", "/health"]
1634
- },
1635
- oauth_configured: !!env.FIGMA_OAUTH_CLIENT_ID
1636
- }), {
1637
- headers: { "Content-Type": "application/json" },
1638
- });
1639
- }
1640
- // Browser Rendering API test endpoint
1641
- if (url.pathname === "/test-browser") {
1642
- const results = await testBrowserRendering(env);
1643
- return new Response(JSON.stringify(results, null, 2), {
1644
- headers: { "Content-Type": "application/json" },
1645
- });
1646
- }
1647
- // Serve favicon
1648
- if (url.pathname === "/favicon.ico") {
1649
- // Redirect to custom Figma Console icon
1650
- return Response.redirect("https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg", 302);
1651
- }
1652
- // Proxy /docs to Mintlify
1653
- if (/^\/docs/.test(url.pathname)) {
1654
- // Try mintlify.app domain (Mintlify's standard hosting)
1655
- const DOCS_URL = "southleftllc.mintlify.app";
1656
- const CUSTOM_URL = "figma-console-mcp.southleft.com";
1657
- const proxyUrl = new URL(request.url);
1658
- proxyUrl.hostname = DOCS_URL;
1659
- const proxyRequest = new Request(proxyUrl, request);
1660
- proxyRequest.headers.set("Host", DOCS_URL);
1661
- proxyRequest.headers.set("X-Forwarded-Host", CUSTOM_URL);
1662
- proxyRequest.headers.set("X-Forwarded-Proto", "https");
1663
- return await fetch(proxyRequest);
1664
- }
1665
- // Root path - serve landing page with editorial layout and light/dark mode
1666
- if (url.pathname === "/") {
1667
- return new Response(`<!DOCTYPE html>
1594
+ // Browser Rendering API test endpoint
1595
+ if (url.pathname === "/test-browser") {
1596
+ const results = await testBrowserRendering(env);
1597
+ return new Response(JSON.stringify(results, null, 2), {
1598
+ headers: { "Content-Type": "application/json" },
1599
+ });
1600
+ }
1601
+ // Serve favicon
1602
+ if (url.pathname === "/favicon.ico") {
1603
+ // Redirect to custom Figma Console icon
1604
+ return Response.redirect("https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg", 302);
1605
+ }
1606
+ // Proxy /docs to Mintlify
1607
+ if (/^\/docs/.test(url.pathname)) {
1608
+ // Try mintlify.app domain (Mintlify's standard hosting)
1609
+ const DOCS_URL = "southleftllc.mintlify.app";
1610
+ const CUSTOM_URL = "figma-console-mcp.southleft.com";
1611
+ const proxyUrl = new URL(request.url);
1612
+ proxyUrl.hostname = DOCS_URL;
1613
+ const proxyRequest = new Request(proxyUrl, request);
1614
+ proxyRequest.headers.set("Host", DOCS_URL);
1615
+ proxyRequest.headers.set("X-Forwarded-Host", CUSTOM_URL);
1616
+ proxyRequest.headers.set("X-Forwarded-Proto", "https");
1617
+ return await fetch(proxyRequest);
1618
+ }
1619
+ // Root path - serve landing page with editorial layout and light/dark mode
1620
+ if (url.pathname === "/") {
1621
+ return new Response(`<!DOCTYPE html>
1668
1622
  <html lang="en">
1669
1623
  <head>
1670
1624
  <meta charset="UTF-8">
1671
1625
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1672
1626
  <title>Figma Console MCP - The Most Comprehensive MCP Server for Figma</title>
1673
1627
  <link rel="icon" type="image/svg+xml" href="https://docs.figma-console-mcp.southleft.com/favicon.svg">
1674
- <meta name="description" content="Turn your Figma design system into a living API. 59+ tools give AI assistants deep access to design tokens, component specs, variables, and programmatic design creation.">
1628
+ <meta name="description" content="Turn your Figma design system into a living API. 63+ tools give AI assistants deep access to design tokens, component specs, variables, and programmatic design creation.">
1675
1629
 
1676
1630
  <!-- Open Graph -->
1677
1631
  <meta property="og:type" content="website">
1678
1632
  <meta property="og:url" content="https://figma-console-mcp.southleft.com">
1679
1633
  <meta property="og:title" content="Figma Console MCP - Turn Your Design System Into a Living API">
1680
- <meta property="og:description" content="The most comprehensive MCP server for Figma. 59+ tools give AI assistants deep access to design tokens, components, variables, and programmatic design creation.">
1634
+ <meta property="og:description" content="The most comprehensive MCP server for Figma. 63+ tools give AI assistants deep access to design tokens, components, variables, and programmatic design creation.">
1681
1635
  <meta property="og:image" content="https://docs.figma-console-mcp.southleft.com/images/og-image.jpg">
1682
1636
  <meta property="og:image:width" content="1200">
1683
1637
  <meta property="og:image:height" content="630">
@@ -1685,7 +1639,7 @@ async function handleRequest(request, env, ctx) {
1685
1639
  <!-- Twitter -->
1686
1640
  <meta name="twitter:card" content="summary_large_image">
1687
1641
  <meta name="twitter:title" content="Figma Console MCP - Turn Your Design System Into a Living API">
1688
- <meta name="twitter:description" content="The most comprehensive MCP server for Figma. 59+ tools give AI assistants deep access to design tokens, components, variables, and programmatic design creation.">
1642
+ <meta name="twitter:description" content="The most comprehensive MCP server for Figma. 63+ tools give AI assistants deep access to design tokens, components, variables, and programmatic design creation.">
1689
1643
  <meta name="twitter:image" content="https://docs.figma-console-mcp.southleft.com/images/og-image.jpg">
1690
1644
 
1691
1645
  <meta name="theme-color" content="#0D9488">
@@ -2892,8 +2846,9 @@ async function handleRequest(request, env, ctx) {
2892
2846
  </script>
2893
2847
  </body>
2894
2848
  </html>`, {
2895
- headers: { "Content-Type": "text/html; charset=utf-8" }
2896
- });
2897
- }
2898
- return new Response("Not found", { status: 404 });
2899
- }
2849
+ headers: { "Content-Type": "text/html; charset=utf-8" }
2850
+ });
2851
+ }
2852
+ return new Response("Not found", { status: 404 });
2853
+ },
2854
+ };