Surfaces from OpenAPI — Design Spec (#5453)
Milestone: SwiftUI/Engine - OpenAPI Manual: TBD — the reference manual’s MCP and CLI pages must say every tool and command comes from the engine’s contract, and list the toolsets. Facts for the maintainer to write it from (no user-manual page yet):
docs/contributor_manual/manual-facts/2026-10-05.md, section 4. Status: DRAFT
Intent (the design)
Everything Fichero can do is an engine route in one OpenAPI contract
(fichero-server/tests/contracts/openapi.json, 831 operations). The CLI is already generated
from that contract (fichero-server/scripts/generate_openapi_cli.py writes
fichero-cli/src/fichero_cli/openapi_surface_generated.py: one command per operation). The MCP
is not. About fifty tools are written by hand in fichero-mcp/src/fichero_mcp/ and fall behind
every time a route is added. So an agent driving a recipe finds no tool for training, evaluation,
clusters or the recipe itself (#5453), and someone has to write each missing one.
The MCP is generated the same way as the CLI, from the same contract. Adding a route adds its tool. An agent picks toolsets (OpenAPI tags) at start, so it sees the tools for its job and not all 831. Hand-written tools retire as generated ones cover their routes: one route, one tool.
Two surfaces drive the app rather than the engine, and they are not routes:
- AppleScript:
fichero/fichero/Fichero.sdef. - App Intents:
fichero/fichero/Intents/, which run on Mac, iPhone and iPad.
Their verbs open a node, select, reveal a line, or open a pane or Inspector tab. Each verb calls the same store method the click calls. They are how an agent, a script or a UI test checks what a person would see. This is the only way to drive iPhone and iPad from outside.
Prior art / best practices
- The CLI generator in this repo is the model. Its exclusion list (stream and debug routes) and
its
x-cli-requiredhandling of request bodies are reused, not rebuilt. - FastMCP 2.x (
fastmcp) hasFastMCP.from_openapi. It is not installed: we run the officialmcpSDK (mcp.server.fastmcp, pinned<2infichero-mcp/pyproject.toml). A second MCP framework for one feature costs more than a generator of about 200 lines writing calls toFicheroClient.request. - GitHub’s MCP server exposes toolsets chosen at start (
--toolsets). Tags as toolsets follow that convention. - App Intents (
AppIntent,AppShortcutsProvider) are Apple’s cross-platform automation surface. Shortcuts and XCUITest both drive them.
Behaviors
A. MCP generated from the contract
openapi.mcp.one-tool-per-operation[OK] (#5453) every operation in the contract, outside the shared exclusion list, has exactly one MCP tool. It is named from its tag and operation, and its description and parameter docs come from the route’s summary, description and schema. The tool makes oneFicheroClient.requestcall and holds no logic of its own. Built:fichero-server/scripts/generate_openapi_mcp.pywritesfichero-mcp/src/fichero_mcp/openapi_tools_generated.py(825 tools; the event streams are left out with the exclusion list, which lives infichero-server/scripts/openapi_operations.py, shared with the CLI generator). A tool isfichero_<tag>_<handler>. Pinned byfichero-mcp/tests/test_mcp_generated.pyand, against the real routes,fichero-server/tests/unit/mcp/test_generated_mcp_tools_reach_the_engine.py.openapi.mcp.toolsets-by-tag[OK] (#5453) the MCP server starts with--toolsets, a list of OpenAPI tags, and lists only those tags’ tools. The default is the recipe golden path (recipes, training, check, segments, documents, activity, local-models and hpc) plus what organising a project needs (#5568): canvas and classifications.--toolsets alllists every tool. An unknown tag is refused, naming the known ones. Pinned byfichero-mcp/tests/test_mcp_generated.py.openapi.mcp.pictures-are-images[OK] (#5568) a generated tool for a GET that answers a picture (a 200 offeringimage/*: a folder’s canvas, a segment, a thumbnail, a run’s diagram) returns MCP image content, so the agent sees the picture rather than a count of bytes; an answer that is not a picture is returned parsed, and a refusal is the same typed error. Built: the generator emits_rt.imagefor those routes (generate_openapi_mcp.py’simage_routes), which fetches the bytes withFicheroClient.get_bytes. Pinned bytest_a_picture_route_answers_the_agent_with_an_imageinfichero-mcp/tests/test_mcp_generated.py.openapi.mcp.organise-a-project[OK] (#5568) an agent organises a project through the default MCP surface as a historian does in the app: it groups pages into a document in their order (fichero_documents_create_group,_reorder,_move;_ungroupundoes it), gives the document a prototype (fichero_classifications_list_values/_create_valuewithattributes,fichero_documents_assign_prototype) and attribute values (fichero_documents_updatewithattributes;_get_effective_attributesreads them resolved), lays the folder’s board out (fichero_canvas_get_folder_layout,_save_folder_layout,_arrange_folder_layout), labels it (fichero_canvas_create_folder_itemand its update, delete and list), and LOOKS at it:fichero_canvas_get_folder_picturereturns the board as one PNG, drawn by the engine from the saved layout and the stored thumbnails (GET /api/canvas/folders/{id}/canvas-picture: cards at their saved centres labelled with name and id, groups outlined with their member count, notes as yellow boxes, unplaced pages in a strip below, longest side bounded). Every tool is the route’s generated tool; the picture is the one new route. Pinned end to end through the MCP protocol against the real routes byfichero-server/tests/unit/mcp/test_mcp_organises_a_project.py, and the picture’s pixels byfichero-server/tests/unit/api/test_canvas_picture.py.openapi.mcp.current-with-the-contract[OK] (#5453) a guard regenerates the MCP module and fails if it differs from the committed one, as the CLI’s does. A route added without regenerating fails the gate. Built:scripts/check_mcp_generated_current.py(run bytest_every_guard_passes_on_the_real_tree.py);sync_openapi_schema.shregenerates both surfaces. Pinned failing on a stale module byfichero-mcp/tests/test_mcp_generated.py.openapi.mcp.one-route-one-tool[PARTIAL] (#5453) no route has both a generated tool and a hand-written one. A guard lists any hand-written tool whose route a generated tool covers. That hand-written tool is removed, or kept only where it composes several routes, and then says which routes. Built: the covered hand-written tools are retired, andtest_one_route_one_toolinfichero-mcp/tests/test_mcp_generated.pyfails on any left that names one covered route and is not inKEPT_SINGLE_ROUTE. Still partial: four stay on one route each for what the route does not do (fichero_docs_listandfichero_workflow_listanswer lean summaries the routes have no view for;fichero_page_importsends the YOLO dataset file found beside the labels;fichero_use_librarysets the session’s library, by its path or by the name the sidebar shows, #5567), andOPERATOR_ALIASESkeeps the old tool names an agent configuration still calls, pointing at the generated tools.openapi.mcp.mutations-act-as-the-agent[OK] (#5453) a generated tool that changes data calls as the agent account (_agent_clientinfichero-mcp/src/fichero_mcp/server.py), so the audit log names the agent. Reads may use the default client. Built through_mutating_client: the agent account when one is signed in; on a single-user engine with no accounts, the owner, labelledclient=fichero-mcpin the audit (#4469). Pinned byfichero-mcp/tests/test_mutating_client_attribution.py.openapi.mcp.reads-say-so[OK] (#5584) a generated tool says whether it reads or changes data. A GET reads; a route that takes its question as a body but writes nothing (POST /api/recipes/assemble) is marked a read in the contract (x-fichero-reads: truethrough the route’sopenapi_extra), and its tool says it reads rather than “changes data”. Built: the shared parse reads the mark intoOperation.reads(openapi_operations.py) andgenerate_openapi_mcp.pywords the tool from it. Pinned, the contract’s mark and the tool’s words, byfichero-mcp/tests/test_mcp_reads_say_so.py.openapi.mcp.errors-reach-the-agent[OK] (#5453) a refused or failed call returns the engine’s typed error (status and detail) as an MCP tool error. It is never an empty result. The error text is{"status", "detail", "route"}as JSON. Pinned through the MCP protocol byfichero-mcp/tests/test_mcp_generated.pyand against the real routes byfichero-server/tests/unit/mcp/test_generated_mcp_tools_reach_the_engine.py.
B. CLI (already generated)
openapi.cli.one-command-per-operation[OK]generate_openapi_cli.pywrites one command per operation, pinned by thetest_cli_generated_*integration tests.openapi.cli.operator-rough-edges[OK] (#5567) what an operator found running reads through the CLI. A trained model’s id is accepted as the app shows it:--model fichero-trained/<name>(or the whole id with no provider) runs on the local MLX server with the whole id, because the engine’s execute request keeps a split trained id whole (run_model_choice), whichever client split it.workflow run <workflow> <doc>...runs ONE run over several pages, as the Run menu runs a selection. A project is opened by the name the sidebar shows as well as by its path:--library,library openand MCPfichero_use_libraryresolve a name throughGET /api/registry/resolve(404 naming the known projects; 409 naming the paths when two share the name). Pinned byfichero-server/tests/unit/api/test_run_model_trained_id.py,fichero-server/tests/unit/api/test_resolve_project_by_name.py,fichero-server/tests/unit/cli/test_cli_commands.pyandfichero-mcp/tests/test_mcp_server.py.openapi.cli.path-ids-positional[OK] (#5584) every path parameter of a generated command is positional (fichero docs get-children <doc_id>, asdocs get <doc_id>), and every body field and query parameter an option. The MCP tools take every argument by name, the path’s ids under the same names (doc_idfor bothfichero_documents_getandfichero_documents_get_children). The rule already held forget-children; it did not for a{name}inside a segment (/api/citations/document/{document_id}.bib, the IIIF image request’s{quality}.{format}), whose commands and tools sent the braces literally. Built: the shared parse takes every{name}in the path (openapi_operations.py). Pinned on every generated command byfichero-cli/tests/test_cli_path_ids_positional.py.openapi.cli.flags-say-what-they-take[OK] (#5501) a generated flag’s--helpis the field’s description from the contract (its choices and default after), never onlyRequest field: X, as the MCP tool’s parameter doc says it (onefield_docinopenapi_operations.py). A list of plain values (languages,scripts,document_ids) is given by repeating the flag, as one comma-separated value, or as a JSON list, and its help says so; a structured field (an object, a list of objects) says it takes JSON. The request models of recipes, documents, segments and workflow runs describe every field. Pinned byfichero-cli/tests/test_cli_flags_and_names.py.openapi.cli.names-from-the-handler[OK] (#5501) a generated command is named, as its MCP tool is, by the route handler’s name less the words its group already says (evaluation start,sync-folders tie), never by cutting the group’s words out of the summary sentence (sync-folders tie-the-project-to-a-on-the-engine-s-disk). The name it had before runs still, hidden, so a command written down keeps working. A generated command never takes the name of a hand-written one in the same group (typer keeps one of two). Pinned byfichero-cli/tests/test_cli_flags_and_names.py.
C. Driving the app: AppleScript and App Intents
openapi.ui.verbs-are-the-click[PARTIAL] (#5453) each UI verb (open a node, select nodes, reveal a line in Preview, open a pane, show an Inspector tab) is anAppIntentin shared code. It calls the same store method its click calls, never a second path. On the Mac the same verb is an AppleScript command. Built:fichero/fichero/Intents/UIVerbs.swiftis the one path, called by the intents (Intents/FicheroUIVerbIntents.swift: Open Project, Open Node, Select Nodes, Reveal Lines in Preview, Show Pane, Show Inspector Tab, Take Screenshot) and the AppleScript commands (Services/AppleScriptUIVerbs.swift:open project,open node,select nodes,reveal segments,show pane,show inspector tab). Open project is File ▸ Open’sopenLibrary(at:), open node the sidebar’s reveal, reveal a lineWindowState.revealSegments; select, pane and tab are a request on the front window’sWindowStatethatContentView.applyUIVerbdoes through the click’s own method (setPaneVisible, the Inspector button, the tab’s scene storage). Pinned through the realLibraryManager,WindowStateandSegmentStorebyfichero/Tests/Unit/general/Intents/UIVerbsTests.swift, and the commands byfichero/Tests/Unit/mac/AppleScriptSurfaceTests.swift. Still partial: no UI test yet checks that the window’sapplyUIVerbchanges what is on screen.openapi.ui.verbs-on-every-device[PARTIAL] (#5453) the UI verbs build and run on Mac, iPhone and iPad. A UI test on each device drives at least open a node and reveal a line through its intent and checks what is on screen. Built: the intents are shared code and compile for the iOS Simulator; on iPhone and iPad the one window state is the front one. Still partial: no UI test on any device drives an intent yet.openapi.ui.mcp-reaches-the-verbs[OK] (#5453) on the Mac, MCPuitools call the AppleScript verbs (fichero-mcp/src/fichero_mcp/ui_control.pygrows from its four commands). An agent can then check on screen what it did through the engine tools. Built:--toolsets uilistsfichero_ui_open_project,fichero_ui_open_node,fichero_ui_select_nodes,fichero_ui_reveal_segments,fichero_ui_show_pane,fichero_ui_show_inspector_tabandfichero_ui_screenshot(FICHERO_UI_APPnames a built app by path). Pinned byfichero-mcp/tests/test_mcp_ui_control.py: each builds the command the sdef declares, quoted.openapi.ui.screenshot[PARTIAL] (#5453) an agent can ask the app for a picture of the window, or of one pane (Library, Preview, Reader, Inspector, Activity), saved as a PNG at a path it names, through the same UI verb surface (AppleScript on the Mac, an App Intent everywhere). It is how an agent checks on screen what it did, and how documentation screenshots are made (docs/assets/<milestone>/), never a second capture path. Ruled 2026-10-04. Built:screenshot "<path>" of pane "<name>"and Take Screenshot callUIVerbs.screenshot; the app draws its own window (Services/FicheroUICapture.swift:cacheDisplayon the Mac,drawHierarchyon iOS) and crops to the frame each pane records as it is laid out (WindowState.paneFrames); Activity is its own window on the Mac. Pinned byUIVerbsTests(a test window’s PNG at its pixel size; a pane’s crop is that pane’s pixels; a pane not shown is refused, naming those shown). Still partial: the live panes’ recorded frames are not yet checked in the running app.openapi.ui.agents-connect-as-accounts[GAP] (#5453) with Multi-user on, an agent connects to the engine as its own account, which the owner adds in Settings’ accounts list like a person’s; its MCP writes are audited under that account and its device. No new switch: sharing and Multi-user stay the only two. With Multi-user off the MCP acts as the owner, labelledfichero-mcp. Driving the app this way is also how Multi-user gets tested end to end. Ruled 2026-10-04.openapi.applescript.engine-verbs-from-the-contract[GAP] (#5453) any AppleScript command that reaches the engine calls the generated Swift client, never a hand-built URL (seeautomation.applescript.*inui/automation.mdfor the dictionary’s own checks: #5258, #5259).
D. The golden path is drivable
openapi.golden-path.every-step-has-a-tool[GAP] (#5453) every step ofrecipe.distil.golden-path(recipe, Start plan, the teacher-line check, the clean set, training, evaluation, model nodes, cluster submit and status) has a route and therefore a generated tool. A missing route is engine work, filed against the step’s own spec.
Test matrix
| Leg | This surface? | Pins | File |
|---|---|---|---|
| Pure rule (Python) | y | generator: naming, exclusions, toolsets; drift guard | fichero-mcp/tests/test_mcp_generated.py |
| Backend (pytest) | y | a generated tool calls the live route and returns its result | fichero-server/tests/unit/mcp/test_generated_mcp_tools_reach_the_engine.py |
| MCP | y | toolsets list the right tools; errors are typed | fichero-mcp/tests/test_mcp_generated.py, fichero-server/tests/integration/test_mcp_server_contract.py |
| CLI | y | one command per operation | fichero-server/tests/integration/test_cli_generated_* |
| Click-around (XCUITest, Mac) | y | an intent drives what the click drives | planned |
| iPhone (iOS) | y | open a node, reveal a line through intents | planned |
| iPad | y | same | planned |
Order of work
- The generator plus toolsets plus the drift guard (A1–A3), with tests.
- Retire the hand-written tools that are now covered (A4), and move mutations to the agent account (A5).
- UI verbs as App Intents and AppleScript commands (C).
- Fill the golden path’s missing routes (D), each in its owning spec.