# Architecture

The Hub is one Rust program that gives a model two tools, `execute_script` and `get_state`, and reaches each application through a module on the Hub's side and a bridge on the application's side. This page shows how the parts connect, the three ways of reaching an application, and one script measured against separate calls.

## The parts

- **MCP client**: on your side. It starts `libremodular-mcp serve` and calls the two tools.
- **Hub**: one process, `libremodular-mcp`. It answers the client, checks the target, reads the script before sending it and writes the audit journal.
- **Module**: inside the Hub, one per application. It knows how to reach the application.
- **Bridge**: in the application, beside it, or one Python process per script. It runs the script and returns what it printed, its `result`, or the application's own error.
- **Application**: GIMP, Krita, Blender, Inkscape or LibreOffice. It does the work.

The Hub knows no application's interface. A script is written in the application's own Python, and the module and the bridge are the only parts that depend on the application.

The Hub is a single executable of about 4.3 MB. Besides the loader, `ldd` lists two libraries: `libc.so.6` and `libgcc_s.so.1`. The package installs a second executable, `libremodular-launch`, which starts LibreOffice and its bridge.

## Two tools

The answer of the Hub to `tools/list` names exactly `["execute_script", "get_state"]`.

`execute_script(target_app, script_content)` runs a complete Python script in the application named by `target_app` and returns a JSON object: `status`, `duration_ms`, `stdout`, `stderr`, `result`, `error` with the application's own traceback, `truncated`, and `reason` when the Hub refused the script. `get_state(target_app)` returns the state of the application: its documents, layers or objects, a path to a PNG capture when there is one, and `content_origin`, which says the text came from a document and not from the Hub. The target `hub` lists the configured applications without contacting any of them.

## Three ways of reaching an application

The Hub was started with every application enabled and no bridge started. `get_state` on `hub` listed seven targets in these modes:

- `tcp`: `gimp`, `krita`, `blender`. A bridge loaded in the application listens on a local port, and the Hub connects to it.
- `uno`: `libreoffice-writer`, `libreoffice-calc`, `libreoffice-impress`. One stand-alone bridge connects to the UNO port LibreOffice listens on, and the three targets share it.
- `cli`: `inkscape`. The Hub starts one Python process per script; there is no bridge to start.

Only `inkscape` showed `connected: true`: in the `cli` mode nothing has to be running, and the Hub had found the program. In the `tcp` and `uno` modes the Hub never starts, closes or stops an application on the model's request; the operator starts the bridge, as the guide of each application says.

An application runs one script at a time. A script that outlasts the Hub's delay gets the answer `timeout`, and the next request gets `busy` until the application is free; the [Security](https://libremodular.davalan.fr/security/index.md) page measures it.

## What a bridge needs

The four bridge files the package installs are generated from one common core and one adapter per application. They import the Python standard library and, besides it, only the application's own interface. Each line gives the file, its length and what it imports outside the standard library:

- `blender_bridge.py`, 1,091 lines: `bpy`.
- `gimp_bridge.py`, 1,130 lines: `gi`.
- `krita_bridge.py`, 1,094 lines: `krita`, `PyQt5`, `PyQt6`.
- `uno_bridge.py`, 1,203 lines: `uno`, `com`.

The list comes from parsing each file, not from running it. The [Compatibility](https://libremodular.davalan.fr/compatibility/index.md) page gives, for each application, the version these bridges were tried with.

## One script against separate calls

A model that drives an application one gesture at a time makes one call per gesture. A model that writes the whole job makes one call. The Hub treats both the same way, so the two can be timed on the same Hub, the same application and the same job.

The job is to add N elements to a document: N rectangles in Inkscape, N cubes in Blender. For each N, ten repetitions timed one call of `execute_script` that does the whole job, then N calls that do one gesture each. The document was emptied before each measure, and two warm-up calls preceded the repetitions. The time is read in the client, from writing the request to reading the answer; in Inkscape the job is this script,

**Script written**

```python
import json, os
import inkex
document = inkex.load_svg(os.environ['LMM_DOCUMENT'])
root = document.getroot()
for i in range(20):
    root.add(inkex.Rectangle.new(i * 12, 10, 10, 10))
document.write(os.environ['LMM_DOCUMENT'])
print('LMM_RESULT: ' + json.dumps({'count': len(root)}))
```


and each of the twenty separate calls is the same script with the loop replaced by one line, for example the third:

**Script written**

```python
import json, os
import inkex
document = inkex.load_svg(os.environ['LMM_DOCUMENT'])
root = document.getroot()
root.add(inkex.Rectangle.new(36, 10, 10, 10))
document.write(os.environ['LMM_DOCUMENT'])
print('LMM_RESULT: ' + json.dumps({'count': len(root)}))
```


Median of ten repetitions, with the smallest and the largest in brackets. The client is the one of the [Demonstration](https://libremodular.davalan.fr/demonstration/index.md), with a clock around each call.

**Inkscape 1.4, `cli` mode** (one Python process per call):

- N = 1: one script 141.7 ms (141.7 to 151.7); one call 141.7 ms (141.5 to 161.9); ratio ×1.0.
- N = 5: one script 146.7 ms (141.6 to 152.0); five calls 738.7 ms (718.6 to 749.3); ratio ×5.0.
- N = 20: one script 151.7 ms (141.6 to 151.9); twenty calls 2,933.8 ms (2,874.0 to 3,046.2); ratio ×19.3.

**Blender 4.3.2, `tcp` mode** (the application stays running):

- N = 1: one script 1.5 ms (1.3 to 2.7); one call 1.2 ms (1.0 to 2.1); ratio ×0.8.
- N = 5: one script 3.5 ms (3.3 to 6.1); five calls 5.9 ms (5.5 to 8.3); ratio ×1.7.
- N = 20: one script 17.8 ms (16.7 to 20.4); twenty calls 30.5 ms (27.2 to 32.3); ratio ×1.7.

In the `cli` mode every call starts a Python process and loads `inkex`, which costs about 0.14 s whatever the job: twenty gestures in separate calls cost 19.3 times the single call. In the `tcp` mode the application is already running, and the 19 extra calls added 12.7 ms, about 0.7 ms each. A second run of the whole measurement gave medians that differed by at most 10 ms on Inkscape, except the twenty separate calls (87 ms), and by at most 1.3 ms on Blender; the ratio at N = 20 was ×19.9 on Inkscape and ×1.7 on Blender.

> **Limit**
>
> These numbers cover the Hub and the application only. The time a model takes to write and read each of the N calls is not measured and not included. They come from one machine (Debian 13, 32 processors), one job per application, N up to 20, and two of the five applications: GIMP, Krita and LibreOffice were not timed.

