figma-mcp-auth 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 figma-mcp-auth contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # figma-mcp-auth
2
+
3
+ **Connect any MCP client to Figma's remote MCP server — even clients Figma doesn't officially support.**
4
+
5
+ ```
6
+ npx figma-mcp-auth all
7
+ ```
8
+
9
+ ## The problem
10
+
11
+ Figma's remote MCP server (`https://mcp.figma.com/mcp`) is OAuth-only, and its
12
+ OAuth server rejects [RFC 7591 dynamic client registration](https://datatracker.ietf.org/doc/html/rfc7591)
13
+ from every client that isn't on Figma's catalog:
14
+
15
+ > *Only clients listed in the Figma MCP Catalog like VS Code, Cursor, or Claude Code can connect.*
16
+ > — [Figma docs](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/)
17
+
18
+ If your agent isn't in that list (ZCode, Windsurf, Trae, Cline, a home-grown
19
+ agent, anything new), its login fails deterministically:
20
+
21
+ ```
22
+ Dynamic Client Registration rejected (HTTP 403): Forbidden
23
+ MCP authorization was not completed or timed out. Authorize again.
24
+ ```
25
+
26
+ Personal access tokens don't help either — Figma's remote server accepts
27
+ **OAuth only** (no `X-Figma-Token` header, no PATs).
28
+
29
+ ## The fix
30
+
31
+ This tool mints a Figma MCP token **through a client Figma does accept** (the
32
+ Codex CLI, or Claude Code), then injects it into your agent's MCP config as a
33
+ static `Authorization: Bearer` header. Figma's server is perfectly happy serving
34
+ tools to any client that presents a valid token — it just won't *register*
35
+ unknown clients.
36
+
37
+ ```
38
+ ┌─────────────┐ codex mcp login (allow-listed) ┌────────────┐
39
+ │ codex CLI │ ──── browser → you click Allow ──▶│ Figma │
40
+ │ (isolated │ ◀── token stored in OS keyring ───│ OAuth │
41
+ │ home) │ └────────────┘
42
+ └──────┬──────┘
43
+ │ figma-mcp-auth reads the keyring
44
+
45
+ ┌─────────────┐ Authorization: Bearer figu_… ┌────────────┐
46
+ │ your agent │ ◀── HTTP 200, tools available ───▶│ Figma │
47
+ └─────────────┘ │ MCP server │
48
+ └────────────┘
49
+ ```
50
+
51
+ What it does **not** do: touch your existing Codex or Claude setup (Codex runs
52
+ in a throwaway `CODEX_HOME`), send your token anywhere except api.figma.com /
53
+ mcp.figma.com, or store it anywhere but `~/.figma-mcp-auth/token.json` and the
54
+ agent configs you explicitly inject into.
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ npx figma-mcp-auth all # diagnose → mint (browser approval) → inject
60
+ ```
61
+
62
+ Step by step:
63
+
64
+ | Command | What it does |
65
+ |---|---|
66
+ | `figma-mcp-auth doctor` | Checks the endpoint, the stored token (live handshake), the DCR allow-list gate, and every known agent config |
67
+ | `figma-mcp-auth mint` | Runs `codex mcp login figma` in an isolated home; you approve in the browser; token lands in `~/.figma-mcp-auth/token.json` |
68
+ | `figma-mcp-auth inject --target zcode` | Writes the `Authorization` header into `~/.zcode/cli/config.json` (also: `--target claude`, `--target cursor`, or any JSON file via `--config`) |
69
+ | `figma-mcp-auth refresh` | Exchanges the stored refresh token for a fresh access token and updates every config previously injected |
70
+ | `figma-mcp-auth all` | doctor → mint (only if needed) → inject |
71
+
72
+ Supported config shapes (auto-detected):
73
+
74
+ ```jsonc
75
+ // ZCode (~/.zcode/cli/config.json) — nested
76
+ { "mcp": { "servers": { "figma": { "type": "http", "url": "https://mcp.figma.com/mcp",
77
+ "headers": { "Authorization": "Bearer figu_…" } } } } }
78
+
79
+ // Claude Code / Cursor / .mcp.json — flat
80
+ { "mcpServers": { "figma": { "type": "http", "url": "https://mcp.figma.com/mcp",
81
+ "headers": { "Authorization": "Bearer figu_…" } } } }
82
+ ```
83
+
84
+ After injecting, **restart the agent** (MCP servers connect at session start).
85
+
86
+ ## Requirements
87
+
88
+ - Node ≥ 18
89
+ - For `mint`: the [Codex CLI](https://developers.openai.com/codex) installed and
90
+ on PATH (Windows, macOS, Linux). Alternative without Codex: `--client claude`,
91
+ which prints the two-step Claude Code flow and then adopts the token from
92
+ `~/.claude/.credentials.json`.
93
+ - A Figma account, and one click of **Allow** in the browser.
94
+
95
+ ## Token lifetime
96
+
97
+ Figma's MCP access tokens last **90 days** (`expires_in: 7776000`). Note:
98
+ `figma-mcp-auth refresh` only succeeds if the store contains the OAuth
99
+ `client_secret` — Figma's token endpoint demands it for refresh and the Codex
100
+ CLI does not retain it in its keyring blob. In practice, renewal means running
101
+ `figma-mcp-auth mint` again (one browser click, roughly once per 90 days); the
102
+ tool's `refresh` command detects this case and says so.
103
+
104
+ ## Known limits
105
+
106
+ - `mint` reads the token from the OS keyring where Codex stores it:
107
+ Windows Credential Manager (works headlessly), macOS Keychain (`security`
108
+ may show one permission prompt), Linux via `secret-tool` (best effort).
109
+ - Keyring layouts are reverse-engineered from the Codex CLI's behavior as of
110
+ its 0.13x releases; if a future Codex version changes storage, `mint` will
111
+ tell you and the manual steps in [SKILL.md](./SKILL.md) still apply.
112
+
113
+ ## For agent authors: the SKILL.md
114
+
115
+ This package ships [`SKILL.md`](./SKILL.md) — a drop-in skill file (frontmatter +
116
+ procedure) that teaches any agent how to diagnose and fix this exact failure,
117
+ including the fully manual fallback. Copy it into your agent's skills directory
118
+ (e.g. `~/.agents/skills/figma-mcp-auth/SKILL.md`, `~/.claude/skills/`, …) or
119
+ just let the agent run `npx figma-mcp-auth doctor` and read its output.
120
+
121
+ ## Publishing / running from source
122
+
123
+ ```bash
124
+ git clone <this repo> && cd figma-mcp-auth
125
+ node bin/figma-mcp-auth.js doctor # run without installing
126
+ npm link # or: npm i -g .
127
+ npm publish # makes `npx figma-mcp-auth` work for everyone
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT. Unofficial community tool — not affiliated with or endorsed by Figma.
133
+ Use with your own Figma account; the browser approval is always yours to give.
package/SKILL.md ADDED
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: figma-mcp-auth
3
+ description: Fix "Dynamic Client Registration rejected (HTTP 403)" / "MCP authorization was not completed" when a non-catalog MCP client (ZCode, Windsurf, Trae, custom agents, anything not VS Code/Cursor/Claude Code/Codex/Xcode) tries to authenticate against Figma's remote MCP server (mcp.figma.com/mcp). Mints a token via the allow-listed Codex CLI and injects it as a Bearer header. Use whenever Figma MCP auth fails with 401/403 in a client that is not in Figma's catalog.
4
+ ---
5
+
6
+ # Figma MCP auth for non-catalog clients
7
+
8
+ ## The problem
9
+
10
+ Figma's remote MCP server (`https://mcp.figma.com/mcp`) is OAuth-only and rejects
11
+ RFC 7591 dynamic client registration from every client not on its catalog
12
+ (VS Code, Cursor, Claude Code, Codex, Xcode). Symptoms:
13
+
14
+ - Settings shows the server failed with `Dynamic Client Registration rejected (HTTP 403): Forbidden`
15
+ - or `MCP authorization was not completed or timed out. Authorize again.` in a loop
16
+ - clicking the client's Authorize/Login button always fails, deterministically
17
+ - PATs / `X-Figma-Token` headers do NOT work on the remote server (OAuth only)
18
+
19
+ This is not fixable by retrying, changing config fields, or payloads — the
20
+ allow-list is server-side.
21
+
22
+ ## The fix (automated)
23
+
24
+ Mint a token through an allow-listed client that IS accepted (Codex CLI), then
25
+ inject it into the failing client's config as a Bearer header:
26
+
27
+ ```bash
28
+ npx figma-mcp-auth all # doctor + mint + inject (auto-detects config)
29
+ ```
30
+
31
+ Or step by step:
32
+
33
+ ```bash
34
+ npx figma-mcp-auth doctor # diagnose: endpoint, stored token, agent configs
35
+ npx figma-mcp-auth mint # runs codex mcp login in a throwaway home; user approves in browser
36
+ npx figma-mcp-auth inject --target zcode --config ~/.zcode/cli/config.json --name figma
37
+ npx figma-mcp-auth refresh # later, when the 90-day token expires
38
+ ```
39
+
40
+ - `mint` requires the Codex CLI on the machine (or `--client claude` after
41
+ authenticating figma inside Claude Code via `/mcp`). The user's own Codex/Claude
42
+ setup is never modified (Codex runs with an isolated `CODEX_HOME`).
43
+ - `inject` supports `{"mcp":{"servers":{...}}}` (ZCode) and `{"mcpServers":{...}}`
44
+ (Claude Code, Cursor, .mcp.json) shapes. It backs up the file to
45
+ `<path>.figma-mcp-auth.bak` before writing.
46
+ - Token store: `~/.figma-mcp-auth/token.json` (keeps `injected: [{path,name}]`).
47
+ - The access token lives ~90 days (`expires_in: 7776000`). `refresh` only works
48
+ when a `client_secret` is present in the store — Figma's token endpoint
49
+ requires it for refresh and the minting client doesn't retain it, so the
50
+ practical renewal is `npx figma-mcp-auth mint` again (one browser click, ~once
51
+ per 90 days).
52
+
53
+ ## Manual fallback (no npx, do it by hand)
54
+
55
+ 1. Verify the gate (optional): `POST https://api.figma.com/v1/oauth/mcp/register`
56
+ with any RFC 7591 body → 403 Forbidden confirms the allow-list.
57
+ 2. Create a temp dir with `config.toml`:
58
+ ```toml
59
+ [mcp_servers.figma]
60
+ url = "https://mcp.figma.com/mcp"
61
+ ```
62
+ 3. `CODEX_HOME=<tempdir> codex mcp login figma` → approve in browser.
63
+ 4. Read the token from the OS keyring — account `Codex MCP Credentials`,
64
+ service `figma|<client_id>`. The blob is a JSON doc:
65
+ `{ server_name, url, client_id, token_response: { access_token, refresh_token, expires_in }, expires_at }`
66
+ - Windows: PowerShell `CredEnumerate 'figma|*'`; blob is **UTF-16LE** encoded.
67
+ - macOS: `security find-generic-password -s 'figma|<id>' -a 'Codex MCP Credentials' -w`
68
+ - Linux: `secret-tool search account 'Codex MCP Credentials'` (best effort)
69
+ 5. Put `"headers": { "Authorization": "Bearer <access_token>" }` on the figma
70
+ entry of the failing client's MCP config (HTTP transport). Restart the client.
71
+
72
+ ## Notes and gotchas
73
+
74
+ - Sign in to Figma with the account that owns the files the agent should see.
75
+ - If the client has its own OAuth machinery (like ZCode's Settings → MCP
76
+ "Authorize again"), leave it broken — a config `Authorization` header takes
77
+ precedence because the server never returns a 401 challenge.
78
+ - Do NOT put the token in any file that gets committed; it is an account credential.
79
+ - If refresh ever fails (revoked), just re-run `mint` — it is a fresh 90-day token.
80
+ - Unofficial tool; not affiliated with Figma. Uses the user's own account with
81
+ explicit browser approval.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { runDoctor } = require("../lib/doctor");
5
+ const { runMint } = require("../lib/mint");
6
+ const { runInject } = require("../lib/inject");
7
+ const { runRefresh } = require("../lib/refresh");
8
+ const { STORE_PATH } = require("../lib/constants");
9
+
10
+ const HELP = `
11
+ figma-mcp-auth — connect non-catalog MCP clients to Figma's remote MCP server
12
+
13
+ Figma's remote MCP server (https://mcp.figma.com/mcp) only accepts OAuth client
14
+ registrations from its catalog apps (VS Code, Cursor, Claude Code, Codex, Xcode).
15
+ Any other agent gets "Dynamic Client Registration rejected (HTTP 403)" and can
16
+ never complete the login on its own.
17
+
18
+ This tool works around that by minting a Figma MCP token through an allow-listed
19
+ client already on your machine (Codex CLI, or Claude Code), then injecting it
20
+ into your agent's MCP config as an "Authorization: Bearer" header. No config of
21
+ the minting client is modified — Codex runs in a throwaway home directory.
22
+
23
+ Usage:
24
+ figma-mcp-auth doctor Diagnose endpoint, stored token, and configs
25
+ figma-mcp-auth mint [--client codex|claude]
26
+ Mint a token (opens your browser to approve)
27
+ figma-mcp-auth inject [--target <t>] [--config <path>] [--name <n>] [--dry-run]
28
+ Write the Bearer header into an agent config
29
+ figma-mcp-auth refresh Exchange the refresh token for a new access token
30
+ figma-mcp-auth all [--target <t>] doctor + mint (if needed) + inject
31
+
32
+ Targets: zcode | claude | cursor (or any JSON config via --config; both the
33
+ nested {"mcp":{"servers":{...}}} shape and the flat {"mcpServers":{...}} shape
34
+ are supported)
35
+
36
+ Token store: ${STORE_PATH}
37
+ `;
38
+
39
+ function parseFlags(argv) {
40
+ const flags = {};
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (!a.startsWith("--")) continue;
44
+ const key = a.slice(2);
45
+ const eq = key.indexOf("=");
46
+ if (eq > -1) {
47
+ flags[key.slice(0, eq)] = key.slice(eq + 1);
48
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
49
+ flags[key] = argv[++i];
50
+ } else {
51
+ flags[key] = true;
52
+ }
53
+ }
54
+ return flags;
55
+ }
56
+
57
+ async function runAll(flags) {
58
+ const doctor = await runDoctor({ ...flags, quiet: true });
59
+ const doc = require("../lib/store").load();
60
+ if (!doctor.ok || !doc || require("../lib/store").isExpired(doc)) {
61
+ await runMint(flags);
62
+ }
63
+ return runInject(flags);
64
+ }
65
+
66
+ async function main() {
67
+ const argv = process.argv.slice(2);
68
+ const cmd = argv.find((a) => !a.startsWith("--"));
69
+ const flags = parseFlags(argv);
70
+ switch (cmd) {
71
+ case "doctor":
72
+ return runDoctor(flags);
73
+ case "mint":
74
+ return runMint(flags);
75
+ case "inject":
76
+ return runInject(flags);
77
+ case "refresh":
78
+ return runRefresh(flags);
79
+ case "all":
80
+ return runAll(flags);
81
+ case "-h":
82
+ case "--help":
83
+ case "help":
84
+ case undefined:
85
+ console.log(HELP);
86
+ return;
87
+ default:
88
+ console.error(`Unknown command: ${cmd}`);
89
+ console.log(HELP);
90
+ process.exitCode = 1;
91
+ }
92
+ }
93
+
94
+ main().catch((e) => {
95
+ console.error(e && e.message ? e.message : e);
96
+ process.exitCode = 1;
97
+ });
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+
3
+ const os = require("os");
4
+ const path = require("path");
5
+
6
+ const MCP_URL = "https://mcp.figma.com/mcp";
7
+ const AUTHORIZE_URL = "https://www.figma.com/oauth/mcp";
8
+ const TOKEN_URL = "https://api.figma.com/v1/oauth/token";
9
+ const REGISTER_URL = "https://api.figma.com/v1/oauth/mcp/register";
10
+ const PROTECTED_RESOURCE_URL = "https://mcp.figma.com/.well-known/oauth-protected-resource";
11
+ const SCOPE = "mcp:connect";
12
+
13
+ // Keyring blob layout written by the Codex CLI's MCP OAuth store:
14
+ // { server_name, url, client_id, token_response: { access_token, token_type, expires_in, refresh_token }, expires_at }
15
+ const KEYRING_ACCOUNT = "Codex MCP Credentials";
16
+ const KEYRING_SERVICE_PREFIX = "figma|";
17
+
18
+ const STORE_DIR = path.join(os.homedir(), ".figma-mcp-auth");
19
+ const STORE_PATH = path.join(STORE_DIR, "token.json");
20
+
21
+ module.exports = {
22
+ MCP_URL,
23
+ AUTHORIZE_URL,
24
+ TOKEN_URL,
25
+ REGISTER_URL,
26
+ PROTECTED_RESOURCE_URL,
27
+ SCOPE,
28
+ KEYRING_ACCOUNT,
29
+ KEYRING_SERVICE_PREFIX,
30
+ STORE_DIR,
31
+ STORE_PATH,
32
+ };
package/lib/doctor.js ADDED
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const store = require("./store");
5
+ const { probeInitialize, probeRegister } = require("./probe");
6
+ const { TARGETS, openConfig, locateServers } = require("./inject");
7
+ const { findCodex } = require("./mint");
8
+
9
+ function checkConfig(name, p, serverName, failuresRef) {
10
+ if (!fs.existsSync(p)) {
11
+ console.log(`info - ${name}: no config at ${p}`);
12
+ return;
13
+ }
14
+ let json;
15
+ try {
16
+ json = openConfig(p);
17
+ } catch (e) {
18
+ console.log(`FAIL - ${name}: ${e.message}`);
19
+ failuresRef.n++;
20
+ return;
21
+ }
22
+ const servers = locateServers(json);
23
+ const entry = servers && servers[serverName];
24
+ if (!entry) {
25
+ console.log(`info - ${name}: config exists at ${p} but has no "${serverName}" server entry`);
26
+ return;
27
+ }
28
+ const header = entry.headers && entry.headers.Authorization;
29
+ if (!header) {
30
+ console.log(`warn - ${name}: "${serverName}" entry at ${p} has no Authorization header (this is why auth fails)`);
31
+ return;
32
+ }
33
+ console.log(`ok - ${name}: "${serverName}" configured at ${p} (Authorization ${header.slice(0, 14)}...${header.slice(-4)})`);
34
+ return header;
35
+ }
36
+
37
+ async function runDoctor(flags) {
38
+ const failures = { n: 0 };
39
+ let ok = true;
40
+
41
+ // 1. Is the remote server reachable, and does it behave as expected?
42
+ let probe;
43
+ try {
44
+ probe = await probeInitialize(null);
45
+ } catch (e) {
46
+ console.log(`warn - could not reach mcp.figma.com (${e.message}). Check your network/proxy; skipping endpoint checks.`);
47
+ probe = null;
48
+ }
49
+ if (probe && probe.status === 401) {
50
+ console.log("ok - mcp.figma.com is reachable and rejects unauthenticated clients (expected)");
51
+ } else if (probe) {
52
+ console.log(`warn - mcp.figma.com probe returned HTTP ${probe.status} (expected 401)`);
53
+ }
54
+
55
+ // 2. Stored token state.
56
+ const doc = store.load();
57
+ const token = store.accessToken(doc);
58
+ if (!doc) {
59
+ console.log(`info - no token stored yet (${store.STORE_PATH})`);
60
+ } else if (store.isExpired(doc)) {
61
+ console.log(`warn - stored token EXPIRED (was valid until ${new Date(store.expiresAt(doc)).toISOString()}). Run: figma-mcp-auth refresh`);
62
+ } else {
63
+ console.log(`ok - stored token valid until ${new Date(store.expiresAt(doc)).toISOString()}`);
64
+ try {
65
+ const live = await probeInitialize(token);
66
+ if (live.ok) {
67
+ console.log("ok - initialize handshake with stored token: HTTP 200 (tools available)");
68
+ } else {
69
+ console.log(`FAIL - initialize with stored token: HTTP ${live.status}. Try: figma-mcp-auth refresh`);
70
+ failures.n++;
71
+ }
72
+ } catch (e) {
73
+ console.log(`warn - live handshake with stored token failed (${e.message}) — network issue, token state unknown`);
74
+ }
75
+ }
76
+
77
+ // 3. Demonstrate the allow-list gate (why plain OAuth can never work here).
78
+ try {
79
+ const reg = await probeRegister();
80
+ if (reg.status === 403) {
81
+ console.log("info - direct OAuth registration is rejected (HTTP 403): Figma only accepts catalog clients (VS Code, Cursor, Claude Code, Codex, Xcode). That is the failure this tool works around.");
82
+ } else {
83
+ console.log(`info - registration probe returned HTTP ${reg.status} (unexpected; Figma may have changed its policy)`);
84
+ }
85
+ } catch (e) {
86
+ console.log(`warn - registration probe failed (${e.message}) — skipping allow-list check`);
87
+ }
88
+
89
+ // 4. Agent configs.
90
+ const serverName = flags.name || "figma";
91
+ for (const [name, fn] of Object.entries(TARGETS)) {
92
+ let header;
93
+ try {
94
+ header = checkConfig(name, fn(), serverName, failures);
95
+ } catch (e) {
96
+ console.log(`warn - ${name}: config check failed (${e.message})`);
97
+ continue;
98
+ }
99
+ const m = header && header.match(/^Bearer (.+)$/);
100
+ if (m) {
101
+ try {
102
+ const live = await probeInitialize(m[1]);
103
+ if (live.ok) {
104
+ console.log(`ok - ${name}: configured token works (HTTP 200)`);
105
+ } else {
106
+ console.log(`FAIL - ${name}: configured token rejected (HTTP ${live.status}). Run: figma-mcp-auth refresh`);
107
+ failures.n++;
108
+ }
109
+ } catch (e) {
110
+ console.log(`warn - ${name}: live check failed (${e.message}) — network issue, config state unknown`);
111
+ }
112
+ }
113
+ }
114
+
115
+ // 5. Can we mint at all?
116
+ const codex = findCodex();
117
+ console.log(codex ? `ok - Codex CLI found (${codex}) — minting available` : "info - Codex CLI not found on this machine (mint --client claude is the alternative)");
118
+
119
+ ok = failures.n === 0;
120
+ if (!flags.quiet) {
121
+ console.log(ok ? "\nAll checks passed." : `\n${failures.n} check(s) failed.`);
122
+ }
123
+ return { ok };
124
+ }
125
+
126
+ module.exports = { runDoctor };
package/lib/inject.js ADDED
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { MCP_URL } = require("./constants");
7
+ const store = require("./store");
8
+ const { adoptClaudeToken } = require("./mint");
9
+
10
+ const TARGETS = {
11
+ zcode: () => path.join(os.homedir(), ".zcode", "cli", "config.json"),
12
+ claude: () => path.join(os.homedir(), ".claude.json"),
13
+ cursor: () => path.join(os.homedir(), ".cursor", "mcp.json"),
14
+ };
15
+
16
+ function openConfig(p) {
17
+ if (!fs.existsSync(p)) return null;
18
+ try {
19
+ return JSON.parse(fs.readFileSync(p, "utf8"));
20
+ } catch (e) {
21
+ throw new Error(`${p} is not valid JSON: ${e.message}`);
22
+ }
23
+ }
24
+
25
+ /** Find the mcpServers map in either the ZCode nested shape or the flat standard shape. */
26
+ function locateServers(doc) {
27
+ if (doc.mcp && doc.mcp.servers && typeof doc.mcp.servers === "object") return doc.mcp.servers;
28
+ if (doc.mcpServers && typeof doc.mcpServers === "object") return doc.mcpServers;
29
+ return null;
30
+ }
31
+
32
+ function ensureServersContainer(doc) {
33
+ if (locateServers(doc)) return locateServers(doc);
34
+ if (doc.mcp && typeof doc.mcp === "object") {
35
+ doc.mcp.servers = doc.mcp.servers || {};
36
+ return doc.mcp.servers;
37
+ }
38
+ doc.mcpServers = {};
39
+ return doc.mcpServers;
40
+ }
41
+
42
+ function describeEntry(entry) {
43
+ const header = entry && entry.headers && entry.headers.Authorization;
44
+ return {
45
+ exists: !!entry,
46
+ hasToken: !!header,
47
+ masked: header ? `${header.slice(0, 14)}...${header.slice(-4)}` : null,
48
+ };
49
+ }
50
+
51
+ async function runInject(flags) {
52
+ // Resolve the token: store first, then adopt from Claude's credentials file.
53
+ let doc = store.load();
54
+ let token = store.accessToken(doc);
55
+ if (!token && (flags.client === "claude" || !flags.client)) {
56
+ const adopted = adoptClaudeToken();
57
+ if (adopted) {
58
+ console.log("Adopting token found in Claude Code credentials (~/.claude/.credentials.json).");
59
+ doc = { ...adopted, injected: (doc && doc.injected) || [] };
60
+ store.save(doc);
61
+ token = store.accessToken(doc);
62
+ }
63
+ }
64
+ if (!token) {
65
+ console.error("No Figma MCP token available. Run first: figma-mcp-auth mint");
66
+ process.exitCode = 1;
67
+ return { ok: false };
68
+ }
69
+
70
+ // Resolve the target config path.
71
+ let cfgPath;
72
+ if (flags.config) {
73
+ cfgPath = path.resolve(flags.config);
74
+ } else if (flags.target && TARGETS[flags.target]) {
75
+ cfgPath = TARGETS[flags.target]();
76
+ } else if (flags.target) {
77
+ console.error(`Unknown target '${flags.target}'. Targets: ${Object.keys(TARGETS).join(", ")} or pass --config <path>.`);
78
+ process.exitCode = 1;
79
+ return { ok: false };
80
+ } else {
81
+ for (const fn of Object.values(TARGETS)) {
82
+ const p = fn();
83
+ if (fs.existsSync(p)) {
84
+ cfgPath = p;
85
+ break;
86
+ }
87
+ }
88
+ if (!cfgPath) {
89
+ console.error(`No known agent config found. Pass one explicitly: --config <path> or --target <${Object.keys(TARGETS).join("|")}>`);
90
+ process.exitCode = 1;
91
+ return { ok: false };
92
+ }
93
+ }
94
+
95
+ const json = openConfig(cfgPath) || {};
96
+ const servers = ensureServersContainer(json);
97
+ const name = flags.name || "figma";
98
+ const before = describeEntry(servers[name]);
99
+
100
+ servers[name] = {
101
+ ...(servers[name] || {}),
102
+ type: (servers[name] && servers[name].type) || "http",
103
+ url: (servers[name] && servers[name].url) || MCP_URL,
104
+ headers: {
105
+ ...((servers[name] && servers[name].headers) || {}),
106
+ Authorization: `Bearer ${token}`,
107
+ },
108
+ };
109
+
110
+ const dryRun = !!(flags.dryRun || flags["dry-run"]);
111
+ if (!dryRun) {
112
+ if (!fs.existsSync(cfgPath)) console.log(`(creating ${cfgPath})`);
113
+ else fs.copyFileSync(cfgPath, cfgPath + ".figma-mcp-auth.bak");
114
+ fs.writeFileSync(cfgPath, JSON.stringify(json, null, 2) + "\n");
115
+ store.rememberInjected(doc, cfgPath, name);
116
+ }
117
+
118
+ const after = describeEntry(servers[name]);
119
+ if (before.hasToken && before.masked === after.masked) {
120
+ console.log(`${cfgPath}: "${name}" already up to date (Authorization ${after.masked}).`);
121
+ } else {
122
+ console.log(`${cfgPath}: ${dryRun ? "[dry-run] would update" : "updated"} "${name}" -> ${after.hasToken ? `Authorization ${after.masked}` : "(no header)"}`);
123
+ }
124
+ if (!dryRun) {
125
+ console.log("Restart your agent / start a new session so the MCP server reconnects with the new header.");
126
+ }
127
+ return { ok: true, configPath: cfgPath, name };
128
+ }
129
+
130
+ module.exports = { runInject, TARGETS, openConfig, locateServers };
package/lib/keyring.js ADDED
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+
3
+ const { execFileSync } = require("child_process");
4
+ const { MCP_URL, KEYRING_ACCOUNT, KEYRING_SERVICE_PREFIX } = require("./constants");
5
+
6
+ /**
7
+ * Extract Figma MCP token blobs that the Codex CLI stores in the OS keyring
8
+ * after `codex mcp login <name>` succeeds.
9
+ *
10
+ * Layout per platform:
11
+ * - Windows: Credential Manager, target "figma|<client_id>.Codex MCP Credentials",
12
+ * blob is the JSON document encoded UTF-16LE.
13
+ * - macOS: login keychain generic password, service "figma|<client_id>",
14
+ * account "Codex MCP Credentials", data is the JSON document (UTF-8).
15
+ * - Linux: Secret Service (GNOME Keyring/KWallet), best-effort via secret-tool.
16
+ *
17
+ * Returns an array of parsed token documents (may be empty).
18
+ */
19
+ function extractFigmaTokens() {
20
+ switch (process.platform) {
21
+ case "win32":
22
+ return safe(readWindows);
23
+ case "darwin":
24
+ return safe(readMacOS);
25
+ case "linux":
26
+ return safe(readLinux);
27
+ default:
28
+ return [];
29
+ }
30
+ }
31
+
32
+ function safe(fn) {
33
+ try {
34
+ return fn().filter(Boolean).filter((d) => d.url === MCP_URL && d.token_response && d.token_response.access_token);
35
+ } catch (err) {
36
+ console.error(`keyring read failed: ${err && err.message ? err.message : err}`);
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /* ------------------------------ Windows ------------------------------ */
42
+
43
+ // CredEnumerate "figma|*" then dump each blob as UTF-16LE text, one JSON doc per line.
44
+ const PS_SCRIPT = `
45
+ $src = @'
46
+ using System;
47
+ using System.Runtime.InteropServices;
48
+ public class CM {
49
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
50
+ public struct CRED {
51
+ public int Flags;
52
+ public int Type;
53
+ public string TargetName;
54
+ public string Comment;
55
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
56
+ public int CredentialBlobSize;
57
+ public IntPtr CredentialBlob;
58
+ public int Persist;
59
+ public int AttributeCount;
60
+ public IntPtr Attributes;
61
+ public string TargetAlias;
62
+ public string UserName;
63
+ }
64
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
65
+ public static extern bool CredEnumerate(string filter, int flag, out int count, out IntPtr p);
66
+ [DllImport("advapi32.dll")]
67
+ public static extern void CredFree(IntPtr b);
68
+ }
69
+ '@
70
+ Add-Type -TypeDefinition $src
71
+ [IntPtr]$p = [IntPtr]::Zero
72
+ [int]$n = 0
73
+ if (-not [CM]::CredEnumerate('${KEYRING_SERVICE_PREFIX}*', 0, [ref]$n, [ref]$p)) { exit 1 }
74
+ $ptrSize = [Runtime.InteropServices.Marshal]::SizeOf([type][IntPtr])
75
+ for ($i = 0; $i -lt $n; $i++) {
76
+ $ip = [Runtime.InteropServices.Marshal]::ReadIntPtr($p, $ptrSize * $i)
77
+ $c = [Runtime.InteropServices.Marshal]::PtrToStructure($ip, [type][CM+CRED])
78
+ if ($c.CredentialBlobSize -le 0) { continue }
79
+ $b = New-Object byte[] $c.CredentialBlobSize
80
+ [Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $b, 0, $c.CredentialBlobSize)
81
+ $t = [Text.Encoding]::Unicode.GetString($b)
82
+ if ($t.TrimStart().StartsWith('{')) { Write-Output $t }
83
+ }
84
+ `;
85
+
86
+ function readWindows() {
87
+ const encoded = Buffer.from(PS_SCRIPT.replace("${KEYRING_SERVICE_PREFIX}", KEYRING_SERVICE_PREFIX), "utf16le").toString("base64");
88
+ const out = execFileSync(
89
+ "powershell.exe",
90
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
91
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
92
+ );
93
+ return out
94
+ .split(/\r?\n/)
95
+ .map((line) => line.trim())
96
+ .filter(Boolean)
97
+ .map((line) => {
98
+ try {
99
+ return JSON.parse(line);
100
+ } catch {
101
+ return null;
102
+ }
103
+ });
104
+ }
105
+
106
+ /* -------------------------------- macOS -------------------------------- */
107
+
108
+ function readMacOS() {
109
+ // Enumerate keychain items metadata (no prompts), find services that look
110
+ // like figma|<client_id>, then read the matching password (may prompt once).
111
+ let dump = "";
112
+ try {
113
+ dump = execFileSync("security", ["dump-keychain"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
114
+ } catch {
115
+ return [];
116
+ }
117
+ const services = new Set();
118
+ const lines = dump.split(/\r?\n/);
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const m = lines[i].match(/"svce"<blob>="([^"]*)"/);
121
+ if (m && m[1].startsWith(KEYRING_SERVICE_PREFIX)) {
122
+ // Confirm the account on the next attribute lines when possible.
123
+ const near = lines.slice(i, i + 6).join("\n");
124
+ if (near.includes(KEYRING_ACCOUNT) || true) services.add(m[1]);
125
+ }
126
+ }
127
+ const docs = [];
128
+ for (const svc of services) {
129
+ try {
130
+ const out = execFileSync(
131
+ "security",
132
+ ["find-generic-password", "-s", svc, "-a", KEYRING_ACCOUNT, "-w"],
133
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
134
+ );
135
+ docs.push(parseMacBlob(out));
136
+ } catch {
137
+ // User declined the keychain prompt, or item mismatch — skip.
138
+ }
139
+ }
140
+ return docs;
141
+ }
142
+
143
+ function parseMacBlob(raw) {
144
+ const text = raw.trim();
145
+ try {
146
+ return JSON.parse(text);
147
+ } catch {}
148
+ // `security -w` prints binary blobs as hex pairs — decode and retry.
149
+ const hex = text.replace(/[^0-9a-fA-F]/g, "");
150
+ if (hex.length % 2 === 0) {
151
+ try {
152
+ return JSON.parse(Buffer.from(hex, "hex").toString("utf8"));
153
+ } catch {}
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /* -------------------------------- Linux -------------------------------- */
159
+
160
+ function readLinux() {
161
+ if (!hasBinary("secret-tool")) {
162
+ console.error("secret-tool not found. Install libsecret-tools, or extract the token manually (see SKILL.md).");
163
+ return [];
164
+ }
165
+ for (const args of [
166
+ ["search", "--all", "account", KEYRING_ACCOUNT],
167
+ ["search", "--all", "service", `${KEYRING_SERVICE_PREFIX}`],
168
+ ["search", "--all", "xdg:schema", "org.gnome.keyring.NetworkPassword"],
169
+ ]) {
170
+ try {
171
+ const out = execFileSync("secret-tool", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
172
+ const docs = (out.match(/\{[\s\S]*?\}/g) || [])
173
+ .map((b) => {
174
+ try {
175
+ return JSON.parse(b);
176
+ } catch {
177
+ return null;
178
+ }
179
+ })
180
+ .filter(Boolean);
181
+ if (docs.length) return docs;
182
+ } catch {
183
+ // try next query shape
184
+ }
185
+ }
186
+ return [];
187
+ }
188
+
189
+ function hasBinary(name) {
190
+ try {
191
+ execFileSync("which", [name], { stdio: "ignore" });
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ module.exports = { extractFigmaTokens };
package/lib/mint.js ADDED
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { spawnSync } = require("child_process");
7
+ const { MCP_URL } = require("./constants");
8
+ const store = require("./store");
9
+ const keyring = require("./keyring");
10
+
11
+ function findCodex() {
12
+ const candidates =
13
+ process.platform === "win32"
14
+ ? [path.join(process.env.LOCALAPPDATA || "", "OpenAI", "Codex", "bin", "codex.exe")]
15
+ : ["/usr/local/bin/codex", "/opt/homebrew/bin/codex", path.join(os.homedir(), ".local", "bin", "codex")];
16
+ for (const c of candidates) {
17
+ try {
18
+ fs.accessSync(c);
19
+ return c;
20
+ } catch {
21
+ // keep looking
22
+ }
23
+ }
24
+ try {
25
+ const out = spawnSync(process.platform === "win32" ? "where" : "which", ["codex"], { encoding: "utf8" });
26
+ const first = (out.stdout || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
27
+ if (first && fs.existsSync(first)) return first;
28
+ } catch {
29
+ // fall through
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Mint a Figma MCP token by driving the allow-listed Codex CLI:
36
+ * 1. create a throwaway CODEX_HOME so the user's Codex setup is untouched
37
+ * 2. register figma as a streamable-HTTP server in that home's config.toml
38
+ * 3. `codex mcp login figma` — opens the browser, user approves
39
+ * 4. pull the token document out of the OS keyring and store it
40
+ */
41
+ function mintViaCodex() {
42
+ const codex = findCodex();
43
+ if (!codex) {
44
+ console.error("Codex CLI not found.");
45
+ console.error("Install Codex (https://developers.openai.com/codex) or use: figma-mcp-auth mint --client claude");
46
+ process.exitCode = 1;
47
+ return null;
48
+ }
49
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "figma-mcp-auth-"));
50
+ fs.writeFileSync(path.join(home, "config.toml"), `[mcp_servers.figma]\nurl = "${MCP_URL}"\n`);
51
+
52
+ console.log("Running `codex mcp login figma` with an isolated CODEX_HOME (your Codex setup is not touched).");
53
+ console.log("A browser window should open — sign in to Figma and click Allow.");
54
+ console.log("If no browser opens, copy the URL codex prints into your browser.\n");
55
+ const res = spawnSync(codex, ["mcp", "login", "figma"], {
56
+ env: { ...process.env, CODEX_HOME: home },
57
+ stdio: "inherit",
58
+ });
59
+ try {
60
+ fs.rmSync(home, { recursive: true, force: true });
61
+ } catch {
62
+ // temp dir cleanup is best-effort
63
+ }
64
+ if (res.status !== 0) {
65
+ console.error(`\ncodex mcp login exited with code ${res.status} — authorization did not complete.`);
66
+ process.exitCode = 1;
67
+ return null;
68
+ }
69
+
70
+ const docs = keyring.extractFigmaTokens();
71
+ if (!docs.length) {
72
+ console.error("\nLogin succeeded but no Figma token was found in the OS keyring.");
73
+ console.error("Re-run mint, and when the keychain/credential prompt appears, click Allow.");
74
+ process.exitCode = 1;
75
+ return null;
76
+ }
77
+ const prev = store.load();
78
+ const doc = { ...docs[0], injected: (prev && prev.injected) || [] };
79
+ store.save(doc);
80
+ console.log(`\nToken minted and saved to ${store.STORE_PATH}`);
81
+ console.log(`Access token expires: ${new Date(doc.expires_at).toISOString()} (typically 90 days)`);
82
+ return doc;
83
+ }
84
+
85
+ /**
86
+ * Claude Code is also allow-listed. Its OAuth tokens live in a plain JSON file,
87
+ * so the flow is: add the server, authenticate inside Claude Code (/mcp), then
88
+ * the token can be adopted from ~/.claude/.credentials.json.
89
+ */
90
+ function claudeCredentialsPath() {
91
+ return path.join(os.homedir(), ".claude", ".credentials.json");
92
+ }
93
+
94
+ function adoptClaudeToken() {
95
+ try {
96
+ const c = JSON.parse(fs.readFileSync(claudeCredentialsPath(), "utf8"));
97
+ const entry = c.mcpOAuth && c.mcpOAuth[MCP_URL];
98
+ if (!entry) return null;
99
+ const access = entry.accessToken || entry.access_token;
100
+ if (!access) return null;
101
+ return {
102
+ server_name: "figma",
103
+ url: MCP_URL,
104
+ client_id: entry.clientId || entry.client_id || null,
105
+ token_response: {
106
+ access_token: access,
107
+ token_type: "bearer",
108
+ refresh_token: entry.refreshToken || entry.refresh_token || null,
109
+ },
110
+ expires_at: entry.expiresAt || entry.expires_at || 0,
111
+ };
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function printClaudeInstructions() {
118
+ console.log("Minting via Claude Code:");
119
+ console.log(` 1. claude mcp add --transport http figma ${MCP_URL} --scope user`);
120
+ console.log(" 2. start `claude`, run /mcp, select figma -> Authenticate -> Allow access");
121
+ console.log(" 3. back here, run: figma-mcp-auth inject --client claude");
122
+ }
123
+
124
+ async function runMint(flags) {
125
+ const client = flags.client || "codex";
126
+ if (client === "codex") {
127
+ const doc = mintViaCodex();
128
+ if (doc) console.log("Next: figma-mcp-auth inject --target <zcode|claude|cursor> [--config <path>]");
129
+ return doc;
130
+ }
131
+ if (client === "claude") {
132
+ printClaudeInstructions();
133
+ return null;
134
+ }
135
+ console.error(`Unknown client: ${client} (expected codex or claude)`);
136
+ process.exitCode = 1;
137
+ return null;
138
+ }
139
+
140
+ module.exports = { runMint, findCodex, adoptClaudeToken, claudeCredentialsPath };
package/lib/probe.js ADDED
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+
3
+ const { MCP_URL, REGISTER_URL } = require("./constants");
4
+
5
+ /**
6
+ * POST an MCP `initialize` request to Figma's remote MCP server.
7
+ * Without a token the server answers 401 (live, unauthenticated).
8
+ * With a valid bearer token it answers 200 and starts a session.
9
+ */
10
+ async function probeInitialize(token) {
11
+ const res = await fetch(MCP_URL, {
12
+ method: "POST",
13
+ headers: {
14
+ "Content-Type": "application/json",
15
+ Accept: "application/json, text/event-stream",
16
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
17
+ },
18
+ body: JSON.stringify({
19
+ jsonrpc: "2.0",
20
+ id: 1,
21
+ method: "initialize",
22
+ params: {
23
+ protocolVersion: "2025-06-18",
24
+ capabilities: {},
25
+ clientInfo: { name: "figma-mcp-auth", version: "1.0.0" },
26
+ },
27
+ }),
28
+ });
29
+ const body = (await res.text()).slice(0, 300);
30
+ return { status: res.status, ok: res.ok, body };
31
+ }
32
+
33
+ /**
34
+ * Attempt an RFC 7591 dynamic client registration, the way any non-catalog
35
+ * MCP client does on first connect. Figma answers 403 Forbidden for every
36
+ * client that is not on its catalog allow-list — that 403 is the exact
37
+ * failure this tool works around.
38
+ */
39
+ async function probeRegister() {
40
+ const res = await fetch(REGISTER_URL, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({
44
+ client_name: "figma-mcp-auth diagnostic",
45
+ redirect_uris: ["http://127.0.0.1:0/callback"],
46
+ grant_types: ["authorization_code", "refresh_token"],
47
+ response_types: ["code"],
48
+ token_endpoint_auth_method: "none",
49
+ scope: "mcp:connect",
50
+ }),
51
+ });
52
+ const body = (await res.text()).slice(0, 200);
53
+ return { status: res.status, ok: res.ok, body };
54
+ }
55
+
56
+ module.exports = { probeInitialize, probeRegister };
package/lib/refresh.js ADDED
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+
3
+ const { TOKEN_URL } = require("./constants");
4
+ const store = require("./store");
5
+ const { runInject } = require("./inject");
6
+
7
+ /**
8
+ * Exchange the stored refresh token for a new access token.
9
+ *
10
+ * NOTE: Figma's token endpoint requires the DCR client secret for refresh
11
+ * ("Client ID and client secret are required"). The Codex CLI does not retain
12
+ * that secret in its keyring blob, so in practice refresh only works if the
13
+ * store happens to contain a `client_secret` (e.g. adopted from a client that
14
+ * keeps one). Otherwise the correct move is `figma-mcp-auth mint` again —
15
+ * access tokens last ~90 days, so this is rare.
16
+ */
17
+ async function refreshToken(doc) {
18
+ const tr = doc.token_response || {};
19
+ if (!tr.refresh_token) throw new Error("Stored token has no refresh_token — run: figma-mcp-auth mint");
20
+ if (!doc.client_id) throw new Error("Stored token has no client_id — run: figma-mcp-auth mint");
21
+
22
+ const body = new URLSearchParams({
23
+ grant_type: "refresh_token",
24
+ refresh_token: tr.refresh_token,
25
+ client_id: doc.client_id,
26
+ });
27
+ const headers = { "Content-Type": "application/x-www-form-urlencoded" };
28
+ if (doc.client_secret) {
29
+ body.set("client_secret", doc.client_secret);
30
+ }
31
+ const res = await fetch(TOKEN_URL, { method: "POST", headers, body });
32
+ const json = await res.json().catch(() => ({}));
33
+ if (!res.ok || !json.access_token) {
34
+ if (res.status === 400 && /secret/i.test(`${json.error || ""} ${json.error_description || ""}`)) {
35
+ throw new Error(
36
+ "Figma's token endpoint requires the OAuth client secret to refresh, and it is not retained by the minting client. Re-run: figma-mcp-auth mint (a fresh browser approval; tokens last ~90 days)"
37
+ );
38
+ }
39
+ throw new Error(`Refresh failed (HTTP ${res.status}): ${JSON.stringify(json).slice(0, 200)}`);
40
+ }
41
+ doc.token_response = { ...tr, ...json };
42
+ doc.expires_at = Date.now() + (json.expires_in || 0) * 1000;
43
+ store.save(doc);
44
+ return doc;
45
+ }
46
+
47
+ async function runRefresh(flags) {
48
+ const doc = store.load();
49
+ if (!doc) {
50
+ console.error(`No stored token (${store.STORE_PATH}). Run: figma-mcp-auth mint`);
51
+ process.exitCode = 1;
52
+ return { ok: false };
53
+ }
54
+ try {
55
+ const updated = await refreshToken(doc);
56
+ console.log(`Token refreshed. New expiry: ${new Date(updated.expires_at).toISOString()}`);
57
+ const injected = updated.injected || [];
58
+ for (const inj of injected) {
59
+ await runInject({ config: inj.path, name: inj.name, quiet: true });
60
+ }
61
+ if (injected.length) console.log(`Re-injected into ${injected.length} config file(s).`);
62
+ return { ok: true };
63
+ } catch (e) {
64
+ console.error(e.message);
65
+ if (!/figma-mcp-auth mint/.test(e.message)) {
66
+ console.error("If refresh keeps failing (revoked token), just re-run: figma-mcp-auth mint");
67
+ }
68
+ process.exitCode = 1;
69
+ return { ok: false };
70
+ }
71
+ }
72
+
73
+ module.exports = { runRefresh, refreshToken };
package/lib/store.js ADDED
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { STORE_DIR, STORE_PATH } = require("./constants");
5
+
6
+ function load() {
7
+ try {
8
+ return JSON.parse(fs.readFileSync(STORE_PATH, "utf8"));
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function save(doc) {
15
+ fs.mkdirSync(STORE_DIR, { recursive: true });
16
+ fs.writeFileSync(STORE_PATH, JSON.stringify(doc, null, 2) + "\n");
17
+ }
18
+
19
+ function accessToken(doc) {
20
+ return (doc && doc.token_response && doc.token_response.access_token) || null;
21
+ }
22
+
23
+ function expiresAt(doc) {
24
+ return (doc && doc.expires_at) || 0;
25
+ }
26
+
27
+ function isExpired(doc, skewMs = 60_000) {
28
+ const t = expiresAt(doc);
29
+ return !t || Date.now() > t - skewMs;
30
+ }
31
+
32
+ function rememberInjected(doc, configPath, name) {
33
+ doc.injected = doc.injected || [];
34
+ if (!doc.injected.some((e) => e.path === configPath && e.name === name)) {
35
+ doc.injected.push({ path: configPath, name });
36
+ save(doc);
37
+ }
38
+ }
39
+
40
+ module.exports = { load, save, accessToken, expiresAt, isExpired, rememberInjected, STORE_PATH };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "figma-mcp-auth",
3
+ "version": "1.0.0",
4
+ "description": "Fix 'Dynamic Client Registration rejected (HTTP 403)' when connecting a non-catalog MCP client to Figma's remote MCP server. Mints an OAuth token via the allow-listed Codex CLI and injects it into your agent's MCP config as a Bearer header.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "figma-mcp-auth": "bin/figma-mcp-auth.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "lib/",
13
+ "SKILL.md",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "figma",
22
+ "mcp",
23
+ "mcp-server",
24
+ "oauth",
25
+ "dynamic-client-registration",
26
+ "codex",
27
+ "claude",
28
+ "cursor",
29
+ "403",
30
+ "bearer-token"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/faizersoftdev/figma-mcp-auth.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/faizersoftdev/figma-mcp-auth/issues"
38
+ },
39
+ "homepage": "https://github.com/faizersoftdev/figma-mcp-auth#readme"
40
+ }