This tutorial shows you how to make a JavaFX desktop application scriptable by AI agents using the Model Context Protocol (MCP). We’ll build a Note Manager app that AI agents like Claude can control — creating notes, searching, navigating the UI, and exporting data — all through natural language.

Introduction

The Vision: AppleScript for the AI Age

For decades, macOS apps have supported AppleScript: a way for external scripts to control applications through high-level, semantic commands. Instead of simulating mouse clicks, a script can say tell application "Keynote" to start the slideshow or tell application "Mail" to make new outgoing message. The app exposes what it can do, and the caller decides when to do it.

This pattern is incredibly powerful, but it has always been limited to macOS and to traditional scripting. What if we could bring the same idea to Java desktop applications — cross-platform — and make them scriptable by AI agents?

That’s what this tutorial builds. Using jDeploy 6.0’s MCP server support and deep linking, we’ll create a JavaFX application that AI agents can script through the Model Context Protocol. The AI doesn’t need to see the screen or simulate clicks. It calls well-defined MCP tools like create_note, search_notes, or show_note, and the app responds with structured data.

What We’re Building

A Note Manager JavaFX application with two personalities:

  1. GUI mode — A desktop note-taking app with a sidebar, editor, and search

  2. MCP server mode — A headless process that exposes the app’s capabilities as MCP tools

The MCP server communicates with the running GUI via the jdeploy-desktop-lib-bridge library, which handles all IPC complexity. An AI agent can:

  • Create, read, and search notes

  • Navigate the GUI to display a specific note

  • Export notes to files

All from a natural language conversation.

Architecture Overview

The core challenge is that MCP servers run as CLI processes (communicating via stdin/stdout), while the JavaFX app runs as a separate GUI process. We need inter-process communication between them.

jDeploy 6.0 provides the building blocks:

  • MCP server support — deploy a CLI command as an MCP server that AI tools discover automatically

  • Singleton mode — ensures one GUI instance; commands route to it instead of launching duplicates

  • GUI Bridge library — the jdeploy-desktop-lib-bridge module handles all IPC complexity, providing a simple GuiBridge API for the MCP server and GuiBridgeReceiver for the GUI

The bridge supports two modes:

  • Background — Data queries are processed without interrupting the user. The GUI stays in the background.

  • Foreground — UI commands like show_note bring the GUI to front so the user can see the result.

The flow for every MCP tool call:

This is the approach we’ll implement in this tutorial.

Prerequisites

Before starting, ensure you have:

  • Java 21 or higher installed

  • jDeploy Desktop App version 6.0 or later (download)

  • A GitHub account for publishing

  • An AI tool that supports MCP — Claude Code, Claude Desktop, VS Code Copilot, Cursor, etc.

  • Basic familiarity with JavaFX and Quarkus

Project Setup

We’ll create the project using the jDeploy Desktop App and the Quarkus MCP Server template. This gives us Quarkus’s @Tool annotation-based MCP server framework combined with jDeploy’s multi-modal packaging, pre-configured with a GitHub Actions workflow for publishing.

Step 1: Create a New Project

Launch the jDeploy Desktop App. From the main menu, click "Create new project…​".

Screenshot: jDeploy main menu showing "Create new project…​" button
Figure 1. The jDeploy main menu with the "Create new project…​" option

Step 2: Select the Quarkus MCP Server Template

In the template selection dialog, find and select "Quarkus MCP Server". This template includes:

  • Quarkus with the quarkus-mcp-server-stdio extension for MCP protocol handling

  • A Gradle build with an uber-jar task

  • A GitHub Actions workflow for jDeploy publishing

  • A package.json pre-configured with an MCP command

Screenshot: Template selection dialog with "Quarkus MCP Server" highlighted
Figure 2. Selecting the Quarkus MCP Server template

Click Next to continue.

Step 3: Configure Project Settings

Fill in the project details:

  • Application Display Name: Note Manager

  • Group ID: com.example

  • Artifact ID: note-manager

  • Project Location: Choose where to create the project on disk

  • Publish to: Select GitHub and enter your repository (e.g., yourusername/note-manager)

Screenshot: Project configuration dialog filled in with Note Manager details
Figure 3. Configuring the Note Manager project settings

Click Finish to generate the project.

Generated Project Structure

jDeploy generates the following project structure:

note-manager/
├── .github/
│   └── workflows/
│       └── jdeploy.yml          # GitHub Actions workflow for publishing
├── gradle/
│   └── wrapper/                 # Gradle wrapper files
├── src/
│   └── main/
│       ├── java/
│       │   └── com/example/notemanager/
│       │       ├── NoteManager.java   # Entry point with mode detection
│       │       └── GreetingTools.java # Sample MCP tools (we'll replace)
│       └── resources/
│           └── application.properties
├── build.gradle                 # Gradle build configuration
├── gradle.properties            # Quarkus version properties
├── gradlew / gradlew.bat       # Gradle wrapper scripts
├── settings.gradle              # Gradle settings
├── icon.png                     # Application icon
├── package.json                 # jDeploy configuration
└── README.md
You can open the project in your preferred IDE using File > Open in IDE in the jDeploy Desktop App.
Screenshot: File menu showing "Open in IDE" option
Figure 4. Opening the project in your IDE

Step 4: Add JavaFX and jDeploy Desktop Lib Dependencies

The generated template includes Quarkus and MCP dependencies. We need to add JavaFX (for the GUI) and the jDeploy Desktop Lib modules for deep linking, singleton support, and GUI-to-MCP communication.

Open build.gradle and add these dependencies:

// JavaFX
implementation 'org.openjfx:javafx-controls:21'

// jDeploy Desktop Lib - use BOM for version management
implementation platform('ca.weblite:jdeploy-desktop-lib-bom:1.0.0')
implementation 'ca.weblite:jdeploy-desktop-lib-javafx'
implementation 'ca.weblite:jdeploy-desktop-lib-hive-filewatcher'
implementation 'ca.weblite:jdeploy-desktop-lib-bridge'

The modules provide:

  • jdeploy-desktop-lib-javafx — JavaFX integration for deep linking and singleton support

  • jdeploy-desktop-lib-hive-filewatcher — Enables background IPC (required by the bridge)

  • jdeploy-desktop-lib-bridge — High-level API for MCP-to-GUI communication via GuiBridge and GuiBridgeReceiver

Step 5: Configure JavaFX, Deep Linking, and Singleton Mode

The generated package.json already has an MCP command (note-manager) configured. We need to enable JavaFX support, singleton mode, a custom URL scheme, and configure the MCP args so our app can distinguish MCP mode from GUI mode.

Open the project in the jDeploy Desktop App and configure the following settings:

Enable JavaFX and Singleton Mode

Click Build in the side navigation. Check both JavaFX Application (to bundle JavaFX with the app) and Singleton Application (to ensure only one instance runs).

Screenshot: Build tab with "JavaFX Application" and "Singleton Application" checkboxes checked
Figure 5. Enabling JavaFX and singleton mode in the Build tab

Register the URL Scheme

Click URLs in the side navigation and add notemanager as a URL scheme.

Screenshot: URLs tab with "notemanager" added as a URL scheme
Figure 6. Adding the "notemanager" URL scheme

Verify the MCP Server Configuration

Click AI Integrations in the side navigation. The template already has the note-manager command selected as the MCP server. No additional configuration is needed.

Screenshot: AI Integrations tab showing MCP server configured with the note-manager command
Figure 7. MCP server configuration

The relevant parts of the resulting package.json should look like this:

{
  "name": "note-manager",
  "version": "1.0.0",
  "jdeploy": {
    "javaVersion": "21",
    "jar": "build/note-manager-1.0.0-SNAPSHOT-runner.jar",
    "title": "Note Manager",
    "javafx": true,
    "singleton": true,
    "urlSchemes": ["notemanager"],
    "commands": {
      "note-manager": {
        "description": "Note Manager MCP server"
      }
    },
    "ai": {
      "mcp": {
        "command": "note-manager"
      }
    }
  }
}

Here’s what each piece does:

  • javafx: true — Bundles JavaFX with the application for cross-platform GUI support.

  • singleton: true — Only one GUI instance runs at a time. Subsequent launches forward their arguments to the existing instance.

  • urlSchemes: ["notemanager"] — Registers the notemanager:// custom URL scheme with the OS. Deep links using this scheme are routed to the running app.

  • commands.note-manager — The CLI command generated by the template. We use it as the MCP server command.

  • ai.mcp — Configures the MCP server. The command specifies which CLI command to use as the MCP server.

Step 6: Update the Entry Point

The generated NoteManager.java already has mode detection for GUI vs. command mode. We need to modify it to launch JavaFX instead of Swing, initialize the GUI Bridge, and route MCP mode through Quarkus:

package com.example.notemanager;

import ca.weblite.jdeploy.app.hive.filewatcher.FileWatcherHiveDriver;
import io.quarkus.runtime.Quarkus;
import io.quarkus.runtime.annotations.QuarkusMain;

@QuarkusMain
public class NoteManager {

    public static final String MODE_GUI = "gui";
    public static final String MODE_COMMAND = "command";
    public static final String PROP_MODE = "jdeploy.mode";

    private static final String APP_NAME = "note-manager";

    public static void main(String... args) {
        // Ensure jdeploy.app.name is set (required by the bridge library)
        if (System.getProperty("jdeploy.app.name") == null) {
            System.setProperty("jdeploy.app.name", APP_NAME);
        }

        // Initialize background IPC for the GUI Bridge
        FileWatcherHiveDriver.install();

        String mode = System.getProperty(PROP_MODE, MODE_GUI);

        if (MODE_COMMAND.equalsIgnoreCase(mode)) {
            // MCP server mode: Quarkus handles stdin/stdout MCP protocol
            Quarkus.run(args);
        } else {
            // GUI mode: launch the JavaFX application
            NoteManagerApp.main(args);
        }
    }
}

The FileWatcherHiveDriver.install() call initializes the background IPC used by the GUI Bridge. This must be called in both modes (GUI and MCP server) so they can communicate with each other.

Building the JavaFX Note Manager

The Data Model

First, a simple model for our notes:

package com.example.notemanager.model;

import java.time.LocalDateTime;
import java.util.UUID;

public class Note {
    private final String id;
    private String title;
    private String content;
    private final LocalDateTime createdAt;
    private LocalDateTime modifiedAt;

    public Note(String title, String content) {
        this.id = UUID.randomUUID().toString().substring(0, 8);
        this.title = title;
        this.content = content;
        this.createdAt = LocalDateTime.now();
        this.modifiedAt = this.createdAt;
    }

    public String getId() { return id; }
    public String getTitle() { return title; }
    public void setTitle(String title) {
        this.title = title;
        this.modifiedAt = LocalDateTime.now();
    }
    public String getContent() { return content; }
    public void setContent(String content) {
        this.content = content;
        this.modifiedAt = LocalDateTime.now();
    }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getModifiedAt() { return modifiedAt; }
}

The Note Store

A shared store that both the GUI and IPC handler can access:

package com.example.notemanager.model;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class NoteStore {
    private static final NoteStore INSTANCE = new NoteStore();
    private final List<Note> notes = new ArrayList<>();

    public static NoteStore getInstance() { return INSTANCE; }

    private NoteStore() {
        // Start with a sample note
        notes.add(new Note("Welcome", "Welcome to Note Manager! "
            + "This app can be scripted by AI agents via MCP."));
    }

    public synchronized List<Note> getAllNotes() {
        return new ArrayList<>(notes);
    }

    public synchronized Optional<Note> getNote(String id) {
        return notes.stream()
            .filter(n -> n.getId().equals(id))
            .findFirst();
    }

    public synchronized Note createNote(String title, String content) {
        Note note = new Note(title, content);
        notes.add(note);
        return note;
    }

    public synchronized boolean deleteNote(String id) {
        return notes.removeIf(n -> n.getId().equals(id));
    }

    public synchronized List<Note> search(String query) {
        String lower = query.toLowerCase();
        return notes.stream()
            .filter(n -> n.getTitle().toLowerCase().contains(lower)
                || n.getContent().toLowerCase().contains(lower))
            .collect(Collectors.toList());
    }
}

The JavaFX Application

package com.example.notemanager;

import ca.weblite.jdeploy.app.JDeployOpenHandler;
import ca.weblite.jdeploy.app.bridge.GuiBridgeHandler;
import ca.weblite.jdeploy.app.bridge.GuiBridgeReceiver;
import ca.weblite.jdeploy.app.javafx.JDeployFXApp;
import com.example.notemanager.model.Note;
import com.example.notemanager.model.NoteStore;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class NoteManagerApp extends Application {
    private Stage primaryStage;
    private ListView<Note> noteListView;
    private ObservableList<Note> noteItems;
    private TextArea contentArea;
    private TextField titleField;
    private TextField searchField;
    private final NoteStore store = NoteStore.getInstance();

    private GuiBridgeReceiver bridgeReceiver;

    public static void main(String[] args) {
        // IMPORTANT: Initialize JDeployFXApp before launching JavaFX.
        // On macOS, this registers AWT Desktop handlers while AWT is still
        // the primary toolkit. Without this, deep links won't be received.
        JDeployFXApp.initialize();
        launch(args);
    }

    @Override
    public void start(Stage stage) {
        this.primaryStage = stage;

        // --- Sidebar: note list + search + new button ---
        searchField = new TextField();
        searchField.setPromptText("Search notes...");
        searchField.textProperty().addListener((obs, oldVal, newVal) -> refreshList());

        noteItems = FXCollections.observableArrayList(store.getAllNotes());
        noteListView = new ListView<>(noteItems);
        noteListView.setCellFactory(lv -> new ListCell<>() {
            @Override
            protected void updateItem(Note note, boolean empty) {
                super.updateItem(note, empty);
                setText(empty || note == null ? null : note.getTitle());
            }
        });
        noteListView.getSelectionModel().selectedItemProperty()
            .addListener((obs, oldNote, newNote) -> showNote(newNote));

        Button newButton = new Button("New Note");
        newButton.setMaxWidth(Double.MAX_VALUE);
        newButton.setOnAction(e -> createNewNote());

        VBox sidebar = new VBox(8, searchField, noteListView, newButton);
        sidebar.setPadding(new Insets(10));
        sidebar.setPrefWidth(250);
        VBox.setVgrow(noteListView, Priority.ALWAYS);

        // --- Editor: title + content ---
        titleField = new TextField();
        titleField.setPromptText("Note title");
        titleField.setStyle("-fx-font-size: 16px; -fx-font-weight: bold;");
        titleField.textProperty().addListener((obs, oldVal, newVal) -> {
            Note selected = noteListView.getSelectionModel().getSelectedItem();
            if (selected != null) {
                selected.setTitle(newVal);
                noteListView.refresh();
            }
        });

        contentArea = new TextArea();
        contentArea.setPromptText("Start writing...");
        contentArea.setWrapText(true);
        contentArea.textProperty().addListener((obs, oldVal, newVal) -> {
            Note selected = noteListView.getSelectionModel().getSelectedItem();
            if (selected != null) {
                selected.setContent(newVal);
            }
        });

        VBox editor = new VBox(8, titleField, contentArea);
        editor.setPadding(new Insets(10));
        VBox.setVgrow(contentArea, Priority.ALWAYS);

        // --- Layout ---
        SplitPane root = new SplitPane(sidebar, editor);
        root.setOrientation(Orientation.HORIZONTAL);
        root.setDividerPositions(0.3);

        Scene scene = new Scene(root, 900, 600);
        stage.setTitle("Note Manager");
        stage.setScene(scene);

        // Set up MCP command handling via GuiBridge
        setupBridgeReceiver();

        // Register deep link handler (for "show_note" and fallback)
        registerDeepLinkHandler();

        stage.show();

        // Select first note
        if (!noteItems.isEmpty()) {
            noteListView.getSelectionModel().select(0);
        }
    }

    // =========================================================================
    // GUI BRIDGE SETUP
    // =========================================================================

    private void setupBridgeReceiver() {
        // Create handler that processes MCP commands
        GuiBridgeHandler handler = this::handleCommand;

        // Create receiver and register for background commands
        bridgeReceiver = new GuiBridgeReceiver(handler);
        bridgeReceiver.registerHiveListener(requestPath ->
                Platform.runLater(() -> bridgeReceiver.processRequest(requestPath)));
    }

    private Map<String, String> handleCommand(String command, Map<String, String> params) {
        return switch (command) {
            case "list_notes" -> {
                Map<String, String> result = new HashMap<>();
                List<Note> notes = store.getAllNotes();
                result.put("count", String.valueOf(notes.size()));
                int i = 0;
                for (Note n : notes) {
                    result.put("note." + i + ".id", n.getId());
                    result.put("note." + i + ".title", n.getTitle());
                    result.put("note." + i + ".createdAt", n.getCreatedAt().toString());
                    result.put("note." + i + ".modifiedAt", n.getModifiedAt().toString());
                    i++;
                }
                yield result;
            }

            case "get_note" -> {
                String noteId = params.get("id");
                Note note = store.getNote(noteId)
                        .orElseThrow(() -> new RuntimeException("Note not found: " + noteId));
                yield Map.of(
                        "id", note.getId(),
                        "title", note.getTitle(),
                        "content", note.getContent(),
                        "createdAt", note.getCreatedAt().toString(),
                        "modifiedAt", note.getModifiedAt().toString()
                );
            }

            case "create_note" -> {
                String title = params.get("title");
                String content = params.getOrDefault("content", "");
                Note note = store.createNote(title, content);
                refreshList();
                yield Map.of(
                        "id", note.getId(),
                        "title", note.getTitle(),
                        "createdAt", note.getCreatedAt().toString(),
                        "modifiedAt", note.getModifiedAt().toString()
                );
            }

            case "search_notes" -> {
                String query = params.get("query");
                Map<String, String> result = new HashMap<>();
                List<Note> notes = store.search(query);
                result.put("count", String.valueOf(notes.size()));
                int i = 0;
                for (Note n : notes) {
                    result.put("note." + i + ".id", n.getId());
                    result.put("note." + i + ".title", n.getTitle());
                    i++;
                }
                yield result;
            }

            case "show_note" -> {
                String noteId = params.get("id");
                navigateToNote(noteId);
                yield Map.of("shown", "true");
            }

            case "export_note" -> {
                String noteId = params.get("id");
                String outputPath = params.get("path");
                Note note = store.getNote(noteId)
                        .orElseThrow(() -> new RuntimeException("Note not found: " + noteId));
                try {
                    Files.writeString(Path.of(outputPath),
                            "# " + note.getTitle() + "\n\n" + note.getContent());
                } catch (IOException e) {
                    throw new RuntimeException("Failed to export: " + e.getMessage());
                }
                yield Map.of("exported", "true", "path", outputPath);
            }

            default -> throw new IllegalArgumentException("Unknown command: " + command);
        };
    }

    // =========================================================================
    // DEEP LINK HANDLER
    // =========================================================================

    private void registerDeepLinkHandler() {
        JDeployFXApp.setOpenHandler(new JDeployOpenHandler() {
            @Override
            public void openFiles(List<File> files) {
                // Not used in this tutorial
            }

            @Override
            public void openURIs(List<URI> uris) {
                for (URI uri : uris) {
                    if ("notemanager".equals(uri.getScheme()) && "bridge".equals(uri.getHost())) {
                        // Process via bridge receiver
                        bridgeReceiver.processDeepLink(uri);
                    }
                }
            }

            @Override
            public void appActivated() {
                primaryStage.setIconified(false);
                primaryStage.toFront();
                primaryStage.requestFocus();
            }
        });
    }

    // =========================================================================
    // NOTE DISPLAY
    // =========================================================================

    private void showNote(Note note) {
        if (note == null) {
            titleField.clear();
            contentArea.clear();
            return;
        }
        titleField.setText(note.getTitle());
        contentArea.setText(note.getContent());
    }

    private void createNewNote() {
        Note note = store.createNote("Untitled", "");
        refreshList();
        noteListView.getSelectionModel().select(note);
        titleField.requestFocus();
        titleField.selectAll();
    }

    public void refreshList() {
        String query = searchField.getText();
        List<Note> filtered = (query == null || query.isBlank())
            ? store.getAllNotes()
            : store.search(query);
        noteItems.setAll(filtered);
    }

    public void navigateToNote(String noteId) {
        store.getNote(noteId).ifPresent(note -> {
            searchField.clear();
            refreshList();
            noteListView.getSelectionModel().select(note);
            noteListView.scrollTo(note);
            primaryStage.setIconified(false);
            primaryStage.toFront();
            primaryStage.requestFocus();
        });
    }
}

Notice how the handleCommand method contains all the business logic for MCP commands. The GuiBridgeReceiver handles all the IPC details — reading request files, parsing parameters, and writing responses.

The GUI Bridge Library

The jdeploy-desktop-lib-bridge module handles all the complexity of MCP-to-GUI communication. You don’t need to write any IPC code yourself — just use the provided GuiBridge (sender side) and GuiBridgeReceiver (receiver side).

The bridge provides two methods for sending commands:

  • sendCommand() — Sends a command in the background. The GUI processes it without coming to the foreground. Use this for data queries.

  • sendCommandViaDeepLink() — Sends a command and brings the GUI to the foreground. Use this for commands like show_note that should display something to the user.

Setting Up the Receiver (GUI Side)

In the JavaFX application, create a GuiBridgeReceiver with a handler that processes commands:

// Create handler that processes MCP commands
GuiBridgeHandler handler = this::handleCommand;

// Create receiver and register for background commands
GuiBridgeReceiver bridgeReceiver = new GuiBridgeReceiver(handler);
bridgeReceiver.registerHiveListener(requestPath ->
        Platform.runLater(() -> bridgeReceiver.processRequest(requestPath)));

The handler is a simple method that takes a command name and parameters, and returns a result map:

private Map<String, String> handleCommand(String command, Map<String, String> params) {
    return switch (command) {
        case "list_notes" -> {
            Map<String, String> result = new HashMap<>();
            List<Note> notes = store.getAllNotes();
            result.put("count", String.valueOf(notes.size()));
            int i = 0;
            for (Note n : notes) {
                result.put("note." + i + ".id", n.getId());
                result.put("note." + i + ".title", n.getTitle());
                i++;
            }
            yield result;
        }
        case "create_note" -> {
            Note note = store.createNote(params.get("title"), params.getOrDefault("content", ""));
            refreshList();
            yield Map.of("id", note.getId(), "title", note.getTitle());
        }
        // ... other commands
        default -> throw new IllegalArgumentException("Unknown command: " + command);
    };
}

Building the MCP Server (Quarkus)

The MCP server is the CLI-side process. It runs under Quarkus with the quarkus-mcp-server-stdio extension, which handles the MCP protocol over stdin/stdout. Each @Tool-annotated method becomes a tool that AI agents can call.

MCP Tool Definitions

The MCP tools use GuiBridge from the library to communicate with the GUI. The bridge handles all IPC complexity — you just call sendCommand() or sendCommandViaDeepLink():

package com.example.notemanager.mcp;

import ca.weblite.jdeploy.app.bridge.GuiBridge;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;

import java.util.Map;

public class NoteManagerTools {

    // GuiBridge handles all IPC with the GUI
    private static final GuiBridge bridge = new GuiBridge("notemanager");

    @Tool(description = "List all notes in the Note Manager. "
            + "Returns note IDs, titles, and timestamps.")
    public String listNotes() throws Exception {
        Map<String, String> result = bridge.sendCommand("list_notes", Map.of());
        return formatResult(result);
    }

    @Tool(description = "Get the full content of a note by its ID.")
    public String getNote(
            @ToolArg(description = "The note ID") String id) throws Exception {
        Map<String, String> result = bridge.sendCommand("get_note", Map.of("id", id));
        return formatResult(result);
    }

    @Tool(description = "Create a new note with the given title and content.")
    public String createNote(
            @ToolArg(description = "The note title") String title,
            @ToolArg(description = "The note content") String content) throws Exception {
        Map<String, String> result = bridge.sendCommand("create_note",
                Map.of("title", title, "content", content));
        return formatResult(result);
    }

    @Tool(description = "Search notes by keyword. Searches both titles and content.")
    public String searchNotes(
            @ToolArg(description = "The search query") String query) throws Exception {
        Map<String, String> result = bridge.sendCommand("search_notes", Map.of("query", query));
        return formatResult(result);
    }

    @Tool(description = "Navigate the Note Manager GUI to display a specific note. "
            + "Brings the window to the front.")
    public String showNote(
            @ToolArg(description = "The note ID to display") String id) throws Exception {
        // Use deep link because this command should bring the app to the foreground
        Map<String, String> result = bridge.sendCommandViaDeepLink("show_note", Map.of("id", id));
        return formatResult(result);
    }

    @Tool(description = "Export a note to a file. The note is written as Markdown.")
    public String exportNote(
            @ToolArg(description = "The note ID to export") String id,
            @ToolArg(description = "The file path to write to") String path) throws Exception {
        Map<String, String> result = bridge.sendCommand("export_note",
                Map.of("id", id, "path", path));
        return formatResult(result);
    }

    private static String formatResult(Map<String, String> result) {
        StringBuilder sb = new StringBuilder("{");
        boolean first = true;
        for (Map.Entry<String, String> entry : result.entrySet()) {
            if (!first) sb.append(", ");
            first = false;
            sb.append("\"").append(entry.getKey()).append("\": ");
            sb.append("\"").append(escapeJson(entry.getValue())).append("\"");
        }
        sb.append("}");
        return sb.toString();
    }

    private static String escapeJson(String s) {
        if (s == null) return "";
        return s.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r")
                .replace("\t", "\\t");
    }
}

Key points:

  • sendCommand() processes in the background — the GUI stays in the background while processing data queries

  • sendCommandViaDeepLink() brings the GUI to the foreground — used for show_note which should display something to the user

  • The bridge automatically falls back to deep links if the GUI isn’t running

Each tool returns a JSON string that the AI agent can parse. The tool descriptions are important — they tell the AI agent what each tool does and when to use it, just as an AppleScript dictionary describes an application’s commands.

Final Project Structure

After adding all the code, your project structure should look like this:

note-manager/
├── .github/
│   └── workflows/
│       └── jdeploy.yml
├── src/
│   └── main/
│       ├── java/
│       │   └── com/example/notemanager/
│       │       ├── NoteManager.java       # Entry point
│       │       ├── NoteManagerApp.java    # JavaFX GUI
│       │       ├── model/
│       │       │   ├── Note.java          # Note data model
│       │       │   └── NoteStore.java     # In-memory store
│       │       └── mcp/
│       │           └── NoteManagerTools.java  # MCP tool definitions
│       └── resources/
│           └── application.properties
├── build.gradle
├── package.json
└── ...

Trying It Out

Local Testing

For local development and testing, you can build and install directly without publishing:

./gradlew buildUberJar
jdeploy install

This installs the app locally, adds the MCP command to your PATH, and configures the MCP server in your AI tools.

Using It with an AI Agent

Launch the Note Manager GUI, then open your AI tool. The MCP tools are automatically available. Try conversations like:

Create a note called "Meeting Notes" with the content
"Discuss Q4 budget, review hiring pipeline, plan offsite."
Search my notes for "budget"
Show me the Meeting Notes note in the app.
Export all notes about "project" to ~/Desktop/notes/

The AI agent calls the MCP tools, which communicate with the running GUI, and the GUI responds with structured data. The AI never needs to see the screen or simulate clicks — it works with the application’s semantic API.

Publishing to GitHub

The Quarkus MCP Server template includes a GitHub Actions workflow (.github/workflows/jdeploy.yml) that automatically builds native app installer bundles when you create a release. This means your users can download and install the Note Manager — complete with MCP server integration — from your GitHub releases page.

Step 1: Push to GitHub

Initialize a Git repository (if you haven’t already) and push your project to the GitHub repository you specified during project creation:

cd note-manager
git init
git add .
git commit -m "Initial commit: Note Manager with MCP support"
git remote add origin https://github.com/yourusername/note-manager.git
git push -u origin master

Step 2: Verify the Build

Before creating a release, verify that your project builds locally:

./gradlew buildUberJar

This should produce build/note-manager-1.0.0-SNAPSHOT-runner.jar.

Step 3: Create a Release

The GitHub Actions workflow is triggered when you push a tag that starts with v. Create and push a tag for your first release:

git tag v1.0.0
git push origin v1.0.0

Alternatively, you can create the release through the GitHub web UI:

  1. Go to your repository on GitHub

  2. Click Releases in the right sidebar

  3. Click Create a new release

  4. Enter v1.0.0 as the tag (select "Create new tag on publish")

  5. Enter a release title (e.g., "Note Manager 1.0.0")

  6. Click Publish release

Screenshot: GitHub release creation page with tag v1.0.0 and title "Note Manager 1.0.0"
Figure 8. Creating a release on GitHub

Step 4: Wait for the Build

The GitHub Actions workflow will automatically:

  1. Check out your code

  2. Set up JDK 21

  3. Build the uber-jar with Gradle

  4. Run the jDeploy action to build native installer bundles for Windows, macOS, and Linux

  5. Attach the installer bundles to the GitHub release

You can monitor the build progress in the Actions tab of your repository.

Screenshot: GitHub Actions tab showing the jDeploy CI workflow in progress
Figure 9. The jDeploy CI workflow running after a release is created

Step 5: Download and Install

Once the workflow completes, the release page will have installer downloads for each platform. Users can download the appropriate installer for their OS and run it.

Screenshot: GitHub release page showing attached installer bundles for Windows
Figure 10. The release page with installer bundles attached

During installation, the jDeploy installer will:

  1. Install the Note Manager GUI application

  2. Add the note-manager-mcp command to the user’s PATH

  3. Detect installed AI tools and offer to configure the MCP server

Screenshot: jDeploy installer showing the "Install AI Integrations" checkbox with AI tools detected
Figure 11. The installer detects AI tools and offers to configure MCP

Alternative Approaches

The deep linking approach is natural for jDeploy apps because it uses infrastructure that jDeploy already provides. But it’s worth knowing the alternatives and their trade-offs.

Embedded HTTP Server in the GUI

Instead of deep links, the JavaFX app could start an embedded HTTP server (using Javalin, com.sun.net.httpserver, or similar) and the MCP server could make HTTP calls to it.

Drawbacks:

  • Security surface — Opens a network port on the user’s machine. Corporate firewalls and endpoint security tools may flag or block it.

  • Port conflicts — Need to handle the case where the chosen port is already in use.

  • Additional dependencies — Requires an HTTP server library in the GUI app.

  • No wake-up mechanism — If the GUI isn’t running, the MCP server has no way to start it. You’d need to add deep linking anyway for that.

  • Doesn’t leverage jDeploy — Ignores the IPC infrastructure that jDeploy already provides.

Raw Socket or Unix Domain Socket

The GUI app listens on a socket; the MCP server connects to it.

Drawbacks:

  • Platform-specific behavior — Unix domain sockets aren’t available on older Windows versions. TCP sockets have the same port/firewall issues as HTTP.

  • Custom protocol — You need to design and implement a wire protocol, handle framing, buffering, and error recovery.

  • Harder to debug — Can’t inspect traffic with standard tools the way you can with HTTP or JSON files.

  • No wake-up mechanism — Same as HTTP: no way to launch the GUI if it’s not running.

Vision-Based Automation (Computer Use)

AI agents like Anthropic’s Computer Use can screenshot the desktop and simulate mouse/keyboard input to control any application.

Drawbacks:

  • Fragile — Breaks when the UI changes: different theme, font size, screen resolution, or OS version.

  • Slow — Each interaction requires a screenshot, a vision model inference, and simulated input. A single tool call can take seconds.

  • Blind to hidden state — Can only see what’s on screen. Can’t query notes that are scrolled out of view, count items in a list, or access data that isn’t currently rendered.

  • No structured data — Returns pixels, not JSON. The AI must OCR the screen to extract text, with all the errors that implies.

  • Expensive — Vision model calls per interaction add up quickly.

File Watcher Without Deep Linking

The GUI app watches a directory for command files using java.nio.file.WatchService.

Drawbacks:

  • No wake-up — If the GUI isn’t running, commands sit in the directory with no one listening.

  • Polling latency — WatchService latency varies by OS (up to 10 seconds on some platforms).

  • No guaranteed delivery — If the GUI crashes between detecting a file and processing it, the command is lost.

Our Approach: The GUI Bridge Library

The jdeploy-desktop-lib-bridge module avoids all these drawbacks:

  • Background processing — Data queries don’t interrupt the user or steal focus

  • Automatic wake-up — If the GUI isn’t running, it’s launched automatically

  • Foreground when needed — Commands like show_note bring the GUI to front

The bridge library handles all this automatically. Call sendCommand() for background operations and sendCommandViaDeepLink() when you want to bring the GUI to front.

Summary

In this tutorial we built a JavaFX Note Manager application that is fully scriptable by AI agents via MCP. The key components:

  1. jDeploy multi-modal packaging — A single package.json declares both the GUI app and the MCP server command

  2. GUI Bridge library — The jdeploy-desktop-lib-bridge module handles all IPC complexity with GuiBridge (sender) and GuiBridgeReceiver (receiver)

  3. Background and foreground modes — Data queries stay in the background; UI commands bring the app to front

  4. Singleton mode — Ensures commands route to the running instance

  5. Quarkus MCP tools — Six @Tool-annotated methods that AI agents discover automatically

  6. Semantic commands — Tools that expose what the app can do, not how to click its UI

The library-based approach means you don’t need to write any IPC code yourself. Just implement a command handler in your GUI and use GuiBridge in your MCP tools.

The pattern is reusable. Any JavaFX application deployed with jDeploy can adopt this approach to become scriptable by AI agents — bringing the power of AppleScript-style automation to the cross-platform Java desktop, powered by MCP.

See Also