VSCodeでMCPを使って、ノーコードでWebアプリ開発

# はじめに
ついにVSCodeでも簡単にMCPが使えるようになったので、試しにノーコード開発でtodoアプリを作ってみました。

https://code.visualstudio.com/docs/copilot/chat/mcp-servers

# 必要なもの
- VSCode
- Githubアカウント
- Docker（オプション）

# 開発手順
- node環境のdevcontainerをセットアップする
```json:devcontainer.json
{
	"name": "Node.js & TypeScript",
	"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm"
}
```
devcontainerを使用しない場合は、ローカルにnodeをインストールしてください。

- VSCodeでGithub Copilotをセットアップする

無料でも利用できますが、Githubアカウントが必要になります。

https://code.visualstudio.com/docs/copilot/setup

- `.vscode/mcp.json`ファイルに`filesystem`MCPサーバーの設定を書く
```json:mcp.json
{
    "servers": {
        "filesystem": {
            "type": "stdio",
            "command": "npx",
            "args": [
                "-y",
                "@modelcontextprotocol/server-filesystem",
                "${workspaceFolder}",
              ]
        }
    }
}
```
これにより、Github CopilotのAgentがワークスペースでファイルを読み書きできるようになります。

- Github Copilotのチャットを開き、Agentモードでtodoアプリの開発を依頼する
```text
todoアプリをhtml,css,jsで作ってください。
```

生成されたファイルが以下になります。
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Todoアプリ</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Todoリスト</h1>
        
        <div class="todo-input">
            <input type="text" id="todo-input" placeholder="タスクを入力...">
            <button id="add-button">追加</button>
        </div>
        
        <div class="filter-buttons">
            <button id="filter-all" class="active">すべて</button>
            <button id="filter-active">未完了</button>
            <button id="filter-completed">完了済み</button>
        </div>
        
        <ul id="todo-list"></ul>
        
        <div class="todo-footer">
            <span id="items-left">0 項目が残っています</span>
            <button id="clear-completed">完了済みを削除</button>
        </div>
    </div>
    
    <script src="script.js"></script>
</body>
</html>
```

```js:script.js
document.addEventListener('DOMContentLoaded', () => {
    // DOM elements
    const todoInput = document.getElementById('todo-input');
    const addButton = document.getElementById('add-button');
    const todoList = document.getElementById('todo-list');
    const filterAll = document.getElementById('filter-all');
    const filterActive = document.getElementById('filter-active');
    const filterCompleted = document.getElementById('filter-completed');
    const itemsLeft = document.getElementById('items-left');
    const clearCompleted = document.getElementById('clear-completed');
    
    // Load todos from localStorage
    let todos = JSON.parse(localStorage.getItem('todos')) || [];
    let filter = 'all';
    
    // Render todos
    const renderTodos = () => {
        todoList.innerHTML = '';
        
        const filteredTodos = todos.filter(todo => {
            if (filter === 'active') return !todo.completed;
            if (filter === 'completed') return todo.completed;
            return true;
        });
        
        filteredTodos.forEach((todo, index) => {
            const li = document.createElement('li');
            
            const todoItem = document.createElement('div');
            todoItem.classList.add('todo-item');
            if (todo.completed) {
                todoItem.classList.add('completed');
            }
            
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = todo.completed;
            checkbox.addEventListener('change', () => toggleComplete(todo.id));
            
            const todoText = document.createElement('span');
            todoText.textContent = todo.text;
            
            todoItem.appendChild(checkbox);
            todoItem.appendChild(todoText);
            
            const deleteBtn = document.createElement('button');
            deleteBtn.classList.add('delete-btn');
            deleteBtn.innerHTML = '×';
            deleteBtn.addEventListener('click', () => deleteTodo(todo.id));
            
            li.appendChild(todoItem);
            li.appendChild(deleteBtn);
            todoList.appendChild(li);
        });
        
        updateItemsLeft();
    };
    
    // Add a new todo
    const addTodo = () => {
        const text = todoInput.value.trim();
        if (text !== '') {
            const todo = {
                id: Date.now(),
                text,
                completed: false
            };
            
            todos.push(todo);
            saveTodos();
            todoInput.value = '';
            renderTodos();
        }
    };
    
    // Toggle todo completion status
    const toggleComplete = (id) => {
        todos = todos.map(todo => {
            if (todo.id === id) {
                return { ...todo, completed: !todo.completed };
            }
            return todo;
        });
        
        saveTodos();
        renderTodos();
    };
    
    // Delete a todo
    const deleteTodo = (id) => {
        todos = todos.filter(todo => todo.id !== id);
        saveTodos();
        renderTodos();
    };
    
    // Clear all completed todos
    const clearCompletedTodos = () => {
        todos = todos.filter(todo => !todo.completed);
        saveTodos();
        renderTodos();
    };
    
    // Save todos to localStorage
    const saveTodos = () => {
        localStorage.setItem('todos', JSON.stringify(todos));
    };
    
    // Update items left counter
    const updateItemsLeft = () => {
        const activeCount = todos.filter(todo => !todo.completed).length;
        itemsLeft.textContent = `${activeCount} 項目が残っています`;
    };
    
    // Set active filter
    const setFilter = (selectedFilter) => {
        filter = selectedFilter;
        
        // Update active class for filter buttons
        filterAll.classList.remove('active');
        filterActive.classList.remove('active');
        filterCompleted.classList.remove('active');
        
        if (selectedFilter === 'all') {
            filterAll.classList.add('active');
        } else if (selectedFilter === 'active') {
            filterActive.classList.add('active');
        } else if (selectedFilter === 'completed') {
            filterCompleted.classList.add('active');
        }
        
        renderTodos();
    };
    
    // Event listeners
    addButton.addEventListener('click', addTodo);
    todoInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') {
            addTodo();
        }
    });
    
    filterAll.addEventListener('click', () => setFilter('all'));
    filterActive.addEventListener('click', () => setFilter('active'));
    filterCompleted.addEventListener('click', () => setFilter('completed'));
    clearCompleted.addEventListener('click', clearCompletedTodos);
    
    // Initial render
    renderTodos();
});
```

```css:style.css
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: 'Helvetica', 'Arial', sans-serif;
}

body {
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
    padding: 20px;
}

.container {
    max-width: 600px;
    margin: 0 auto;
    background-color: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

h1 {
    text-align: center;
    margin-bottom: 20px;
    color: #2c3e50;
}

.todo-input {
    display: flex;
    margin-bottom: 20px;
}

#todo-input {
    flex: 1;
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px 0 0 4px;
    font-size: 16px;
}

#add-button {
    padding: 10px 15px;
    background-color: #3498db;
    color: #fff;
    border: none;
    border-radius: 0 4px 4px 0;
    cursor: pointer;
    font-size: 16px;
    transition: background-color 0.3s;
}

#add-button:hover {
    background-color: #2980b9;
}

.filter-buttons {
    display: flex;
    justify-content: center;
    margin-bottom: 15px;
}

.filter-buttons button {
    margin: 0 5px;
    padding: 5px 10px;
    background-color: transparent;
    border: 1px solid #ddd;
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.3s;
}

.filter-buttons button.active {
    background-color: #3498db;
    color: white;
    border-color: #3498db;
}

#todo-list {
    list-style-type: none;
}

#todo-list li {
    padding: 10px 15px;
    border-bottom: 1px solid #eee;
    display: flex;
    align-items: center;
    transition: background-color 0.2s;
}

#todo-list li:hover {
    background-color: #f9f9f9;
}

.todo-item input[type="checkbox"] {
    margin-right: 10px;
    cursor: pointer;
}

.todo-item.completed span {
    color: #999;
    text-decoration: line-through;
}

.todo-item {
    display: flex;
    align-items: center;
    flex: 1;
}

.delete-btn {
    color: #e74c3c;
    background: none;
    border: none;
    font-size: 16px;
    cursor: pointer;
    opacity: 0.7;
    transition: opacity 0.3s;
}

.delete-btn:hover {
    opacity: 1;
}

.todo-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-top: 15px;
    margin-top: 15px;
    border-top: 1px solid #eee;
    font-size: 14px;
}

#items-left {
    color: #7f8c8d;
}

#clear-completed {
    background: none;
    border: none;
    color: #7f8c8d;
    cursor: pointer;
    text-decoration: underline;
}

#clear-completed:hover {
    color: #e74c3c;
}
```

- ブラウザで開いて動作確認をする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3532049/80bf528c-6514-4a1c-8151-134514ae1424.png)

指示していない細かな部分もつくってくれていました。

# おわりに
今回は課金してClaude 3.7 Sonnetを使いましたが、無料版で使えるgpt-4oでもtodoアプリ程度であれば正しく実装できていました。
`filesystem`以外のMCPサーバーも追加していけば、VSCode上でもDevinやClineのようなAI駆動開発ができてしまいそうな可能性を感じました。

MCPサーバーの一覧はこちらから確認できます。

https://github.com/modelcontextprotocol/servers