3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM と人間の両方が使えるデスクトップアプリを作ろう【MCP, Electron, React】

Last updated at Posted at 2025-05-16

はじめに

この記事では、次の動画に示すようなデスクトップ Todo アプリの作り方を解説します。

この Todo アプリは人間と LLM の両方が操作できます:

  • (人間側)アプリ画面で Todo 一覧を確認でき、マウスとキーボードでタスクを追加・完了できる
  • (LLM 側)MCP クライアントを利用して Todo アプリが提供する MCP サーバにアクセスすることにより、タスクを確認・追加・完了できる

本記事で使用するソースコードを https://github.com/jajimajp/godo に公開しています。

プロジェクトの作成

今回作るアプリの名前は人間と LLM の "合同" 作業と Todo をもじって Godo とします。

まずはプロジェクトのディレクトリを作成して npm init を実行します。

mkdir godo && cd godo && npm init

npm init で質問される内容のうち、entry point は main.js としてください。それ以外の値は今回のアプリの動作に関係しないので自由に埋めても空欄のままにしても構いません。

次に、必要な npm パッケージを追加します。

npm install @modelcontextprotocol/sdk express react react-dom zod
npm install --save-dev electron esbuild

Electron ウィンドウを立ち上げる

(参考: Electron チュートリアル

Electron はデスクトップアプリ開発のためのフレームワークで、Web ページの作成に使う技術(HTML, CSS, JavaScript)で開発できるという特徴があります。

まずは表示用のページを作成します。

touch index.html
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <meta
      http-equiv="X-Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <title>Godo - LLM もあなたも使える Todo アプリ</title>
  </head>
  <body>
    <h1>こんにちは 👋</h1>
  </body>
</html>

そして、main.js に Electron の起動処理を書きます。

touch main.js
main.js
const { app, BrowserWindow } = require('electron')

app.whenReady().then(() => {
  const win = new BrowserWindow({
    width: 600,
    height: 600,
  })

  win.loadFile('index.html')
})

最後に、package.json を編集して npm start の実行時に electron が実行されるようにします。

package.json
   "main": "main.js",
   "scripts": {
+    "start": "electron .",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "repository": {

次のコマンドをターミナルで実行します。

npm start

次のようなウィンドウが出れば成功です。

Electron ウィンドウのスクリーンショット。画面内には「こんにちは」に続けて手を振る絵文字が表示されている。

デスクトップアプリの表示部分を実装する

Electron ウィンドウが立ち上げられるようになったので、React を使って Todo の入力フォームとタスクの一覧画面を作ります。まずはタスクの追加や更新処理を後回しにしてテキストボックスやボタンの配置を行います。後のステップでアプリが実際に動作するようにします。

画面表示の実装として renderer.jsx を作成します。

touch renderer.jsx
renderer.jsx
import React from 'react'
import { createRoot } from 'react-dom/client'

const App = () => {
  const todos = [
    { id: 1, title: 'タスク1', completed: true  },
    { id: 2, title: 'タスク2', completed: false },
    { id: 3, title: 'タスク3', completed: false },
  ]

  const addTodo = () => { alert('addTodo: まだ実装されていません!') }
  const completeTodo = () => { alert('completeTodo: まだ実装されていません!') }

  return (
    <>
      <h1>やること & やったこと</h1>
      <form action={addTodo}>
        <input type="text" name="title" placeholder="新しくやること..." required />
        <button type="submit">追加</button>
      </form>
      <ul>
        {
          todos.map(({ id, title, completed }) => (
            <li key={id}>
              <label>
                <input
                  type="checkbox"
                  checked={completed}
                  onChange={() => completeTodo(id)}
                />
                {title}
              </label>
            </li>
          ))
        }
      </ul>
    </>
  )
}

const root = createRoot(document.getElementById('root'))
root.render(<App />)

このアプリでは、1つの Todo タスクを { id: 1, title: 'タスク1', completed: true } のようなデータで表すことにします。id はタスクごとに固有の値、title はタスク名、completed はタスクが完了済みかどうかを表します。

これを Electron のウィンドウで表示するために、package.jsonindex.html をそれぞれ少しずつ変更します。

package.json
   "main": "main.js",
   "scripts": {
     "start": "electron .",
+    "build:frontend": "esbuild --bundle renderer.jsx --outdir=dist",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "repository": {

上記の変更では、esbuild を使って renderer.jsx をビルドするためのスクリプトを定義しています。次のコマンドを実行してください。

npm run build:frontend

これを実行することで、dist/renderer.js が生成されます。dist/renderer.js は、renderer.jsx を純粋な JavaScript のファイルに変換するとともに react 等の依存ライブラリを含めた単一のファイルです。

次の変更によって、dist/renderer.js が読み込まれて Todo アプリの画面が表示されるようになります。

index.html
       http-equiv="X-Content-Security-Policy"
       content="default-src 'self'; script-src 'self'"
     />
+    <script defer src="./dist/renderer.js"></script>
     <title>Godo - LLM もあなたも使える Todo アプリ</title>
   </head>
   <body>
-    <h1>こんにちは 👋</h1>
+    <div id="root"></div>
   </body>
 </html>

再度 npm start を実行しましょう。変更内容が正しければ次のようなアプリが表示されます。まだ Todo の追加や完了の操作をすることはできません。

スクリーンショット 2025-05-15 22.34.01.png

renderer.jsx を変更した際には、npm run build:frontend を再度実行しないとアプリに変更が反映されないことに注意してください。

Todos モデルの作成

次に、Todo の作成、追加、更新処理を行うためのクラスを作成します。Todo の内容は React のコンポーネントだけでなく MCP サーバからも扱える必要があるので、renderer.jsx とは別の todosModel.js に作成します。

touch todosModel.js
todosModel.js
class TodosModel {
  constructor() {
    this.todos = [] // { id, title, completed } の配列
    this.nextId = 1 // 次に追加する Todo の id
  }

  // 全ての Todo の配列を返す
  list() {
    return this.todos
  }

  // 名前が title の Todo を新たに追加して、追加した Todo を返す
  add(title) {
    const newTodo = { title, id: this.nextId, completed: false }
    this.todos.push(newTodo)
    this.nextId += 1

    return newTodo
  }

  // 指定された id の Todo を完了済みにする
  complete(id) {
    const todo = this.todos.find((todo) => todo.id === id)
    if (todo) { todo.completed = true }
  }
}

module.exports = { TodosModel }

上記の TodosModel は次のように使うことができます。

$ node
> const { TodosModel } = require('./todosModel')
> const todosModel = new TodosModel()
> todosModel.add('タスク')
{ title: 'タスク', id: 1, completed: false }
> todosModel.complete(1)
> todosModel.list()
[ { title: 'タスク', id: 1, completed: true } ]

Todos モデルと React アプリの接続

これまでに作った内容を繋ぎ合わせて、Todos モデルで管理している Todo を React アプリで表示・変更できるようにします。ただし、MCP サーバでも Todo を利用したいので、TodosModel のインスタンスは main.js で定義するようにして、renderer.jsxmain.js と通信することで Todo 一覧を扱えるようにします。

  • main → renderer
    • タスクが変更されたときに新しいタスク一覧を renderer に送信する
  • renderer → main
    • 現在のタスク一覧の取得やタスクの追加、完了の要求を main に送信する

これを実現するために、Electron が提供する ipcMainipcRenderer を利用します。詳しくはプロセス間通信に関する Electron のドキュメントを参考にしてください。

先に renderer.jsx 側で利用する機能を preload.js に作成します。

touch preload.js
preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('todosClient', {
  list: () => ipcRenderer.invoke('list'),
  add: (title) => ipcRenderer.invoke('add', title),
  complete: (id) => ipcRenderer.invoke('complete', id),

  subscribeOnUpdate: (callback) => {
    const wrappedFn = (_, value) => callback(value)
    ipcRenderer.on('update', wrappedFn)

    const unsubscribe = () => { ipcRenderer.off('update', wrappedFn) }
    return unsubscribe
  },
})

ipcRenderer を使う処理を renderer.jsx に直接書かないのはセキュリティ上の理由によります。概要がElectron チュートリアル(プリロードスクリプトの利用) に記載されています。

次に、main.js 内で ipcMain を使う処理を書きます。また、preload.js の内容が renderer.jsx で利用できるように設定します。

main.js
-const { app, BrowserWindow } = require('electron')
+const { app, BrowserWindow, ipcMain } = require('electron')
+const  path = require('node:path')
+const { TodosModel } = require('./todosModel')
+
+const todosModel = new TodosModel() // ①

 app.whenReady().then(() => {
   const win = new BrowserWindow({
     width: 600,
     height: 600,
+    webPreferences: { // ②
+      preload: path.join(__dirname, 'preload.js'),
+    },
   })

+  // ③
+  const todosApi = {
+    list: () => todosModel.list(),
+    add: (title) => {
+      const newTodo = todosModel.add(title)
+      win.webContents.send('update', todosModel.list())
+      return newTodo
+    },
+    complete: (id) => {
+      todosModel.complete(id)
+      win.webContents.send('update', todosModel.list())
+    }
+  }
+  
+  // ④
+  ipcMain.handle('list', () => todosApi.list())
+  ipcMain.handle('add', (_event, title) => todosApi.add(title))
+  ipcMain.handle('complete', (_event, id) => todosApi.complete(id))
+
   win.loadFile('index.html')
 })
  • TodoModel のインスタンスを作ります
  • preload.js の読み込み設定です。この記述をすることで preload.js の内容がレンダラー側の JavaScript 実行時に含まれるようになるため、例えば renderer.jsx 内で todosClient.list() が実行できます
  • ③ todos モデルの操作を提供します。Todo 一覧の取得、追加、完了機能を提供しており、Todo 一覧が変更されたときに 'update' メッセージを送信するようになっています。参考: Electron チュートリアル(メインからレンダーへ)
  • ④ プロセス間通信のメインプロセス側の実装です。preload.js で定義したそれぞれの ipcRenderer.invoke の呼び出しに対応しています

以上の変更によって、renderer.jsx 側で todosClient が利用できるようになっています。todosClient を実際に使って動的な Todo アプリに修正しましょう。

renderer.jsx
 import React from 'react'
 import { createRoot } from 'react-dom/client'
 
-const App = () => {
+const App = ({ initialTodos }) => {
-  const todos = [
-    { id: 1, title: 'タスク1', completed: true  },
-    { id: 2, title: 'タスク2', completed: false },
-    { id: 3, title: 'タスク3', completed: false },
-  ]
+  const [todos, setTodos] = React.useState(initialTodos)
 
+  React.useEffect(() => {
+    const callback = (todos) => setTodos(todos)
+    const unsubscribe = todosClient.subscribeOnUpdate(callback)
+    return unsubscribe
+  }, [setTodos, todosClient])
+
-  const addTodo = () => { alert('addTodo: まだ実装されていません!') }
+  const addTodo = (formData) => {
+    const title = formData.get('title')
+    todosClient.add(title)
+  }
-  const completeTodo = () => { alert('completeTodo: まだ実装されていません!') }
+  const completeTodo = (id) => { todosClient.complete(id) }
 

(...中略...)

 
 const root = createRoot(document.getElementById('root'))
-root.render(<App />)
+todosClient.list().then((todos) => root.render(<App initialTodos={todos} />))

まず、App コンポーネントの引数を変更して最初の todo 一覧 (initialTodos)を受け取るようにします。renderer.jsx の一番下を見るとわかりますが、ここには todosClient.list() の結果が入るようになっています。今の main.js で作った todosModel では Todo 一覧にひとつも Todo を持たないため、[] が渡されることになります。

React.useEffect を使っている部分は複雑に感じる人もいるかもしれません。まず、const callback = ... では、main.js の Todo 一覧が変更されるたびに実行される関数を定義して、次の行で todosClient.subscribeOnUpdate を使って登録しています。subscribeOnUpdate の戻り値は関数(unsubscribe)で、これを実行すると callback の登録を解除します。unsubscribe を return することによって、App コンポーネントの再読み込みや破棄時に unsubscribe が実行されるようになります。このような実装によって、App コンポーネントが表示されている間は Todo 一覧の変更に対応できるようになっています。

例えば、新しい Todo の名前を入力して「追加」ボタンを押したときには、次の流れで変更が反映されます:

  1. <form> に書かれた action の記述によって、addTodo が実行される
  2. addTodo では todosClient.add() を呼び出す
  3. preload.js の定義によって ipcRenderer.invoke('add', title) が実行される
  4. main.js に書いた ipcMain.handle('add', ...) が実行される。この関数が実行時に 'update' メッセージをレンダラーに送信する
  5. renderer.jsxuseEffect 内で定義した callback が実行されて、setTodos に新しい todos がセットされる。これによって App コンポーネントの表示が更新される

忘れずに npm run build:frontend を実行して、npm start で画面を立ち上げましょう。Todo の追加と完了ができるようになっていれば成功です。このアプリでは Todo の完了取消機能は持たないので、一度つけたチェックを外せないことは想定内の挙動です。

スタイルを設定する

次のようにスタイルを設定します。

touch style.css
style.css
html {
  font-size: 16px;
}

body {
  max-width: 400px;
  margin: 0 auto;
  padding: 24px 0;
  background: #eee;
}

h1 {
  font-size: 1.4rem;
  font-weight: normal;
}

form {
  width: 100%;
  position: relative;
  border-radius: 16px;
  box-shadow: 1px 2px 2px #888;

  input[type="text"] {
    box-sizing: border-box;
    font-size: inherit;
    width: 100%;
    border: 1px solid #333;
    padding: 12px 12px 60px 12px;
    border-radius: 16px;
    background: #f8f8f8;
  }

  button {
    font-weight: bold;
    font-size: inherit;
    color: white;
    background: #3273f6;
    border: none;

    position: absolute;
    right: 10px;
    bottom: 10px;
    padding: 4px 16px;
    border-radius: 8px;
    cursor: pointer;
  }
}

ul {
  display: flex;
  flex-direction: column;
  gap: 8px;
  list-style: none;
  padding: 0;
}

li {
  border-radius: 12px;
}

label {
  display: flex;
  gap: 6px;
  box-sizing: border-box;
  width: 100%;
  box-shadow: 1px 2px 2px #888;
  border: 1px solid #888;
  border-radius: 12px;
  padding: 8px;
  cursor: pointer;
  background: #f8f8f8;

  &:hover {
    text-decoration: line-through;
    filter: brightness(95%);
  }

  &:has(input:checked) {
    text-decoration: line-through;
    cursor: initial;

    &:hover {
      filter: initial;
    }
  }
}

index.htmlstyle.css を読み込みます。

index.html
       content="default-src 'self'; script-src 'self'"
     />
     <script defer src="./dist/renderer.js"></script>
+    <link href="./style.css" rel="stylesheet" />
     <title>Godo - LLM もあなたも使える Todo アプリ</title>
   </head>
   <body>

ここまでの実装で人間向けの Todo アプリは完成です 🎉

スクリーンショット 2025-05-16 21.26.38.png

MCP サーバを実装する

最後に、デスクトップアプリの立ち上げと同時に MCP サーバを起動するようにします。これによって LLM から Todo 一覧を確認・操作できるようになります。

MCP サーバは次の方針で実装します。

  • MCP TypeScript SDK ライブラリの Streamable HTTP 方式を採用
  • express を使用して、ローカルホストの 3000 番に MCP サーバエンドポイントを公開する
  • サーバを立ち上げるために、startMcpServer() 関数を作って、main.js からこれを呼ぶことでサーバを立ち上げられるようにする。startMcpServer は引数に todosApi を受け取るようにすることで、アプリ共通の Todo 一覧を操作できるようにする

実装は次のようになります。実装にあたって MCP TypeScript SDK README.md の記述を参考にしています。

touch mcpServer.js
mcpServer.js
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js')
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js')
const { z } = require('zod')
const express = require('express')

const startMcpServer = (todosApi) => {
  const server = new McpServer({ name: 'godo', version: '1.0.0' })

  server.tool('list-all-todos',
    'Todo 一覧を取得する',
    {},
    () => {
      const todos = todosApi.list()
      return { content: [{ type: 'text', text: JSON.stringify(todos) }] }
    }
  )

  server.tool('add-new-todo',
    'Todo を追加する',
    { title: z.string() },
    ({ title }) => {
      const newTodo = todosApi.add(title)
      return { content: [{ type: 'text', text: JSON.stringify({ newTodo }) }] }
    }
  )

  server.tool('complete-todo',
    'Todo を完了済みにする',
    { id: z.number() },
    ({ id }) => {
      todosApi.complete(id)
      return { content: [{ type: 'text', text: `Completed todo (id: ${id})` }] }
    }
  )

  const app = express()
  app.use(express.json())

  app.post('/mcp', async (req, res) => {
    try {
      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined,
      })
      res.on('close', () => {
        transport.close()
        server.close()
      })
      await server.connect(transport)
      await transport.handleRequest(req, res, req.body)
    } catch (error) {
      console.error('Error handling MCP request:', error)
      if (!res.headersSent) {
        res.status(500).json({
          jsonrpc: '2.0',
          error: {
            code: -32603,
            message: 'Internal server error',
          },
          id: null,
        })
      }
    }
  })

  const PORT = 3000
  app.listen(PORT, () => {
    console.log(`MCP HTTP Server listening on port ${PORT}`)
  })
}

module.exports = { startMcpServer }

コード中で定義している server が MCP サーバです。server.tool() を呼ぶことでツールを登録しています。server.tool() の第1引数にはツール名、第2引数には説明文、第3引数には入力のスキーマ、第4引数にツールの動作を表す関数を入力しています。スキーマの記述には Zod を利用しています。

server.tool では入力のスキーマ以外にも、出力のスキーマやツールのアノテーションを追加することができます。これらを利用することによって LLM がより適切にツールを使えるようになる可能性があります。
参考:

コード中で定義している app が express アプリの本体です。POST リクエストに対する処理を記述しています。最後に 3000 番ポートで HTTP サーバを立ち上げます。

本記事の利用ケース(VSCode からの利用)では、筆者の確認の限りでは POST のハンドルのみで問題なく動作しますが、より多くの MCP クライアントから利用する場合には GET, DELETE 等のリクエストをハンドルするようにするのが適切でしょう。利用しない種類のリクエストメソッドについてはエラー(Method not allowed)を返すようにします。本記事のリファレンス実装ではこれらのメソッドに対応する関数を定義しています。

startMcpServer を使うコードを main.js に追加すれば実装は完了です。

main.js
 const { app, BrowserWindow, ipcMain } = require('electron')
 const  path = require('node:path')
 const { TodosModel } = require('./todosModel')
+const { startMcpServer } = require('./mcpServer')


(...中略...)


   ipcMain.handle('complete', (_event, id) => todosApi.complete(id))

   win.loadFile('index.html')
+  startMcpServer(todosApi)
 })

GitHub Copilot からアプリを使用する

VS Code の GitHub Copilot agent mode を利用して、作成した Todo アプリを LLM から利用してみます。

2025/5/16 現在では、通常の VS Code の MCP 設定からでは Streamable HTTP 方式を利用することができないようです。従って、新機能が取り込まれている(、そして安定版ではないのでバグがあるかもしれない)Visual Studio Code Insiders を利用します。VS Code Insiders をインストールし、GitHub Copilot を設定して Copilot Chat が利用できるようにしてください。

MCP サーバの設定を登録します。VSCode の settings.json を開いて、次の項目を記述してください。

settings.json
    "mcp": {
        "inputs": [],
        "servers": {
            "godo": {
                "type": "http",
                "url": "http://localhost:3000/mcp",
            }
        }
    }

settings.json の開き方がわからない場合、Qiita:VS Codeのsettings.jsonの開き方 を参考にしてください。または VS Code メニューの Preferences > Settings から、検索窓に「mcp」と入力して最初に出てくる「Mcp」項目の「Edit in settings.json」をクリックすることでも編集できます。

それでは Todo アプリを利用してみましょう。MCP サーバの利用時には npm start によるアプリを起動したままにしておいてください。

VS Code の settings.json で先ほど記載した項目の上にある「Start」ボタンをクリックしてください。

スクリーンショット 2025-05-16 23.29.04.png

Running が表示されたら無事 MCP サーバに接続できています。

スクリーンショット 2025-05-16 23.29.31.png

Copilot Chat を開いて、動作モードを「Agent」にしてください。この状態で Todo に関する話を Copilot Chat に入力したときに、適切に Todo アプリを利用できれば成功です ✅

すでに複数の MCP サーバを登録している場合、Copilot Chat がどの MCP サーバを利用するべきか適切に判断できない場合があります。そのような場合にはより具体的に今回の MCP サーバを利用するように指示したり、他の MCP サーバを停止または登録解除してみてください。

おわりに

本記事では LLM と人間の両方が利用できるデスクトップアプリの作り方を解説しました。本記事で作成したデスクトップアプリには、実装に含めたかったものの実装内容を小さくする観点から省いているものが多くあります。
キーワード:永続化、TypeScript、ホットリロード、コンポーネントのスタイリング(TailwindCSS, shadcn/ui)
これらを実装することや、 Todo アプリの機能を充実させることなども LLM / デスクトップアプリ開発の理解につながると思います。

本記事が読者のお役に立てれば幸いです。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?