mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
crates/mcp (lightcraft-mcp, L5): hand-written MCP over stdio (JSON-RPC 2.0, protocol 2025-06-18 with 2025-03-26/2024-11-05 negotiation): initialize, ping, tools/list, tools/call, resources/list/read. Two backends behind one trait: Headless (in-process engine Session; answers engine.execute, engine.commands, ui.render, app.export) and Remote (the desktop app's loopback JSON-lines control channel; lazy connect + reconnect). Tools: helpers (list_commands, run_command, import, query_photos, select_photos, list_controls, get/set_develop, apply_preset, crop, render_photo -> image content, export), UI tools when connected (screenshot -> image content, inspect_ui, set_ui, list_widgets, click, press_key, type_text, pointer_gesture), plus one cmd_<id> tool generated per command in the registry (--compact omits them). apps/lightcraft-cli: `mcp [--connect [ADDR]] [--demo] [--compact] [FILES]`, `render <in> -o <out> [--set control=value]... [--settings] [--preset] [--size] [--quality]`, `commands`, `controls`. Acceptance e2e: crates/mcp/tests/e2e.rs sets exposure over MCP and renders (headless and through the TCP transport); apps/lightcraft-cli/tests/cli.rs does the same through the real binary's stdio. Docs: docs/mcp.md, docs/control-protocol.md. Also rustfmt of one line in pipeline/finish.rs (main was not fmt-clean). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 lines
2.2 KiB
Rust
61 lines
2.2 KiB
Rust
//! Standard base64 (RFC 4648, padded) for MCP image content.
|
|
|
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
/// Encode bytes as padded standard base64.
|
|
pub fn base64_encode(bytes: &[u8]) -> String {
|
|
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
|
for chunk in bytes.chunks(3) {
|
|
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
|
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
|
|
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
|
|
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
|
|
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6) as usize & 63] as char } else { '=' });
|
|
out.push(if chunk.len() > 2 { ALPHABET[n as usize & 63] as char } else { '=' });
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Decode padded or unpadded standard base64 (whitespace ignored). `None` on invalid input.
|
|
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
|
let mut out = Vec::with_capacity(text.len() / 4 * 3);
|
|
let (mut acc, mut bits) = (0u32, 0u32);
|
|
for c in text.bytes().filter(|c| !c.is_ascii_whitespace()) {
|
|
if c == b'=' {
|
|
break;
|
|
}
|
|
let v = ALPHABET.iter().position(|&a| a == c)? as u32;
|
|
acc = (acc << 6) | v;
|
|
bits += 6;
|
|
if bits >= 8 {
|
|
bits -= 8;
|
|
out.push((acc >> bits) as u8);
|
|
acc &= (1 << bits) - 1;
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{base64_decode, base64_encode};
|
|
|
|
#[test]
|
|
fn rfc4648_vectors() {
|
|
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
|
|
{
|
|
assert_eq!(base64_encode(i.as_bytes()), o);
|
|
}
|
|
assert_eq!(base64_encode(&[0xff, 0xfe, 0xfd]), "//79");
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip() {
|
|
let data: Vec<u8> = (0..=255u8).chain((0..100).map(|i| (i * 37) as u8)).collect();
|
|
for n in 0..data.len() {
|
|
assert_eq!(base64_decode(&base64_encode(&data[..n])).unwrap(), &data[..n]);
|
|
}
|
|
assert!(base64_decode("not base64!").is_none());
|
|
}
|
|
}
|