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
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_bridge_before_login.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"crates/devup-mcp-figma/Cargo.toml": "Minor",
"crates/devup-mcp/Cargo.toml": "Minor"
},
"note": "The bridge is now tried before a login is demanded, and `doctor` reports it as a path. A collection asked the OAuth backend for a token before it asked anything whether a token was needed, and refused outright when there wasn't one. That inverted the two paths. The bridge spends no Figma allowance and needs no credential of any kind, so it is the path to reach for; direct is metered and a single screen costs several reads. Whoever had attached the plugin - precisely to stay off the metered path - was told to go and authorize the metered path first, and the export never started, on a file every read of which the plugin was sitting there ready to serve. After the previous change moved node-scoped metadata onto the bridge, nothing in a tsx export needs the remote path at all, so that refusal stood between an attached plugin and a finished screen for no remaining reason. `FigmaUpstream::serves_without_credentials` answers per file key rather than per process, because a plugin holding another file open says nothing about this one, and `FallbackUpstream` delegates it to the bridge. The login is now required only when no plugin is holding this file. A read inside the collection that the bridge genuinely cannot serve - a file-scope metadata read, or referencePng's get_screenshot - still refuses on its own, where the reason belongs to that read instead of condemning the whole export before it starts. Separately, `devup_figma_auth doctor` knew only about `direct`, which made the only instrument for diagnosing the connection one that could give exactly one answer - run login - to every question, including the ones whose real answer was 'run the plugin' or 'the listener never bound'. It now reports `preferredPath: bridge` and a `paths.bridge` block separating the three states that need three different repairs: not listening (the port was taken or switched off), listening with nothing attached (run the plugin, and the port is named), and attached (with the file keys, because a plugin open on the wrong file looks identical from the outside). Measured against the release binary with no token stored at all: a plugin attached over the real socket, doctor reporting available, and an export that reached the read instead of DEVUP_AUTH_REQUIRED.",
"date": "2026-09-14T16:00:00+09:00"
}
40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,17 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다.
```json
{
"status": "disconnected",
"preferredPath": "bridge",
"preferredPathNote": "Two paths reach Figma and they are not equals. ...",
"paths": {
"bridge": {
"available": false,
"listening": true,
"port": 1993,
"attachedFiles": [],
"attachedFilesNote": "File keys the attached plugins have open. ...",
"reason": "The bridge is listening on 127.0.0.1:1993 but no plugin is attached. ..."
},
"direct": {
"available": false,
"credentialSource": "none",
Expand All @@ -162,7 +172,15 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다.
}
```

`doctor`는 네트워크 호출을 전혀 하지 않습니다. 세 필드는 **서로 다른 것**을 말하므로 함께 읽어야 합니다.
`doctor`는 네트워크 호출을 전혀 하지 않습니다.

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

- `listening: false` — 이 프로세스가 브리지 포트를 아예 잡지 못했습니다. `DEVUP_FIGMA_BRIDGE_PORT`가 `off`이거나, 다른 devup-mcp가 이미 그 포트를 쥐고 있는 경우입니다(MCP 클라이언트를 여러 개 띄우면 정상입니다). 플러그인을 아무리 실행해도 이 프로세스로는 오지 않습니다.
- `listening: true`, `attachedFiles: []` — 문은 열려 있는데 아무도 들어오지 않았습니다. 대상 파일에서 `Devup Bridge` 플러그인을 실행하세요.
- `attachedFiles`에 파일 키가 있음 — 정상 동작입니다. **이 상태면 로그인 없이 그 파일의 수집이 그대로 됩니다.**

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

- `credentialSource` — **client 등록 자격증명**(`client_id`/`client_secret`)이 어디서 왔는지. `cli-arg`, `env`, `credential-store`, `none` 중 하나입니다.
- `tokenState` — **사용자의 access token** 상태. `valid`, `expired`, `absent` 중 하나입니다.
Expand All @@ -184,12 +202,22 @@ Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급

## Figma 연결 설정

devup-mcp가 Figma에 붙는 경로는 하나입니다 — **원격 OAuth (`direct`)**. `devup_figma_auth { action: "login" }`으로 브라우저 인증. Figma MCP Catalog에 승인된 client만 등록할 수 있습니다.
현재 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요.
devup-mcp가 Figma에 붙는 경로는 **둘**이고, 대등하지 않습니다.

### 브리지 플러그인 — 한도를 쓰지 않고 읽기
| 경로 | 로그인 | Figma 한도 | 언제 쓰나 |
|---|---|---|---|
| **브리지** (`bridge`) | 필요 없음 | **쓰지 않음** | **기본.** 데스크톱 앱에서 플러그인을 띄워 두면 그쪽으로 읽습니다 |
| 직접 (`direct`) | `devup_figma_auth { action: "login" }` | 씁니다 | 브리지가 못 하는 읽기와, 플러그인을 띄울 수 없는 환경(CI 등) |

원격 OAuth 경로는 Figma가 **사용량을 셉니다.** devup-mcp의 수집은 화면 하나에 snapshot을 여러 번 부르므로 한도가 금방 바닥납니다.
**브리지를 먼저 쓰십시오.** direct는 Figma가 사용량을 세는 경로이고, 화면 하나가 여러 번의 읽기를 쓰므로 한도가 금방 바닥납니다.

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

지금 어느 경로가 살아 있는지는 `devup_figma_auth { action: "doctor" }`의 `paths.bridge`/`paths.direct`로 확인하세요.

현재 브리지가 **못** 하는 읽기는 둘입니다 — `scope: "file"`의 노드 없는 metadata 읽기(최상위 페이지 목록이라 계약이 다릅니다)와 `referencePng`의 `get_screenshot`. 그 밖의 tsx export 경로는 전부 브리지로 갑니다.

### 브리지 플러그인 — 한도를 쓰지 않고 읽기

그래서 **우리가 직접 만든 Figma 플러그인**을 통해 같은 읽기를 할 수 있습니다. 이 경로는 한도를 쓰지 않습니다. 플러그인이 붙어 있으면 스크립트 읽기가 그쪽으로 가고, **안 붙어 있으면 아무 일도 일어나지 않고 그대로 원격 경로로** 갑니다. 설치하지 않은 사람의 동작은 바뀌지 않습니다.

Expand Down Expand Up @@ -548,7 +576,7 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히

탐색과 검색은 변수 catalog를 수집하지 않습니다. 정확한 UI 변환 단계에서 선택 subtree의 모든 보존 필드에 있는 `VARIABLE_ALIAS`와 paint/text/effect/grid style ID를 재귀적으로 스캔하고, 실제 사용된 ID만 공식 Figma API로 조회합니다. `outputs: ["devupJson"]`에 `scope: "file"`을 함께 준 경우에만 file 전체 로컬 catalog를 수집합니다.

Figma 연결은 direct 하나뿐입니다. `sourcePolicy` 파라미터는 `auto`와 `direct` 둘 다 같은 동작이었으므로 제거했습니다 — 분기하지 않는 선택지는 호출자에게 틀릴 기회만 주었습니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다.
`sourcePolicy` 파라미터는 `auto`와 `direct` 둘 다 같은 동작이었으므로 제거했습니다 — 분기하지 않는 선택지는 호출자에게 틀릴 기회만 주었습니다. 경로 선택은 파라미터가 아니라 **플러그인이 붙어 있는지**가 정합니다: 붙어 있으면 브리지, 아니면 direct입니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다.

정확한 node 링크의 UI 변환은 하나 이상의 공식 `use_figma` 호출 안에서 subtree와 실제 사용 리소스를 수집합니다. 수집 스크립트는 checked-in manifest(devup-ui 변환기가 실제로 읽는 필드만)만 확인하고 — 프로토타입 체인 전체를 훑거나 미분류 필드를 `extra`에 담지 않습니다 — `null`/빈 배열/미바인딩 style ID 같은 기본값은 봉투에서 생략합니다. 결과는 항상 텍스트(`devupFastSnapshotEnvelope`)이며 PNG 같은 바이너리 transport는 없습니다. 한 subtree가 15KB 텍스트 한도를 넘으면 같은 스크립트를 `offset`을 옮겨 다시 호출하는 방식으로 텍스트 페이지네이션합니다 — 각 라운드는 그 라운드가 보낸 node에서만 리소스를 스캔해 자기 완결적이며, Rust가 여러 라운드의 node와 리소스를 병합합니다. Rust는 schema·대상 ID·node graph·리소스 참조·(페이지 중이 아닐 때의) 자식 완전성을 모두 검증한 뒤에만 결과를 채택합니다. 한 항목이라도 불일치하면 fast 결과 전체를 버리고 기존 cursor 수집을 0부터 재시작합니다. Section multi-root에서는 성공한 root와 resource는 그대로 보존하고 실패하거나 상한을 넘은 root만 legacy로 다시 수집한 뒤 원래 시각 순서로 합칩니다. direct upstream은 연결과 read-only tool catalog를 한 session에서 재사용하고 30초 TTL, 연결 종료 또는 transport 오류 때만 재연결·재검증합니다. 결과의 `stats`에는 `figmaToolCalls`, `transport`(`text` | `text-paginated` | `legacy-cursor`), `fallbackUsed`, node/variable/style 수와 byte 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다.

Expand Down
47 changes: 46 additions & 1 deletion crates/devup-mcp-figma/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,15 +418,39 @@ pub trait PreferredUpstream: FigmaUpstream {
fn is_live(&self) -> bool;
}

/// 브리지 경로의 실측 상태. `devup_figma_auth doctor` 의 `paths.bridge` 가 된다.
///
/// 이 값이 있다는 것 자체가 "이 프로세스가 브리지를 열었다"는 뜻이다. 포트를
/// 잡지 못했거나 `DEVUP_FIGMA_BRIDGE_PORT=off` 면 브리지 상류가 아예 만들어지지
/// 않으므로 `None` 이 된다.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BridgePathSnapshot {
/// 실제로 잡은 포트. 플러그인 manifest 의 `allowedDomains` 와 같아야 붙는다.
pub port: Option<u16>,
/// 지금 붙어 있는 플러그인이 열어 둔 파일 키. 빈 문자열은 자기 파일 키를
/// 보고하지 못한 플러그인이며, 혼자 붙어 있을 때만 읽기를 받는다.
pub attached_files: Vec<String>,
}

/// 플러그인을 통해 Figma 를 읽는 `FigmaUpstream`.
#[derive(Clone)]
pub struct BridgeFigmaClient {
state: BridgeState,
/// 진단에만 쓴다. 읽기 경로는 포트를 알 필요가 없다.
port: Option<u16>,
}

impl BridgeFigmaClient {
pub fn new(state: BridgeState) -> Self {
Self { state }
Self { state, port: None }
}

/// 잡은 포트를 함께 들고 있게 한다. "문이 어디에 열려 있는가"는 붙지 않는
/// 플러그인을 진단할 때 가장 먼저 확인할 값이다.
#[must_use]
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
}

Expand Down Expand Up @@ -456,6 +480,17 @@ impl FigmaUpstream for BridgeFigmaClient {
fn batch_budget(&self) -> BatchBudget {
bridge_batch_budget()
}

async fn serves_without_credentials(&self, file_key: &str) -> bool {
self.state.has_plugin(file_key).await
}

async fn bridge_path_snapshot(&self) -> Option<BridgePathSnapshot> {
Some(BridgePathSnapshot {
port: self.port,
attached_files: self.state.connected_files().await,
})
}
}

#[async_trait]
Expand Down Expand Up @@ -547,4 +582,14 @@ where
self.secondary.batch_budget()
}
}

/// 이 파일을 맡은 플러그인이 있으면 원격 자격증명 없이도 수집이 성립한다.
/// 뒤엣것은 원격이므로 물을 것이 없다.
async fn serves_without_credentials(&self, file_key: &str) -> bool {
self.preferred.serves_without_credentials(file_key).await
}

async fn bridge_path_snapshot(&self) -> Option<BridgePathSnapshot> {
self.preferred.bridge_path_snapshot().await
}
}
4 changes: 2 additions & 2 deletions crates/devup-mcp-figma/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ pub use collector::{
};

pub use bridge::{
BridgeFigmaClient, BridgeJob, BridgeServer, BridgeState, DEFAULT_BRIDGE_PORT, FallbackUpstream,
PreferredUpstream,
BridgeFigmaClient, BridgeJob, BridgePathSnapshot, BridgeServer, BridgeState,
DEFAULT_BRIDGE_PORT, FallbackUpstream, PreferredUpstream,
};
pub use credentials::{
ClientCredentialStore, ClientCredentials, CredentialStore, KeyringClientCredentialStore,
Expand Down
18 changes: 18 additions & 0 deletions crates/devup-mcp-figma/src/upstream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,24 @@ pub trait FigmaUpstream: Send + Sync {
fn batch_budget(&self) -> BatchBudget {
BatchBudget::default()
}

/// 이 파일을 Figma 자격증명 없이 읽을 수 있는지 — 즉 이 파일을 열어 둔 브리지
/// 플러그인이 붙어 있는지.
///
/// 로그인을 요구하기 전에 물어야 하는 값이다. 한도를 쓰지 않으려고 플러그인을
/// 띄운 사람에게 한도를 쓰는 경로부터 열라고 시키는 것은 순서가 거꾸로다.
/// 원격만 아는 상류는 기본값 `false` 를 그대로 쓴다.
async fn serves_without_credentials(&self, _file_key: &str) -> bool {
false
}

/// 브리지 경로의 실측 상태. 브리지를 열지 않은 상류는 `None` 이다.
///
/// 진단 전용이다 — `devup_figma_auth doctor` 가 direct 말고도 경로가 있다는
/// 것을 말할 수 있어야 한다.
async fn bridge_path_snapshot(&self) -> Option<crate::bridge::BridgePathSnapshot> {
None
}
}

#[derive(Clone)]
Expand Down
17 changes: 17 additions & 0 deletions crates/devup-mcp-figma/tests/bridge_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,23 @@ async fn routing_is_decided_before_the_call() {
);
}

/// 로그인을 요구할지 말지는 이 값이 정한다.
///
/// 브리지는 Figma 한도도 자격증명도 쓰지 않으므로, 이 파일을 맡은 플러그인이
/// 있으면 수집은 토큰 없이 성립한다. 판정은 파일 단위여야 한다 — 다른 파일을
/// 열어 둔 플러그인이 붙어 있다고 해서 이 파일을 읽을 수 있는 것은 아니다.
#[tokio::test]
async fn a_plugin_holding_the_file_makes_it_readable_without_credentials() {
let server = BridgeServer::start(0).expect("an ephemeral port is free");
let client = BridgeFigmaClient::new(server.state());

assert!(!client.serves_without_credentials(FILE_KEY).await);

let _plugin = connect_plugin(&server).await;
assert!(client.serves_without_credentials(FILE_KEY).await);
assert!(!client.serves_without_credentials("OtherFile").await);
}

/// 키 없이 붙은 플러그인도 혼자면 맡는다.
///
/// `figma.fileKey` 는 늘 오는 값이 아니다. 실기기에서 비어 온 적이 있고, 그때 등록을
Expand Down
Loading