Skip to content

Commit 32d5779

Browse files
authored
feat: add proxy support for managed networks (#338)
* feat(config): add undici dependency and --proxy config option Add `undici` as a production dependency (needed for proxy-aware fetch dispatcher in a follow-up change). Add `--proxy` CLI flag and support for FIGMA_PROXY, HTTPS_PROXY, and HTTP_PROXY env vars with a 4-source fallback chain. The proxy URL is included in startup config logging. * feat(proxy): set global fetch dispatcher at startup Route all fetch() calls through the configured proxy by setting undici's global dispatcher after config resolution. Uses ProxyAgent for explicit --proxy values and EnvHttpProxyAgent as the default to automatically respect HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars. * refactor: replace curl fallback with proxy-aware error messaging Remove the curl subprocess fallback from fetchWithRetry and replace it with connection error detection that guides users toward proxy configuration when they encounter network failures. * refactor: address review findings - Rename fetchWithRetry → fetchJSON (and file) since it no longer retries - Narrow proxy config to only --proxy/FIGMA_PROXY; let EnvHttpProxyAgent handle HTTPS_PROXY/HTTP_PROXY/NO_PROXY correctly at the dispatcher level - Mask proxy URL in startup logs (show "configured" vs "none") * fix: downgrade undici to 6.x for Node 18+ compatibility undici 8.x requires Node >= 22.19, but the project supports Node >= 18. undici 6.x supports Node 18+, is actively maintained through the Node 20/22 LTS cycle, and provides all the APIs we need (ProxyAgent, EnvHttpProxyAgent, setGlobalDispatcher). * fix: use 'network proxy' wording instead of 'corporate'
1 parent 966d0f7 commit 32d5779

7 files changed

Lines changed: 80 additions & 102 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"express": "^5.2.1",
6464
"js-yaml": "^4.1.1",
6565
"remeda": "^2.20.1",
66+
"undici": "^6.24.1",
6667
"zod": "^3.25.76"
6768
},
6869
"devDependencies": {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface ServerConfig {
1414
auth: FigmaAuthOptions;
1515
port: number;
1616
host: string;
17+
proxy: string | undefined;
1718
outputFormat: "yaml" | "json";
1819
skipImageDownloads: boolean;
1920
imageDir: string;
@@ -90,6 +91,10 @@ export function getServerConfig(): ServerConfig {
9091
description:
9192
"Base directory for image downloads. The download tool will only write files within this directory. Defaults to the current working directory.",
9293
},
94+
proxy: {
95+
type: String,
96+
description: "HTTP proxy URL for networks that require a proxy (e.g. http://proxy:8080)",
97+
},
9398
stdio: {
9499
type: Boolean,
95100
description: "Run in stdio transport mode for MCP clients",
@@ -121,6 +126,11 @@ export function getServerConfig(): ServerConfig {
121126
process.cwd(),
122127
);
123128

129+
// Only resolve explicit proxy config here. Standard env vars (HTTPS_PROXY, HTTP_PROXY,
130+
// NO_PROXY) are handled by undici's EnvHttpProxyAgent at the dispatcher level, which
131+
// correctly respects NO_PROXY exclusions.
132+
const proxy = resolve(argv.flags.proxy, envStr("FIGMA_PROXY"), undefined);
133+
124134
// These two don't fit the simple pattern: --json maps to a string enum,
125135
// and --stdio has a NODE_ENV backdoor.
126136
const outputFormat = resolve<"yaml" | "json">(
@@ -151,6 +161,7 @@ export function getServerConfig(): ServerConfig {
151161
figmaOauthToken: figmaOauthToken.source,
152162
port: port.source,
153163
host: host.source,
164+
proxy: proxy.source,
154165
outputFormat: outputFormat.source,
155166
skipImageDownloads: skipImageDownloads.source,
156167
imageDir: imageDir.source,
@@ -172,6 +183,7 @@ export function getServerConfig(): ServerConfig {
172183
}
173184
console.log(`- FRAMELINK_PORT: ${port.value} (source: ${configSources.port})`);
174185
console.log(`- FRAMELINK_HOST: ${host.value} (source: ${configSources.host})`);
186+
console.log(`- PROXY: ${proxy.value ? "configured" : "none"} (source: ${configSources.proxy})`);
175187
console.log(`- OUTPUT_FORMAT: ${outputFormat.value} (source: ${configSources.outputFormat})`);
176188
console.log(
177189
`- SKIP_IMAGE_DOWNLOADS: ${skipImageDownloads.value} (source: ${configSources.skipImageDownloads})`,
@@ -184,6 +196,7 @@ export function getServerConfig(): ServerConfig {
184196
auth,
185197
port: port.value,
186198
host: host.value,
199+
proxy: proxy.value,
187200
outputFormat: outputFormat.value,
188201
skipImageDownloads: skipImageDownloads.value,
189202
imageDir: imageDir.value,

src/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
33
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
44
import { Server } from "http";
55
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import { ProxyAgent, EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
67
import { Logger } from "./utils/logger.js";
78
import { createServer } from "./mcp/index.js";
89
import { getServerConfig } from "./config.js";
@@ -23,6 +24,14 @@ const activeConnections = new Set<ActiveConnection>();
2324
export async function startServer(): Promise<void> {
2425
const config = getServerConfig();
2526

27+
if (config.proxy) {
28+
setGlobalDispatcher(new ProxyAgent(config.proxy));
29+
} else {
30+
// EnvHttpProxyAgent automatically respects HTTP_PROXY/HTTPS_PROXY/NO_PROXY
31+
// env vars when present, and falls through to direct connections when absent.
32+
setGlobalDispatcher(new EnvHttpProxyAgent());
33+
}
34+
2635
const serverOptions = {
2736
isHTTP: !config.isStdioMode,
2837
outputFormat: config.outputFormat as "yaml" | "json",

src/services/figma.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
} from "@figma/rest-api-spec";
88
import { downloadAndProcessImage, type ImageProcessingResult } from "~/utils/image-processing.js";
99
import { Logger, writeLogs } from "~/utils/logger.js";
10-
import { fetchWithRetry } from "~/utils/fetch-with-retry.js";
10+
import { fetchJSON } from "~/utils/fetch-json.js";
1111

1212
export type FigmaAuthOptions = {
1313
figmaApiKey: string;
@@ -61,7 +61,7 @@ export class FigmaService {
6161
Logger.log(`Calling ${this.baseUrl}${endpoint}`);
6262
const headers = this.getAuthHeaders();
6363

64-
return await fetchWithRetry<T & { status?: number }>(`${this.baseUrl}${endpoint}`, {
64+
return await fetchJSON<T & { status?: number }>(`${this.baseUrl}${endpoint}`, {
6565
headers,
6666
});
6767
} catch (error) {

src/utils/fetch-json.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
type RequestOptions = RequestInit & {
2+
/**
3+
* Force format of headers to be a record of strings, e.g. { "Authorization": "Bearer 123" }
4+
*
5+
* Avoids complexity of needing to deal with `instanceof Headers`, which is not supported in some environments.
6+
*/
7+
headers?: Record<string, string>;
8+
};
9+
10+
const CONNECTION_ERROR_CODES = new Set([
11+
"ECONNRESET",
12+
"ECONNREFUSED",
13+
"ETIMEDOUT",
14+
"ENOTFOUND",
15+
"UND_ERR_CONNECT_TIMEOUT",
16+
]);
17+
18+
export async function fetchJSON<T extends { status?: number }>(
19+
url: string,
20+
options: RequestOptions = {},
21+
): Promise<T> {
22+
try {
23+
const response = await fetch(url, options);
24+
25+
if (!response.ok) {
26+
throw new Error(`Fetch failed with status ${response.status}: ${response.statusText}`);
27+
}
28+
return (await response.json()) as T;
29+
} catch (error: unknown) {
30+
if (isConnectionError(error)) {
31+
const message = error instanceof Error ? error.message : String(error);
32+
throw new Error(
33+
`${message}\n\nCould not connect to the Figma API. If your network requires a proxy, ` +
34+
`set the --proxy flag in your MCP server config or the FIGMA_PROXY environment variable ` +
35+
`to your proxy URL (e.g. http://proxy:8080).`,
36+
);
37+
}
38+
throw error;
39+
}
40+
}
41+
42+
function isConnectionError(error: unknown): boolean {
43+
if (!(error instanceof Error)) return false;
44+
const cause = (error as { cause?: { code?: string } }).cause;
45+
return cause?.code !== undefined && CONNECTION_ERROR_CODES.has(cause.code);
46+
}

src/utils/fetch-with-retry.ts

Lines changed: 0 additions & 100 deletions
This file was deleted.

0 commit comments

Comments
 (0)