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
5 changes: 5 additions & 0 deletions .changepacks/changepack_log_original_image_bytes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"changes": { "crates/devup-mcp-figma/Cargo.toml": "Minor" },
"note": "Original image bytes can now be read, and two screens are explained rather than improved. A CROP fill names its source by hash, but nothing could fetch the pixels behind that name: the official MCP returns text only, so the bytes had no transport, and the connector tool that would supply them answers UNAUTHORIZED and belongs to the metered path in any case. The bridge has no such limit - it is a local socket where nothing is truncated - so a read-only getImageByHash route now retrieves the selected fill's original bytes with their intrinsic codec, never a whole-node rendition, and decodes and verifies them against the declared hash. The remote path refuses the request outright with a named reason rather than returning a node PNG that looks like an answer; that refusal is the more valuable half, because a silently wrong image is worse than a missing one. No document is mutated: the temporary-rectangle technique that would have produced an isolated rendition creates and removes nodes and was rejected for that reason. The isolated-fill renderer that would consume these bytes is not implemented and no pixel improvement is claimed, because capturing live source bytes requires re-importing the rebuilt plugin in Figma, which is a desktop menu action; the report names the exact capture - file, node, fill index, hash and call - so it needs no rederiving. Separately, the landing mobile residual is quantified rather than fixed: with identical DOM geometry, a Chromium probe with LCD subpixel antialiasing disabled measures 2.91 percent where the harness measures 4.78, which attributes a large part of that screen's divergence to font rasterization rather than to generated layout, and leaves residual glyph advances needing separate evidence.",
"date": "2026-09-14T06:00:00+09:00"
}
57 changes: 57 additions & 0 deletions crates/devup-mcp-figma/examples/original_image_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Capture an original upload through the updated, read-only bridge plugin.
//! Run with: FILE_KEY NODE_ID FILL_INDEX IMAGE_HASH OUTPUT_PATH.
//! The selected bridge port must be free and match the plugin configuration.

use std::{io::Write, time::Duration};

use devup_mcp_figma::{
AssetRequest, BridgeFigmaClient, BridgeServer, FigmaUpstream, ReadToolCall,
original_image_from_result,
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
anyhow::ensure!(
args.len() == 5,
"usage: original_image_probe FILE_KEY NODE_ID FILL_INDEX IMAGE_HASH OUTPUT_PATH"
);
let fill_index = args[2].parse::<usize>()?;
let server = BridgeServer::from_env().ok_or_else(|| anyhow::anyhow!(
"bridge port unavailable or disabled; stop its current owner or configure the same free port in both plugin and DEVUP_FIGMA_BRIDGE_PORT"
))?;
eprintln!(
"Waiting up to 60 seconds for the updated plugin on port {}",
server.port()
);
let state = server.state();
tokio::time::timeout(Duration::from_secs(60), async {
while !state.has_plugin(&args[0]).await {
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.map_err(|_| anyhow::anyhow!("updated bridge plugin did not connect"))?;
let request = AssetRequest::original_image(&args[1], fill_index, &args[3]);
let response = BridgeFigmaClient::new(state)
.call_read_tool(ReadToolCall::asset_export(&args[0], None, request.clone()))
.await?;
let original = original_image_from_result(&response, &args[0], None, &request)?;
// Refuse to overwrite an earlier capture; its byte identity is evidence.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[4])?;
file.write_all(&original.bytes)?;
println!(
"{}",
serde_json::json!({
"representation":"original-image-v1", "fileKey":args[0], "nodeId":args[1],
"fillIndex":fill_index, "imageHash":args[3], "version":null,
"mimeType":original.mime_type, "width":original.width, "height":original.height,
"byteLength":original.bytes.len(), "sha256":original.sha256, "outputPath":args[4],
"note":"Original upload only; no isolated-fill or document-version parity is claimed."
})
);
Ok(())
}
2 changes: 2 additions & 0 deletions crates/devup-mcp-figma/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,11 @@ pub use upstream::{
pub use url::FigmaTarget;
pub use variables::{ResourceBatch, ResourceStyleRef, UnresolvedResource};
mod metadata;
mod original_image;
pub use assets::{
AssetExportOutcome, AssetFormat, AssetManifest, AssetManifestEntry, AssetRequest,
AssetSelection, AssetStatus, MAX_ASSET_BYTES, PNG_EXPORT_FIELD, SVG_EXPORT_FIELD,
asset_exclusion_reason, asset_export_from_result, discover_asset_manifest,
exported_asset_from_bytes, resolve_asset_selections, source_kind_of, validate_asset_requests,
};
pub use original_image::{OriginalImage, original_image_from_result};
175 changes: 175 additions & 0 deletions crates/devup-mcp-figma/src/original_image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! Original uploads, carried only by the local bridge. These bytes have no
//! paint transforms, filters, opacity, clipping or child composition applied.
//! They must never be decoded as an AssetManifestEntry's node rendition.

use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};

use crate::{AssetFormat, AssetRequest, DevupError, ErrorCode, MAX_ASSET_BYTES, UpstreamResult};

const FIELD: &str = "$original-image/fills/";

impl AssetRequest {
/// A distinct bridge-only read of the upload used by this exact paint.
/// Format/scale are placeholders required by the shared request envelope;
/// the original response explicitly has neither a requested codec nor scale.
/// Use `original_image_from_result`, never `asset_export_from_result`.
pub fn original_image(node_id: &str, fill_index: usize, image_hash: &str) -> Self {
Self {
asset_id: format!("{node_id}:original-image-v1:{fill_index}:{image_hash}"),
node_id: node_id.to_owned(),
field: format!("{FIELD}{fill_index}"),
image_hash: Some(image_hash.to_owned()),
format: AssetFormat::Png,
scale: 1,
}
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OriginalImage {
pub bytes: Vec<u8>,
pub mime_type: String,
/// Intrinsic dimensions reported by Image.getSizeAsync, not a node box.
pub width: u32,
pub height: u32,
pub sha256: String,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Descriptor {
representation: String,
file_key: String,
version: Option<String>,
asset_id: String,
node_id: String,
field: String,
image_hash: String,
status: String,
mime_type: String,
width: u32,
height: u32,
byte_length: usize,
sha256: String,
data: String,
}

fn find(value: &Value) -> Option<Value> {
if matches!(
value["kind"].as_str(),
Some("devupOriginalImage" | "devupAssetExport")
) {
return Some(value.clone());
}
match value {
Value::Object(object) => object.values().find_map(find),
Value::Array(values) => values.iter().find_map(find),
Value::String(text) => serde_json::from_str::<Value>(text)
.ok()
.and_then(|v| find(&v)),
_ => None,
}
}

fn invalid(message: &str) -> DevupError {
DevupError::new(ErrorCode::DevupSnapshotUnsupported, message, false)
}

/// Validate identity, byte count, SHA-256 and codec before returning original
/// bytes. A remote refusal and an old plugin's unsupported-field response stay
/// named diagnostics, rather than being mistaken for successful PNG exports.
pub fn original_image_from_result(
result: &UpstreamResult,
file_key: &str,
version: Option<&str>,
request: &AssetRequest,
) -> Result<OriginalImage, DevupError> {
if request
.field
.strip_prefix(FIELD)
.and_then(|s| s.parse::<usize>().ok())
.is_none()
{
return Err(invalid(
"original image read requires an explicit original-image field",
));
}
let value = find(&result.raw).ok_or_else(|| invalid("original image descriptor missing"))?;
if value["status"] == "failed" {
let code = value["errorCode"]
.as_str()
.unwrap_or("DEVUP_ORIGINAL_IMAGE_READ_FAILED");
return Err(DevupError::with_details(
ErrorCode::DevupSnapshotUnsupported,
format!(
"{code}: original image bytes were not delivered; the bridge requires the updated plugin bundle"
),
false,
json!({"errorCode":code}),
));
}
if value["kind"] != "devupOriginalImage"
|| !value["format"].is_null()
|| !value["scale"].is_null()
{
return Err(invalid(
"a node rendition cannot substitute for original image bytes",
));
}
let descriptor: Descriptor =
serde_json::from_value(value).map_err(|_| invalid("invalid original image descriptor"))?;
if descriptor.representation != "original-image-v1"
|| descriptor.status != "exported"
|| descriptor.file_key != file_key
|| descriptor.version.as_deref() != version
|| descriptor.node_id != request.node_id
|| descriptor.asset_id != request.asset_id
|| descriptor.field != request.field
|| Some(&descriptor.image_hash) != request.image_hash.as_ref()
|| descriptor.width == 0
|| descriptor.height == 0
|| descriptor.byte_length == 0
|| descriptor.byte_length > MAX_ASSET_BYTES
|| descriptor.data.len() > MAX_ASSET_BYTES.div_ceil(3) * 4
{
return Err(invalid(
"original image identity, dimensions or byte count does not match",
));
}
let bytes = STANDARD
.decode(descriptor.data)
.map_err(|_| invalid("invalid original image base64"))?;
let sha256: String = Sha256::digest(&bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if bytes.starts_with(&[255, 216, 255]) {
"image/jpeg"
} else if bytes.starts_with(b"GIF8") {
"image/gif"
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
"image/webp"
} else {
return Err(invalid("unsupported original image codec"));
};
if bytes.len() != descriptor.byte_length
|| sha256 != descriptor.sha256
|| mime != descriptor.mime_type
{
return Err(invalid(
"original image bytes fail length, SHA-256 or MIME validation",
));
}
Ok(OriginalImage {
bytes,
mime_type: descriptor.mime_type,
width: descriptor.width,
height: descriptor.height,
sha256,
})
}
39 changes: 35 additions & 4 deletions crates/devup-mcp-figma/src/scripts/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,21 @@ function failed(errorCode) {
};
}

const original = typeof options.field === "string" && options.field.startsWith("$original-image/fills/");
try {
// Original uploads are a different representation from node renditions.
// Only the local bridge opts in: the remote MCP's bounded text envelope
// cannot carry arbitrary original bytes, and its file writer is not a
// general binary transport. Never silently return a node PNG instead.
if (original && options.transport !== "bridge") {
return failed("DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE");
}
const node = await figma.getNodeByIdAsync(options.nodeId);
if (!node || typeof node.exportAsync !== "function") {
if (!node || (!original && typeof node.exportAsync !== "function")) {
return failed("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM");
}
if (typeof options.field === "string" && options.field.startsWith("fills/")) {
const index = Number(options.field.slice("fills/".length));
if (original || (typeof options.field === "string" && options.field.startsWith("fills/"))) {
const index = Number(options.field.slice(original ? "$original-image/fills/".length : "fills/".length));
const fills = "fills" in node && Array.isArray(node.fills) ? node.fills : [];
const paint = Number.isInteger(index) ? fills[index] : null;
const imageHash = paint && paint.type === "IMAGE" ? paint.imageHash || paint.imageRef : null;
Expand All @@ -36,6 +44,29 @@ try {
return failed("DEVUP_ASSET_FIELD_UNSUPPORTED");
}

if (original) {
const image = figma.getImageByHash(options.imageHash);
if (!image) return failed("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");
const bytes = await image.getBytesAsync();
if (bytes.length === 0 || bytes.length > 8 * 1024 * 1024) {
return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE");
}
const starts = values => values.every((value, index) => bytes[index] === value);
const mimeType = starts([137,80,78,71,13,10,26,10]) ? "image/png"
: starts([255,216,255]) ? "image/jpeg"
: starts([71,73,70,56]) ? "image/gif"
: starts([82,73,70,70]) && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80 ? "image/webp"
: null;
if (!mimeType) return failed("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");
const size = await image.getSizeAsync();
return {
...failed(null), status: "exported", kind: "devupOriginalImage",
representation: "original-image-v1", format: null, scale: null,
mimeType, width: size.width, height: size.height,
byteLength: bytes.length, sha256: devupSha256(bytes), data: figma.base64Encode(bytes),
};
}

const format = String(options.format || "").toUpperCase();
if (!["PNG", "JPG", "SVG", "PDF"].includes(format)) {
return failed("DEVUP_ASSET_FORMAT_UNSUPPORTED");
Expand Down Expand Up @@ -124,5 +155,5 @@ try {
errorCode: null,
};
} catch (_) {
return failed("DEVUP_ASSET_EXPORT_FAILED");
return failed(original ? "DEVUP_ORIGINAL_IMAGE_READ_FAILED" : "DEVUP_ASSET_EXPORT_FAILED");
}
1 change: 1 addition & 0 deletions crates/devup-mcp-figma/src/upstream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,7 @@ impl ReadToolCall {
params: json!({
"nodeId": request.node_id,
"asset": {
"transport": "bridge",
"assetId": request.asset_id,
"nodeId": request.node_id,
"field": request.field,
Expand Down
42 changes: 42 additions & 0 deletions crates/devup-mcp-figma/tests/bridge_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,45 @@ async fn two_keyless_plugins_are_ambiguous_and_neither_serves() {
.await
);
}

#[tokio::test]
async fn original_upload_crosses_the_socket_without_remote_text_truncation() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use devup_mcp_figma::{AssetRequest, original_image_from_result};
use sha2::{Digest, Sha256};

let server = BridgeServer::start(0).unwrap();
let mut plugin = connect_plugin(&server).await;
let client = BridgeFigmaClient::new(server.state());
let request = AssetRequest::original_image("n", 2, "hash");
let call = ReadToolCall::asset_export(FILE_KEY, Some("v1".into()), request.clone());
let reading = tokio::spawn(async move { client.call_read_tool(call).await });
let job = next_job(&mut plugin).await;
assert_eq!(job["script"], "assets");
assert_eq!(job["params"]["asset"]["transport"], "bridge");
assert_eq!(job["params"]["asset"]["field"], "$original-image/fills/2");
let mut bytes = vec![31u8; 1_100_000];
bytes[..3].copy_from_slice(&[255, 216, 255]);
let hash: String = Sha256::digest(&bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
plugin
.send(Message::Text(
json!({
"kind":"devup-result", "requestId":job["requestId"],
"data":{"kind":"devupOriginalImage", "representation":"original-image-v1",
"fileKey":FILE_KEY, "version":"v1", "nodeId":"n", "assetId":request.asset_id,
"field":request.field, "imageHash":"hash", "status":"exported",
"format":null, "scale":null, "mimeType":"image/jpeg", "width":100, "height":100,
"byteLength":bytes.len(), "sha256":hash, "data":STANDARD.encode(&bytes)}
})
.to_string()
.into(),
))
.await
.unwrap();
let result = reading.await.unwrap().unwrap();
let original = original_image_from_result(&result, FILE_KEY, Some("v1"), &request).unwrap();
assert_eq!(original.bytes, bytes);
}
Loading