Skip to content

MCP: headless open_file reads .pdf and PDF-compatible .ai, as File ▸ Open does - #5

Merged
echelon merged 1 commit into
storytold:mainfrom
storybored379:pr/mcp-headless-open-pdf-ai
Oct 4, 2026
Merged

echelon merged 1 commit into
storytold:mainfrom
storybored379:pr/mcp-headless-open-pdf-ai

Conversation

@storybored379

Copy link
Copy Markdown
Contributor

Headless open_file (and file.open through run_command) had its own loader that stopped at
.vectorcraft and .svg, so an agent running vectorcraft-cli mcp --headless got
"headless mode can open .vectorcraft and .svg files, not .pdf" for a PDF or an .ai file, although
the engine and vectorcraft-cli already import them through vectorcraft-pdf.

What changed

  • Headless::open imports .pdf, .ai and any file starting with %PDF through
    vectorcraft_pdf::import_with_report with default options (the same call File ▸ Open makes in
    ui-egui/src/io.rs) and returns the import warnings, like the SVG path does.
  • vectorcraft-mcp depends on vectorcraft-pdf; the open_file tool description lists the new types.

How it was found and tested

  • Reproduced end-to-end through the headless MCP server (vectorcraft-cli mcp --headless), then fixed.
  • headless_end_to_end now exports the test document to PDF, copies it to .ai, and opens both headless.
    Verified it fails on current main without the change (open_file: headless mode can open .vectorcraft and .svg files, not .pdf) and passes with it.
  • cargo test -p vectorcraft-mcp, cargo clippy -p vectorcraft-mcp --all-targets (no new warnings), cargo fmt --check, cargo xtask layers on current main.
  • Files: crates/mcp/Cargo.toml, crates/mcp/src/headless.rs, crates/mcp/src/tools.rs, crates/mcp/src/tests.rs, Cargo.lock
  • Clean-room: no third-party source consulted.

Found and fixed with Claude Code (AI-assisted).

🤖 Generated with Claude Code

…Open does

Headless `open_file` (and `file.open` through run_command) had its own
loader that stopped at .vectorcraft and .svg, so an agent could not open
a PDF or an .ai file without the desktop app, although the engine and
`vectorcraft-cli` already import them through vectorcraft-pdf.

The headless loader now imports .pdf, .ai and any file starting with
%PDF through vectorcraft_pdf::import_with_report (default options, as
File ▸ Open uses), and returns the import warnings like the SVG path.
The open_file tool description lists the new types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@echelon
echelon merged commit 8164d7c into storytold:main Oct 4, 2026
@echelon

echelon commented Oct 4, 2026

Copy link
Copy Markdown
Member

Thanks @storybored379, merged! Headless open_file now matches File ▸ Open for PDF and PDF-compatible .ai. Extending headless_end_to_end to export and re-open both was a nice way to prove it.

If you're not already there, come say hi in our Discord, we'd love to have you: https://discord.gg/artcraft

apexmiguel9-hub pushed a commit to apexmiguel9-hub/vectorcraft-android that referenced this pull request Oct 6, 2026
MEDIDO, y el motivo de fondo es que **abrir una segunda Activity da ANR en este port**.
No es SAF: es de abrir cualquier Activity. Traza del propio movil, textual:

    "main" prio=5 tid=1 Native
      native: storytold#1 <std::sys::sync::condvar::futex::Condvar>::wait+112
      native: storytold#2 <WaitableNativeActivityState>::set_activity_state+160
      native: storytold#3 try_with_waitable_activity_ref::<glue::on_pause::{closure#0}…>+240
      native: storytold#5 android_activity::activity_impl::glue::on_pause+20
      native: storytold#8 android.app.NativeActivity.onPause+16

    Reason: Input dispatching timed out (…MainActivity is not responding.
      Waited 10001ms for FocusEvent(hasFocus=false))
    mLastPausedActivity: com.android.documentsui/…picker.PickActivity

`set_activity_state` (`android-activity-0.6.1/src/native_activity/glue.rs:511`) escribe
el comando en un pipe y **espera en un condvar** a que `activity_state` cambie, y solo el
hilo del bucle puede cambiarlo. Ese hilo es el de `android_main`, que es donde corre el
update de egui.

O sea, el ciclo cerrado: el update espera al selector -> el selector pausa la Activity ->
la pausa espera al update -> el selector nunca devuelve. **Mi suposicion anterior de que
"el despertar viene de `onActivityResult`, en otro hilo, y no hay bloqueo" era
incorrecta**: ignoraba ese handshake.

Un browser dibujado en egui no abre ninguna Activity, asi que el ANR desaparece de raiz.

## El navegador: `egui_file`, y no hay que escribirlo

MEDIDO, leyendo el crate:

* `grep -rniE 'android|jni|SAF' src/` **no devuelve ni una linea**. Es egui puro.
* `src/lib.rs:231` — `fs: Box::new(Fs {})`, y `src/fs.rs` es `std::fs` a pelo
  (`std::fs::create_dir`, `read_dir`, `rename`). **No hay que escribir ningun `Vfs`.**
* Pide `egui ^0.36`, que es exactamente la del proyecto. Una dependencia (`dyn-clone`),
  MIT, 4,1 M de descargas, actualizado 2026-08-17.
* Inmediate mode: `show(ctx)` cada frame y `state()` / `path()` / `selection()` para el
  resultado. **Ninguna espera.** Ademas trae `multi_select`, asi que Place tambien sale.

## El permiso

`MANAGE_EXTERNAL_STORAGE` si hace falta, y se declara. MEDIDO, y no es un detalle de la
web: sin el, el navegador se queda en lo nuestro y no ve el telefono.

    $ adb shell run-as ai.storyteller.vectorcraft ls /sdcard
    ls: /sdcard: Permission denied

Y el otro lado, con un build de test de **ArmorPaint sin ningun** permiso de
almacenamiento: su browser muestra la **rejilla vacia** en `/Download`. Los nombres de las
carpetas de primer nivel si se ven; su contenido, no. Que es justo la linea que separa
las dos apps.

Decidido lo que **no** hace falta: bajar el `minSdk`. Lo que decide es el `targetSdk`, no
la version del telefono, asi que el permiso hace falta igual en Android 11 que en 16. En
los Android 7 a 10 que cubre `minSdk 24` no se pide y no hace falta.

Es un aviso con dos botones, no un dialogo modal: se puede saltar y la app sigue
sirviendo para lo que es suyo.

## Lo que gana el motor sin tocarlo

Con rutas de verdad en vez de `content://`:

* **Los ficheros linked vuelven a funcionar.** El motor usa `std::fs` para ellos
  (`cmd/links.rs:627`, `cmd/swatchlib.rs:136,190`, `cmd/flatten.rs:417`,
  `cmd/pdfcmds.rs:176`, `cmd/printpresets.rs:211`), asi que con SAF un `content://` les
  hacia `stat` a una cadena rara.
* **`note_recent` guarda rutas de verdad**, asi que los recientes se abren.

## Lo que queda fuera, y por que

**Guardar y exportar no funcionan todavia.** No es un descuido: `pick_save`, `pick_open`,
`pick_folder` y `pick_open_multi` son sincronos (`FnMut(…) -> Option<String>`), y un
browser en immediate mode no puede devolver una ruta en la misma llamada en la que el
usuario la elige. Ademas `save` escribe en esa misma llamada (`io.rs:251-263`).

Open y Place si, con `open_async`+`inbox` y `place_async`+`place_inbox`, que **ya existen
upstream** (`lib.rs:159,168`) y se drenan cada frame (`lib.rs:676`). Cero cambios en
`crates/**`.

Para guardar hace falta una costura aditiva, `save_async`, con el mismo patron que
`open_async`. Es el paso siguiente, y medido antes de escribirlo.

## Estructura

* `browser.rs` — el navegador, los `Services` y el frame del browser.
* `permiso.rs` — lo unico que queda de JNI: pedir y leer el permiso.
* `saf.rs` — **borrado**. Con ella caen el base64, `onActivityResult` y el `Condvar`.
* `MainActivity.java` — de 552 a 118 lineas. Solo el permiso y `nativeListo`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants