diff --git a/.changeset/major-shoes-carry.md b/.changeset/major-shoes-carry.md new file mode 100644 index 00000000..8f027285 --- /dev/null +++ b/.changeset/major-shoes-carry.md @@ -0,0 +1,5 @@ +--- +"figma-developer-mcp": minor +--- + +Introduce CLI option for downloading and persisting Figma data diff --git a/eslint.config.js b/eslint.config.js index 2f53760b..c61b4e1e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default [ "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + "no-undef": "off", }, }, { diff --git a/src/bin.ts b/src/bin.ts index bdca0ea9..c6831674 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,14 +1,27 @@ #!/usr/bin/env node -import { config } from "dotenv"; -import { resolve } from "path"; -import { startServer } from "./server.js"; - -// Load .env from the current working directory -config({ path: resolve(process.cwd(), ".env") }); - -// Start the server immediately - this file is only for execution -startServer().catch((error) => { - console.error("Failed to start server:", error); - process.exit(1); -}); +import { startServerConfigurable } from "./server.js"; +import { executeOnce } from "./exec-mode.js"; +import { getParsedArgs, getServerConfig } from "./config.js"; + +const argv = getParsedArgs(); + +// Route to exec mode or server mode +const execUrl = argv.exec || argv.e; + +if (execUrl) { + // Exec mode: one-off data fetch, then exit + const config = getServerConfig(argv, false, true); + executeOnce(execUrl, config.auth, config.outputFormat).catch((error) => { + console.error("Failed to execute:", error); + process.exit(1); + }); +} else { + // Server mode (stdio or HTTP) + const isStdioMode = process.env.NODE_ENV === "cli" || argv.stdio === true; + + startServerConfigurable(argv, isStdioMode).catch((error) => { + console.error("Failed to start server:", error); + process.exit(1); + }); +} diff --git a/src/config.ts b/src/config.ts index 3e0c030c..75bb39cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,21 @@ import { config as loadEnv } from "dotenv"; +import { resolve } from "path"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { resolve } from "path"; import type { FigmaAuthOptions } from "./services/figma.js"; +export interface CliArgs { + "figma-api-key"?: string; + "figma-oauth-token"?: string; + env?: string; + port?: number; + json?: boolean; + "skip-image-downloads"?: boolean; + exec?: string; + e?: string; + stdio?: boolean; +} + interface ServerConfig { auth: FigmaAuthOptions; port: number; @@ -24,18 +36,8 @@ function maskApiKey(key: string): string { return `****${key.slice(-4)}`; } -interface CliArgs { - "figma-api-key"?: string; - "figma-oauth-token"?: string; - env?: string; - port?: number; - json?: boolean; - "skip-image-downloads"?: boolean; -} - -export function getServerConfig(isStdioMode: boolean): ServerConfig { - // Parse command line arguments - const argv = yargs(hideBin(process.argv)) +export function getParsedArgs(): CliArgs { + return yargs(hideBin(process.argv)) .options({ "figma-api-key": { type: "string", @@ -63,11 +65,27 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { description: "Do not register the download_figma_images tool (skip image downloads)", default: false, }, + exec: { + type: "string", + alias: "e", + description: "Execute a single Figma data fetch from URL and exit (non-server mode)", + }, + stdio: { + type: "boolean", + description: "Run server in stdio mode (for MCP clients like Cursor)", + default: false, + }, }) .help() .version(process.env.NPM_PACKAGE_VERSION ?? "unknown") .parseSync() as CliArgs; +} +export function getServerConfig( + argv: CliArgs, + isStdioMode: boolean, + isExecMode = false, +): ServerConfig { // Load environment variables ASAP from custom path or default let envFilePath: string; let envFileSource: "cli" | "default"; @@ -158,8 +176,8 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { process.exit(1); } - // Log configuration sources - if (!isStdioMode) { + // Log configuration sources (skip in exec mode for cleaner output) + if (!isStdioMode && !isExecMode) { console.log("\nConfiguration:"); console.log(`- ENV_FILE: ${envFilePath} (source: ${config.configSources.envFile})`); if (auth.useOAuth) { diff --git a/src/exec-mode.ts b/src/exec-mode.ts new file mode 100644 index 00000000..c57451c8 --- /dev/null +++ b/src/exec-mode.ts @@ -0,0 +1,40 @@ +import { FigmaService } from "./services/figma.js"; +import { simplifyRawFigmaObject, allExtractors } from "./extractors/index.js"; +import { parseFigmaUrl } from "./utils/url-parser.js"; +import type { FigmaAuthOptions } from "./services/figma.js"; +import yaml from "js-yaml"; + +/** + * Execute a single Figma data fetch and output to stdout, then exit. + * This is a non-server mode for one-off data retrieval. + */ +export async function executeOnce( + figmaUrl: string, + authOptions: FigmaAuthOptions, + outputFormat: "yaml" | "json", +): Promise { + const { fileKey, nodeId: rawNodeId } = parseFigmaUrl(figmaUrl); + + // Replace - with : in nodeId for API query Figma API expects + const nodeId = rawNodeId?.replace(/-/g, ":"); + + const figmaService = new FigmaService(authOptions); + + const rawApiResponse = nodeId + ? await figmaService.getRawNode(fileKey, nodeId, null) + : await figmaService.getRawFile(fileKey, null); + + const simplifiedDesign = simplifyRawFigmaObject(rawApiResponse, allExtractors); + + const { nodes, globalVars, ...metadata } = simplifiedDesign; + const result = { + metadata, + nodes, + globalVars, + }; + + const formattedResult = + outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); + + console.log(formattedResult); +} diff --git a/src/server.ts b/src/server.ts index 5a18ef8c..d15b8d18 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,7 +7,7 @@ import { Server } from "http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Logger } from "./utils/logger.js"; import { createServer } from "./mcp/index.js"; -import { getServerConfig } from "./config.js"; +import { getParsedArgs, getServerConfig, type CliArgs } from "./config.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; let httpServer: Server | null = null; @@ -20,10 +20,15 @@ const transports = { * Start the MCP server in either stdio or HTTP mode. */ export async function startServer(): Promise { - // Check if we're running in stdio mode (e.g., via CLI) - const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); + const argv = getParsedArgs(); - const config = getServerConfig(isStdioMode); + // Server mode (stdio or HTTP) + const isStdioMode = process.env.NODE_ENV === "cli" || argv.stdio === true; + await startServerConfigurable(argv, isStdioMode); +} + +export async function startServerConfigurable(argv: CliArgs, isStdioMode: boolean): Promise { + const config = getServerConfig(argv, isStdioMode, false); const server = createServer(config.auth, { isHTTP: !isStdioMode,