Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Minor"
},
"note": "Several devup-mcp processes on one machine now share the Devup Bridge plugin. Only one can hold the port the plugin's manifest allows (ws://localhost:1993), and one devup-mcp per MCP client session is normal, so every process but the first used to report listening: false and could read Figma only through the metered direct path - with a plugin attached and serving, the only remedy was to kill another session's process. The process holding the port is now the host and the others relay through its new /relay endpoint: reads, results and the attached-file list travel over an authenticated WebSocket, request ids are assigned by the host so answers reach only the process that asked, and attachedFiles and the selection read the same in every process. When the host exits, a remaining process takes the port over at once (the OS gives it to exactly one) and the plugin re-attaches on its own two-second retry. A read in flight at that moment is sent again once the plugin is back - reads do not change the document - so a collection under way finishes (measured: 2.0 s after its holder was killed) instead of failing; it fails only if the plugin is not back within 10 s, and a read whose plugin window closed fails at once instead of after 90 s. The plugin now names its window with a sessionId, so a plugin that cannot report its file key (Dev Mode) keeps its routing key across reconnects and handovers, and it understands devup-cancel: a read nobody waits for any more - the process that asked went away, or it timed out - is dropped from the plugin's queue, or its answer withheld if it was already running. Older plugin builds ignore both and keep working. Relaying is limited to the same user's devup-mcp: both sides prove knowledge of a secret with HMAC over fresh nonces, and the secret never crosses the wire. It is kept in %USERPROFILE%\\AppData\\Local\\devup-mcp\\bridge-relay.key on Windows and in /tmp/devup-mcp-<uid>/bridge-relay.key on macOS and Linux - chosen by user id rather than HOME, which MCP clients set differently, so two sessions of one user always find each other; that directory must belong to this user with mode 0700, is closed and its secret replaced if it was open, and is refused if it belongs to someone else or is a link. Browser pages cannot pose as either side: /relay refuses any Origin, and /plugin admits only the Figma plugin's null origin and figma.com. Nothing listens beyond 127.0.0.1, and the relay handshake carries a protocol version so an incompatible peer is refused rather than trusted. devup_figma_auth status/doctor report paths.bridge.role (host, relay, connecting, unavailable), the holder's pid/version/buildId, handoverFrom while the port changes hands, and for an unusable port an issue - legacy-host, foreign-program, incompatible-protocol, authentication-failed - with the step that fixes it, within seconds rather than waiting. A holder that does not say who it is - a devup-mcp from before sharing, or another program - is named by pid, process name and executable path as the operating system reports them, added after the first answer so status never waits on the lookup. available is true only when a read can be sent now, and a relay with the plugin reachable never suggests logging in. Reads the bridge serves are no longer held to the metered pace (DEVUP_FIGMA_CALLS_PER_MINUTE, eight a minute by default), which made a second export on the same server wait most of a minute for an allowance it was not spending. --self-check and a server built in-process for tests no longer open the bridge. DEVUP_FIGMA_BRIDGE_PORT keeps its meaning and a single process behaves and answers as before.",
"date": "2026-09-26T16:22:15.990869Z"
}
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo install cargo-insta --version 1.48.0 --locked
- run: cargo fmt --all -- --check
- run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs
- run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs plugin/tests/withdraw.test.mjs
# `plugin/dist/` is committed so a user can import the plugin into Figma
# without running a build. That only stays true if the committed bundle
# is the one this source produces, so rebuild it and refuse a difference.
Expand Down
44 changes: 5 additions & 39 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ axum = "0.8"
base64 = "0.23"
cap-std = "4.0.3"
dunce = "1.0.5"
futures-util = "0.3"
hmac = "0.13"
keyring = "4.2"
insta = { version = "1.48.0", features = ["glob", "json"] }
image = { version = "=0.25.10", default-features = false, features = ["png"] }
Expand All @@ -40,6 +42,9 @@ sha2 = "0.11"
subtle = "2"
syn = { version = "2.0.119", features = ["full"] }
tokio = { version = "1", features = ["full"] }
# The version axum's `ws` feature already builds, so the bridge relay's client
# and the tests' stand-in plugin add no second WebSocket implementation.
tokio-tungstenite = "0.29"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
Expand Down
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ stdio pipe를 보유한 상태이므로 host의 MCP 연결을 재시작하거나

```bash
cargo fmt --all -- --check
node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs
node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs plugin/tests/withdraw.test.mjs
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo test -p devup-mcp --test stdio_smoke
Expand Down Expand Up @@ -189,8 +189,10 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다.
"paths": {
"bridge": {
"available": true,
"listening": true,
"role": "relay",
"listening": false,
"port": 1993,
"host": { "pid": 32536, "version": "0.12.0", "buildId": "f1962d5", "thisProcess": false },
"attachedFiles": [{
"fileKey": null,
"fileName": "Landing",
Expand All @@ -199,8 +201,8 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다.
"selectionCount": 1,
"fileKeyNote": "This plugin could not report its file key ..."
}],
"attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it. ...",
"reason": "A plugin is attached. ..."
"attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it - as the devup-mcp holding the bridge port sees them ...",
"reason": "Another devup-mcp on this machine (pid 32536, ...) holds the bridge port 1993 and the plugins attach to it; this process reads through it. ..."
},
"direct": {
"available": false,
Expand Down Expand Up @@ -235,11 +237,23 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다.

연결을 "인증"과 떼어 놓으려고 별도 도구(`devup_figma_connection` 등)를 두는 방안도 검토했지만 두지 않았습니다. 도구가 하나 늘면 모든 클라이언트가 매 세션 그 스키마를 싣고, 이름을 바꾸면 기존 호출이 깨집니다. 대신 `devup_figma_auth`의 설명과 `status`의 응답이 두 경로를 먼저 말합니다.

**경로는 둘이고 대등하지 않습니다.** `preferredPath`가 언제나 `bridge`인 이유입니다 — 브리지는 로그인도 필요 없고 Figma 한도도 쓰지 않습니다. `paths.bridge`의 세 상태는 고치는 방법이 서로 다르니 구분해서 읽어야 합니다.
**경로는 둘이고 대등하지 않습니다.** `preferredPath`가 언제나 `bridge`인 이유입니다 — 브리지는 로그인도 필요 없고 Figma 한도도 쓰지 않습니다.

- `listening: false` — 이 프로세스가 브리지 포트를 아예 잡지 못했습니다. `DEVUP_FIGMA_BRIDGE_PORT`가 `off`이거나, 다른 devup-mcp가 이미 그 포트를 쥐고 있는 경우입니다(MCP 클라이언트를 여러 개 띄우면 정상입니다). 플러그인을 아무리 실행해도 이 프로세스로는 오지 않습니다.
- `listening: true`, `attachedFiles: []` — 문은 열려 있는데 아무도 들어오지 않았습니다. 대상 파일에서 `Devup Bridge` 플러그인을 실행하세요.
- `attachedFiles`에 파일 키가 있음 — 정상 동작입니다. **이 상태면 로그인 없이 그 파일의 수집이 그대로 됩니다.**
한 기기에 devup-mcp가 여럿 떠 있어도(MCP 클라이언트·세션마다 하나씩 뜨는 것이 정상입니다) **모두가 같은 플러그인을 씁니다.** 플러그인이 붙는 포트를 잡은 프로세스가 호스트가 되고, 나머지는 호스트를 통해 읽습니다. `paths.bridge.role`이 이 프로세스가 무엇을 하는지 말하고, 상태마다 고치는 방법이 다르니 구분해서 읽어야 합니다.

| `role` | 뜻 | 할 일 |
|---|---|---|
| `host` | 이 프로세스가 포트를 쥐었고(`listening: true`) 플러그인이 여기에 붙습니다 | `attachedFiles`가 비어 있으면 대상 파일에서 `Devup Bridge` 플러그인을 실행하세요 |
| `relay` | 다른 devup-mcp가 쥔 포트를 통해 읽습니다. `host`가 그 프로세스의 pid·버전·buildId입니다 | 호스트와 같습니다. `attachedFiles`는 어느 프로세스에서 보든 같습니다 |
| `connecting` | 포트를 쥔 프로세스가 방금 떠나 이어받거나 다시 붙는 중입니다(`handoverFrom`이 떠난 프로세스) | 몇 초 뒤 `status`를 다시 부르세요. 플러그인은 2초마다 스스로 다시 붙습니다 |
| `unavailable` | 이 프로세스는 지금 브리지를 쓸 수 없습니다. `issue`가 이유입니다 | `reason`과 `nextAction`의 첫 항목이 고치는 방법입니다 |
| `off` | `DEVUP_FIGMA_BRIDGE_PORT`가 `off`/`0`이거나 포트 번호가 아닙니다 | 브리지를 쓰려면 그 값을 지우세요 |

`issue`는 다음 중 하나입니다 — `legacy-host`(이 기능 이전의 devup-mcp가 포트를 쥐었습니다. 그 클라이언트를 재시작·갱신하면 이 프로세스가 스스로 이어받습니다), `foreign-program`(devup-mcp가 아닌 프로그램), `incompatible-protocol`(중계 규약의 판이 다른 devup-mcp — 틀린 답을 내느니 잇지 않습니다), `authentication-failed`(같은 사용자의 devup-mcp임을 증명하지 못함), `secret-unavailable`, `bind-failed`. 어느 경우든 `status`는 몇 초 안에 답하고 기다리지 않습니다.

`legacy-host`와 `foreign-program`은 포트를 쥔 쪽이 자신을 밝히지 않으므로, 운영체제에 그 포트에서 듣는 프로세스를 물어 **pid·이름·실행 파일 경로**를 붙입니다 — 예전 devup-mcp면 `host`에(버전은 알 수 없어 `null`), 다른 프로그램이면 `holder`에 싣고 `reason`에도 적습니다. 실행 파일 경로를 보면 어느 클라이언트가 설치한 것인지 드러납니다. 묻는 데는 외부 명령(Windows `netstat`·`tasklist`·PowerShell, macOS `lsof`·`ps`, Linux `/proc`)이 들어서, `status`는 그것을 기다리지 않고 먼저 답한 뒤 알게 되는 대로 덧붙입니다. 포트를 쥔 프로그램을 이 사용자 권한으로 실행해 보지는 않습니다.

**`available: true`는 지금 실제로 읽기를 보낼 수 있을 때만입니다** — 플러그인이 보이고, 그것에 닿는 길(이 프로세스의 포트, 또는 포트를 쥔 프로세스와의 연결)이 있을 때. 이 상태면 로그인 없이 그 파일의 수집이 그대로 되고, 중계 프로세스에서도 로그인하라는 안내를 하지 않습니다.

`paths.direct`의 세 필드는 **서로 다른 것**을 말하므로 함께 읽어야 합니다.

Expand Down Expand Up @@ -373,7 +387,7 @@ devup-mcp가 Figma에 붙는 경로는 **둘**이고, 대등하지 않습니다.
| **브리지** (`bridge`) | 필요 없음 | **쓰지 않음** | **기본.** 데스크톱 앱에서 플러그인을 띄워 두면 그쪽으로 읽습니다 |
| 직접 (`direct`) | `devup_figma_auth { action: "login" }` | 씁니다 | 브리지가 못 하는 읽기와, 플러그인을 띄울 수 없는 환경(CI 등) |

**브리지를 먼저 쓰십시오.** direct는 Figma가 사용량을 세는 경로이고, 화면 하나가 여러 번의 읽기를 쓰므로 한도가 금방 바닥납니다.
**브리지를 먼저 쓰십시오.** direct는 Figma가 사용량을 세는 경로이고, 화면 하나가 여러 번의 읽기를 쓰므로 한도가 금방 바닥납니다. 그래서 direct로 가는 읽기는 한도에 맞춘 속도(`DEVUP_FIGMA_CALLS_PER_MINUTE`, 기본 분당 8회)로 늦춰 보내지만, 브리지로 가는 읽기는 늦추지 않습니다. 예전에는 브리지 읽기까지 같은 속도로 묶여, 같은 서버의 두 번째 export가 쓰지도 않는 한도를 1분 가까이 기다렸습니다.

**플러그인이 이 파일을 맡고 있으면 로그인을 요구하지 않습니다.** 예전에는 수집을 시작하기 전에 토큰부터 확인해서, 한도를 아끼려고 플러그인을 띄운 사람에게 "먼저 한도 쓰는 경로를 여세요"라고 거절했습니다. 지금은 브리지를 먼저 보고, 이 파일을 맡은 플러그인이 없을 때만 로그인을 요구합니다. 수집 도중 브리지가 못 하는 읽기가 있으면 **그 읽기가** 자기 이유로 거절하므로, 무엇이 왜 막혔는지가 그대로 드러납니다.

Expand Down Expand Up @@ -457,7 +471,10 @@ Figma에서 대상 파일을 열고 `Devup Bridge`를 실행하면 창이 하나

#### 알아 두어야 할 것

- **기본으로 켜져 있습니다.** devup-mcp는 시작할 때 `127.0.0.1:1993`에 대기하고, 그 포트를 잡지 못하면 조용히 원격 경로만 씁니다. 끄려면 `DEVUP_FIGMA_BRIDGE_PORT=off`.
- **기본으로 켜져 있습니다.** devup-mcp는 시작할 때 `127.0.0.1:1993`에 대기합니다. 그 포트를 다른 devup-mcp가 이미 쥐고 있으면 그 프로세스를 통해 같은 플러그인을 읽고, 그 프로세스가 끝나면 남은 devup-mcp 가운데 하나가 포트를 이어받습니다(플러그인은 2초 안에 새 호스트에 다시 붙습니다). 끄려면 `DEVUP_FIGMA_BRIDGE_PORT=off`.
- **포트가 넘어가도 수집은 이어집니다.** 그때 진행 중이던 읽기는 실패로 끝나지 않고, 플러그인이 새 호스트에 다시 붙는 대로 다시 보냅니다(읽기는 문서를 바꾸지 않아 두 번 돌아도 해가 없습니다). 플러그인이 10초 안에 돌아오지 않으면 그때 실패합니다. 요청한 쪽이 떠나거나 시간이 다 된 읽기는 플러그인에 취소를 보내, 차례를 기다리던 작업은 돌리지 않습니다.
- **여러 devup-mcp가 나눠 쓰는 것은 같은 사용자의 것끼리입니다.** 중계는 그 사용자만 읽을 수 있는 비밀값으로 서로를 증명해야 이어지고, 비밀값 자체는 연결로 오가지 않습니다. 비밀값은 Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, macOS·Linux `/tmp/devup-mcp-<uid>/bridge-relay.key`에 있습니다. Unix에서 환경 변수(`HOME`)가 아니라 사용자 번호로 자리를 정하는 것은 MCP 클라이언트마다 서버에 넘기는 `HOME`이 달라(샌드박스로 바꿔 띄우는 클라이언트도 있습니다) 같은 사용자의 프로세스가 서로를 알아보지 못했기 때문입니다. `/tmp`는 누구나 쓰는 곳이라, 그 디렉터리가 이 사용자의 것이고 다른 사용자가 들어올 수 없는지 쓸 때마다 확인합니다 — 열려 있었으면 닫고 비밀값을 새로 만들며, 다른 사용자의 것이거나 링크면 쓰지 않습니다(그때는 `secret-unavailable`). 재부팅하면 지워지지만 그때는 그 비밀값을 알던 프로세스도 모두 끝난 뒤입니다.
- **브라우저 페이지는 붙지 못합니다.** 중계 문은 `Origin`이 붙은 요청을 모두 거절하고, 플러그인 문(`/plugin`)은 Figma 플러그인 창(`Origin: null`)과 `figma.com`만 받습니다. 흔한 웹 페이지가 플러그인인 척 읽기를 가로채지 못하게 하려는 것인데, 샌드박스 iframe이나 `file://` 페이지도 `null`을 실으므로 그런 페이지까지 막지는 못합니다.
- **읽기 전용입니다.** 플러그인이 실행하는 스크립트는 **빌드 시점에 플러그인 안에 박혀 있고**, devup-mcp는 그중 어느 것을 실행할지 **이름만** 보냅니다. 소켓으로 코드가 오가지 않으며 Figma 문서를 바꾸는 호출은 존재하지 않습니다.
- **이 기기에서만 됩니다.** 대기 주소는 `127.0.0.1`이라 다른 기기에서는 붙을 수 없고, 원격 CI에서는 브리지가 없으니 자동으로 원격 경로를 씁니다.
- **파일은 한 번에 하나입니다.** 플러그인이 자기 파일 키를 보고하지 못하는 경우가 있어(Dev Mode 등), 키 없는 플러그인은 **혼자 붙어 있을 때만** 읽기를 받습니다. 두 개 이상이면 어느 파일인지 알 수 없으므로 원격 경로로 넘어갑니다.
Expand Down
11 changes: 7 additions & 4 deletions crates/devup-mcp-figma/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ async-trait.workspace = true
# `ws` 는 기본 기능이 아니다. 브리지가 플러그인을 받는 데 쓴다.
axum = { workspace = true, features = ["ws"] }
base64.workspace = true
# 중계 연결의 WebSocket 클라이언트가 스트림으로 읽고 쓴다.
futures-util.workspace = true
# 중계 연결의 증명(HMAC-SHA256). sha2 와 같은 digest 세대다.
hmac.workspace = true
image.workspace = true
keyring.workspace = true
rand.workspace = true
Expand All @@ -23,14 +27,13 @@ serde_json.workspace = true
sha2.workspace = true
subtle.workspace = true
tokio.workspace = true
# 포트를 잡지 못한 프로세스가 잡은 프로세스에 붙는 클라이언트. 테스트의 가짜
# 플러그인도 이것을 쓴다.
tokio-tungstenite.workspace = true
url.workspace = true
unicode-normalization.workspace = true
webbrowser.workspace = true

[dev-dependencies]
anyhow.workspace = true
tempfile = "3"
# 브리지 서버에 붙는 가짜 플러그인 역할. axum 의 `ws` 가 이미 끌어오는 것과 같은
# 구현이라 의존성 트리가 늘지 않는다.
tokio-tungstenite = "0.28"
futures-util = "0.3"
Loading
Loading