@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.
- package/README.md +0 -22
- package/dist/cloudflare/core/cloud-websocket-relay.js +2 -3
- package/dist/cloudflare/core/config.js +1 -1
- package/dist/cloudflare/core/figma-api.js +6 -2
- package/dist/cloudflare/core/figma-desktop-connector.js +1 -2
- package/dist/cloudflare/core/port-discovery.js +73 -2
- package/dist/cloudflare/core/websocket-server.js +151 -21
- package/dist/cloudflare/core/write-tools.js +10 -2
- package/dist/cloudflare/index.js +701 -746
- package/dist/core/config.js +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +6 -2
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +2 -8
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/websocket-server.d.ts +10 -1
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +151 -21
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +37 -22
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/ui.html +1156 -173
- package/package.json +84 -85
package/dist/cloudflare/index.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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.
|
|
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
|
|
128
|
-
//
|
|
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({
|
|
126
|
+
logger.info({ sessionId: this.sessionId }, "Loaded persistent session ID from storage");
|
|
137
127
|
return;
|
|
138
128
|
}
|
|
139
129
|
else {
|
|
140
|
-
//
|
|
141
|
-
this.sessionId =
|
|
130
|
+
// Store the fixed session ID
|
|
131
|
+
this.sessionId = FIXED_SESSION_ID;
|
|
142
132
|
await storage.put('sessionId', this.sessionId);
|
|
143
|
-
logger.info({
|
|
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:
|
|
152
|
-
this.sessionId =
|
|
153
|
-
logger.info({
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
//
|
|
816
|
-
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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: "
|
|
855
|
-
error_description: "Bearer token is
|
|
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}"
|
|
827
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
|
|
861
828
|
}
|
|
862
829
|
});
|
|
863
830
|
}
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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: "
|
|
871
|
-
error_description: "
|
|
870
|
+
error: "server_error",
|
|
871
|
+
error_description: "Failed to validate authorization"
|
|
872
872
|
}), {
|
|
873
|
-
status:
|
|
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
|
-
|
|
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
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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: "
|
|
923
|
-
error_description: "Bearer token is
|
|
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}"
|
|
895
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
|
|
929
896
|
}
|
|
930
897
|
});
|
|
931
898
|
}
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
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: "
|
|
938
|
-
error_description: "
|
|
937
|
+
error: "server_error",
|
|
938
|
+
error_description: "Failed to validate authorization"
|
|
939
939
|
}), {
|
|
940
|
-
status:
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
|
1004
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
//
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
//
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
//
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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:
|
|
1300
|
+
status: 201,
|
|
1375
1301
|
headers: { "Content-Type": "application/json" }
|
|
1376
1302
|
});
|
|
1377
1303
|
}
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
//
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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: ${
|
|
1405
|
-
<p>Description: ${
|
|
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
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
headers
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
}
|
|
2849
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
return new Response("Not found", { status: 404 });
|
|
2853
|
+
},
|
|
2854
|
+
};
|