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 +21 -0
- package/README.md +133 -0
- package/SKILL.md +81 -0
- package/bin/figma-mcp-auth.js +97 -0
- package/lib/constants.js +32 -0
- package/lib/doctor.js +126 -0
- package/lib/inject.js +130 -0
- package/lib/keyring.js +198 -0
- package/lib/mint.js +140 -0
- package/lib/probe.js +56 -0
- package/lib/refresh.js +73 -0
- package/lib/store.js +40 -0
- package/package.json +40 -0
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
|
+
});
|
package/lib/constants.js
ADDED
|
@@ -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
|
+
}
|