はじめに
この記事では、次の動画に示すようなデスクトップ 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
<!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
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
が実行されるようにします。
"main": "main.js",
"scripts": {
+ "start": "electron .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
次のコマンドをターミナルで実行します。
npm start
次のようなウィンドウが出れば成功です。
デスクトップアプリの表示部分を実装する
Electron ウィンドウが立ち上げられるようになったので、React を使って Todo の入力フォームとタスクの一覧画面を作ります。まずはタスクの追加や更新処理を後回しにしてテキストボックスやボタンの配置を行います。後のステップでアプリが実際に動作するようにします。
画面表示の実装として renderer.jsx
を作成します。
touch 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.json
と index.html
をそれぞれ少しずつ変更します。
"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 アプリの画面が表示されるようになります。
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 の追加や完了の操作をすることはできません。
renderer.jsx
を変更した際には、npm run build:frontend
を再度実行しないとアプリに変更が反映されないことに注意してください。
Todos モデルの作成
次に、Todo の作成、追加、更新処理を行うためのクラスを作成します。Todo の内容は React のコンポーネントだけでなく MCP サーバからも扱える必要があるので、renderer.jsx
とは別の todosModel.js
に作成します。
touch 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.jsx
は main.js
と通信することで Todo 一覧を扱えるようにします。
- main → renderer
- タスクが変更されたときに新しいタスク一覧を renderer に送信する
- renderer → main
- 現在のタスク一覧の取得やタスクの追加、完了の要求を main に送信する
これを実現するために、Electron が提供する ipcMain と ipcRenderer を利用します。詳しくはプロセス間通信に関する Electron のドキュメントを参考にしてください。
先に renderer.jsx
側で利用する機能を preload.js
に作成します。
touch 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
で利用できるように設定します。
-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 アプリに修正しましょう。
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 の名前を入力して「追加」ボタンを押したときには、次の流れで変更が反映されます:
-
<form>
に書かれたaction
の記述によって、addTodo
が実行される -
addTodo
ではtodosClient.add()
を呼び出す -
preload.js
の定義によってipcRenderer.invoke('add', title)
が実行される -
main.js
に書いたipcMain.handle('add', ...)
が実行される。この関数が実行時に 'update' メッセージをレンダラーに送信する -
renderer.jsx
のuseEffect
内で定義したcallback
が実行されて、setTodos
に新しいtodos
がセットされる。これによってApp
コンポーネントの表示が更新される
忘れずに npm run build:frontend
を実行して、npm start
で画面を立ち上げましょう。Todo の追加と完了ができるようになっていれば成功です。このアプリでは Todo の完了取消機能は持たないので、一度つけたチェックを外せないことは想定内の挙動です。
スタイルを設定する
次のようにスタイルを設定します。
touch 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.html
で style.css
を読み込みます。
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 アプリは完成です 🎉
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
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
に追加すれば実装は完了です。
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 を開いて、次の項目を記述してください。
"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」ボタンをクリックしてください。
Running が表示されたら無事 MCP サーバに接続できています。
Copilot Chat を開いて、動作モードを「Agent」にしてください。この状態で Todo に関する話を Copilot Chat に入力したときに、適切に Todo アプリを利用できれば成功です ✅
すでに複数の MCP サーバを登録している場合、Copilot Chat がどの MCP サーバを利用するべきか適切に判断できない場合があります。そのような場合にはより具体的に今回の MCP サーバを利用するように指示したり、他の MCP サーバを停止または登録解除してみてください。
おわりに
本記事では LLM と人間の両方が利用できるデスクトップアプリの作り方を解説しました。本記事で作成したデスクトップアプリには、実装に含めたかったものの実装内容を小さくする観点から省いているものが多くあります。
キーワード:永続化、TypeScript、ホットリロード、コンポーネントのスタイリング(TailwindCSS, shadcn/ui)
これらを実装することや、 Todo アプリの機能を充実させることなども LLM / デスクトップアプリ開発の理解につながると思います。
本記事が読者のお役に立てれば幸いです。