# 群環智行 — MCP Server 設定腳本（setup-mcp.md）

> 本文件是一份可直接交給 Bob 執行的互動式安裝腳本。
> 將本文件放入你的工作區根目錄，然後在 Bob 的對話視窗中輸入：
>
> ```
> 請讀取工作區中的 setup-mcp.md 並依照腳本指示逐步執行
> ```
>
> **前置條件**：請確認已完成 `setup-workspace.md` 的設定，工作區的 `.bob/rules/` 資料夾已建立。
>
> Bob 將逐步說明每個步驟的用途，並詢問你的同意後才執行。
> 若你選擇不執行某個步驟，Bob 將略過並繼續下一步。

---

## 給 Bob 的執行指示

Bob，請依照以下規則執行本腳本：

1. **每個 Step 執行前**，先用簡單易懂的語言向使用者說明「這個步驟要做什麼、為什麼需要做」
2. **取得使用者明確同意**（回覆「是」、「Y」、「好」、「執行」等均視為同意）後才執行
3. 若使用者回覆「否」、「N」、「略過」、「跳過」等，則跳過此步驟，顯示「已略過 Step X，繼續下一步」
4. **每個步驟完成後**，回報執行結果，確認成功後才進入下一步
5. 若執行失敗，停下來回報錯誤訊息，不要繼續，等待使用者指示
6. 所有操作均在**目前工作區**進行，不修改任何全域（Global）設定

---

## Step 0：偵測作業系統（自動執行，不需詢問）

Bob 請告知使用者：「我將先偵測你的作業系統，確認是 Windows 還是 macOS，後續的安裝指令與設定路徑將根據此結果自動調整。」

然後根據環境執行對應指令：

**Windows（PowerShell）：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null;
[System.Runtime.InteropServices.RuntimeInformation]::OSDescription
```

**macOS（Terminal）：**
```bash
uname -s && sw_vers -productVersion
```

Bob 請回報偵測結果，並記住作業系統類型，後續 Step 1、Step 5、Step 6 的指令與路徑格式將根據此結果分支執行。

---

## Step 1：安裝通用 Python 套件

**Bob 請向使用者說明：**
「我需要安裝一些 Python 套件，這些套件是後續 MCP Server 工具的基礎依賴。安裝過程需要連接網路，已安裝的套件不會重複安裝。」

**若偵測到 Python 未安裝，Bob 請告知：**
「你的電腦尚未安裝 Python。請前往 https://www.python.org/downloads/ 下載安裝後，重新執行此腳本。」然後停止執行，等待使用者處理。

**取得同意後，Bob 請根據作業系統執行：**

**Windows（PowerShell）：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null; $env:PYTHONIOENCODING = "utf-8"; $env:PYTHONUTF8 = "1";
pip install --upgrade mcp pywin32
```

**macOS（Terminal）：**
```bash
export PYTHONIOENCODING=utf-8
pip3 install --upgrade mcp
```

---

## Step 2：建立 Playwright 設定檔

**Bob 請向使用者說明：**
「我將建立兩個 Playwright 設定檔。Playwright 是讓 Bob 能夠**控制 Chrome 瀏覽器**的工具，例如：自動開啟網頁、截圖、填寫表單等。這兩個設定檔會告訴 Playwright 使用哪種瀏覽器、以什麼身份瀏覽網頁（避免被網站偵測為機器人）。如果你不需要讓 Bob 操控瀏覽器，可以略過此步驟。」

**取得同意後，Bob 請建立以下兩個檔案：**

**檔案 1：`.bob/playwright-mcp-config.json`**

```json
{
  "browser": {
    "browserName": "chromium",
    "launchOptions": {
      "channel": "chrome",
      "args": [
        "--disable-blink-features=AutomationControlled",
        "--disable-infobars",
        "--no-first-run",
        "--no-default-browser-check"
      ]
    },
    "contextOptions": {
      "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
    }
  }
}
```

**檔案 2：`.bob/playwright-init-script.js`**

```javascript
// Remove Playwright/automation detection flags
Object.defineProperty(navigator, 'webdriver', {
  get: () => undefined,
});

if (window.chrome) {
  Object.defineProperty(window.chrome, 'runtime', {
    get: () => ({
      onMessage: { addListener: () => {} },
      sendMessage: () => {},
    }),
  });
}

Object.defineProperty(navigator, 'plugins', {
  get: () => [1, 2, 3, 4, 5],
});

Object.defineProperty(navigator, 'languages', {
  get: () => ['zh-TW', 'zh', 'en-US', 'en'],
});
```

---

## Step 3：設定 mcp-fetch（網頁擷取工具）

**Bob 請向使用者說明：**
「我將設定 `mcp-fetch`，這個工具讓 Bob 能夠**上網讀取你指定的網頁內容**，例如：查詢官方文件、讀取最新公告、分析某個網頁的資料。需要先確認 `uvx` 工具是否已安裝。」

**Bob 請先執行環境確認：**

**Windows：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null;
where.exe uvx 2>&1; uvx --version 2>&1
```

**macOS：**
```bash
which uvx 2>&1; uvx --version 2>&1
```

**若 uvx 未安裝，Bob 請告知使用者：**
「你的電腦尚未安裝 `uvx`。請執行以下指令安裝後再繼續：

Windows：`pip install uv`
macOS：`pip3 install uv` 或 `brew install uv`

安裝完成後請告訴我，我會繼續執行。」

**確認 uvx 已安裝且取得使用者同意後，Bob 請記錄 uvx 的完整路徑供 Step 6 的 mcp.json 使用。**

Bob 請執行以下指令取得 uvx 路徑：

**Windows：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null;
where.exe uvx
```

**macOS：**
```bash
which uvx
```

Bob 請將此路徑記錄為 `UVX_PATH`，供 Step 6 使用。

---

## Step 4：設定 Playwright MCP（瀏覽器自動化工具）

**Bob 請向使用者說明：**
「我將設定 `Playwright MCP`，這個工具讓 Bob 能夠**直接操控 Chrome 瀏覽器**，包括：點擊網頁元素、填寫表單、截圖等自動化操作。需要先確認 Node.js 是否已安裝（Playwright 需要 Node.js 才能運作）。如果你不需要讓 Bob 操控瀏覽器，可以略過此步驟。」

**Bob 請先執行環境確認：**

**Windows：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null;
node --version 2>&1; npx --version 2>&1
```

**macOS：**
```bash
node --version 2>&1; npx --version 2>&1
```

**若 Node.js 未安裝，Bob 請告知使用者：**
「你的電腦尚未安裝 Node.js。請前往 https://nodejs.org/zh-tw/ 下載 LTS 版本安裝後再繼續。安裝完成後請告訴我，我會繼續執行。」

**確認 Node.js 已安裝且取得使用者同意後，Bob 請記錄工作區根目錄的完整路徑：**

**Windows：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null;
(Get-Location).Path
```

**macOS：**
```bash
pwd
```

Bob 請將此路徑記錄為 `PROJECT_ROOT`，供 Step 6 使用。

---

## Step 5：設定 Office 文件工具

Bob 請根據 Step 0 偵測到的作業系統，執行對應的分支：

---

### Step 5-W（Windows）：建立 Office MCP Server

**Bob 請向使用者說明：**
「我將建立三個 Office 文件工具，讓 Bob 能夠直接讀取和編輯你電腦上的 **Word（.docx）**、**PowerPoint（.pptx）** 和 **Excel（.xlsx）** 檔案，不需要先手動開啟文件。這需要你的電腦已安裝 Microsoft Office。我會逐一詢問你要建立哪幾個工具。」

#### Step 5-W-A：Word 文件工具

**Bob 請向使用者說明：**「是否要建立 **Word 文件工具**？安裝後 Bob 可以讀取 .docx 檔案的內容，以及新增、修改段落文字。需要已安裝 Microsoft Word。」

**取得同意後，Bob 請建立檔案 `.bob/mcp-servers/word-mcp/server.py`，內容如下：**

```python
"""Word MCP Server — drives Microsoft Word via COM Automation (pywin32)."""

import os
import pythoncom
import win32com.client
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("word-mcp")


def _open_word(path: str):
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    pythoncom.CoInitialize()
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    doc = word.Documents.Open(abs_path)
    return word, doc


@mcp.tool()
def read_document(path: str) -> str:
    """Read the full text content of a .docx file, including paragraph styles."""
    word, doc = _open_word(path)
    try:
        lines = []
        for para in doc.Paragraphs:
            style = para.Style.NameLocal
            text = para.Range.Text.strip()
            lines.append(f"[{style}] {text}")
        return "\n".join(lines)
    finally:
        doc.Close(False)
        word.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def write_paragraph(path: str, text: str, style: str = "Normal", position: int = -1) -> str:
    """Append or insert a paragraph into a .docx document."""
    word, doc = _open_word(path)
    try:
        if position == -1 or position >= doc.Paragraphs.Count:
            rng = doc.Content
            rng.Collapse(0)
            rng.InsertParagraphAfter()
            rng.Collapse(0)
            rng.InsertAfter(text)
            rng.Style = doc.Styles(style)
        else:
            rng = doc.Paragraphs(position + 1).Range
            rng.InsertBefore(text + "\n")
            new_para = doc.Paragraphs(position + 1)
            new_para.Style = doc.Styles(style)
        doc.Save()
        return f"Paragraph inserted with style '{style}'."
    finally:
        doc.Close(False)
        word.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def replace_text(path: str, find: str, replace: str) -> str:
    """Find and replace all occurrences of a string throughout the document."""
    word, doc = _open_word(path)
    try:
        find_obj = doc.Content.Find
        find_obj.ClearFormatting()
        find_obj.Replacement.ClearFormatting()
        find_obj.Text = find
        find_obj.Replacement.Text = replace
        find_obj.Forward = True
        find_obj.Wrap = 1
        find_obj.Format = False
        find_obj.MatchCase = False
        find_obj.MatchWholeWord = False
        find_obj.Execute(Replace=2)
        doc.Save()
        return f"Replaced all occurrences of '{find}' with '{replace}'."
    finally:
        doc.Close(False)
        word.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def save_document(path: str, save_as: str = "") -> str:
    """Save the document to its current path or to a new path."""
    word, doc = _open_word(path)
    try:
        if save_as:
            abs_save = os.path.abspath(save_as)
            doc.SaveAs2(abs_save)
            return f"Document saved as: {abs_save}"
        else:
            doc.Save()
            return f"Document saved: {os.path.abspath(path)}"
    finally:
        doc.Close(False)
        word.Quit()
        pythoncom.CoUninitialize()


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

#### Step 5-W-B：PowerPoint 文件工具

**Bob 請向使用者說明：**「是否要建立 **PowerPoint 文件工具**？安裝後 Bob 可以讀取 .pptx 檔案的內容，以及更新投影片文字、新增投影片。需要已安裝 Microsoft PowerPoint。」

**取得同意後，Bob 請建立檔案 `.bob/mcp-servers/ppt-mcp/server.py`，內容如下：**

```python
"""PowerPoint MCP Server — drives Microsoft PowerPoint via COM Automation (pywin32)."""

import os
import pythoncom
import win32com.client
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("ppt-mcp")


def _open_ppt(path: str):
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    pythoncom.CoInitialize()
    ppt = win32com.client.Dispatch("PowerPoint.Application")
    ppt.Visible = True
    presentation = ppt.Presentations.Open(abs_path, WithWindow=False)
    return ppt, presentation


@mcp.tool()
def read_presentation(path: str) -> str:
    """Read the text content of all slides in a .pptx file."""
    ppt, prs = _open_ppt(path)
    try:
        output = []
        for i, slide in enumerate(prs.Slides, 1):
            output.append(f"--- Slide {i} ---")
            for shape in slide.Shapes:
                if shape.HasTextFrame:
                    for para in shape.TextFrame.TextRange.Paragraphs():
                        text = para.Text.strip()
                        if text:
                            output.append(text)
        return "\n".join(output)
    finally:
        prs.Close()
        ppt.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def update_slide_text(path: str, slide_index: int, shape_index: int, new_text: str) -> str:
    """Update the text of a specific shape on a specific slide."""
    ppt, prs = _open_ppt(path)
    try:
        slide = prs.Slides(slide_index)
        shape = slide.Shapes(shape_index)
        if not shape.HasTextFrame:
            raise ValueError(f"Shape {shape_index} on slide {slide_index} has no text frame.")
        shape.TextFrame.TextRange.Text = new_text
        prs.Save()
        return f"Slide {slide_index}, shape {shape_index} updated to: '{new_text}'"
    finally:
        prs.Close()
        ppt.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def add_slide(path: str, position: int, layout_index: int = 1) -> str:
    """Add a new slide at the specified position using the given layout."""
    ppt, prs = _open_ppt(path)
    try:
        layout = prs.SlideMaster.CustomLayouts(layout_index)
        prs.Slides.AddSlide(position, layout)
        prs.Save()
        return f"New slide added at position {position} using layout {layout_index}."
    finally:
        prs.Close()
        ppt.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def save_presentation(path: str, save_as: str = "") -> str:
    """Save the presentation to its current path or to a new path."""
    ppt, prs = _open_ppt(path)
    try:
        if save_as:
            abs_save = os.path.abspath(save_as)
            prs.SaveAs(abs_save)
            return f"Presentation saved as: {abs_save}"
        else:
            prs.Save()
            return f"Presentation saved: {os.path.abspath(path)}"
    finally:
        prs.Close()
        ppt.Quit()
        pythoncom.CoUninitialize()


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

#### Step 5-W-C：Excel 文件工具

**Bob 請向使用者說明：**「是否要建立 **Excel 文件工具**？安裝後 Bob 可以讀取 .xlsx 試算表的儲存格資料，以及寫入數值。需要已安裝 Microsoft Excel。」

**取得同意後，Bob 請建立檔案 `.bob/mcp-servers/excel-mcp/server.py`，內容如下：**

```python
"""Excel MCP Server — drives Microsoft Excel via COM Automation (pywin32)."""

import os
import pythoncom
import win32com.client
from mcp.server.fastmcp import FastMCP
from typing import Any

mcp = FastMCP("excel-mcp")


def _open_excel(path: str):
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    pythoncom.CoInitialize()
    excel = win32com.client.Dispatch("Excel.Application")
    excel.Visible = False
    excel.DisplayAlerts = False
    wb = excel.Workbooks.Open(abs_path)
    return excel, wb


@mcp.tool()
def list_sheets(path: str) -> str:
    """List all sheet names in an Excel workbook."""
    excel, wb = _open_excel(path)
    try:
        names = [wb.Sheets(i + 1).Name for i in range(wb.Sheets.Count)]
        return "\n".join(names)
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def read_sheet(path: str, sheet: str, cell_range: str = "") -> str:
    """Read cell data from a specified sheet, optionally limited to a range."""
    excel, wb = _open_excel(path)
    try:
        ws = wb.Sheets(sheet)
        rng = ws.Range(cell_range) if cell_range else ws.UsedRange
        values = rng.Value2
        if values is None:
            return ""
        if not isinstance(values, tuple):
            return str(values)
        rows = []
        for row in values:
            if isinstance(row, tuple):
                rows.append("\t".join("" if v is None else str(v) for v in row))
            else:
                rows.append("" if row is None else str(row))
        return "\n".join(rows)
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def read_cell(path: str, sheet: str, cell: str) -> str:
    """Read the value and number format of a single cell."""
    excel, wb = _open_excel(path)
    try:
        ws = wb.Sheets(sheet)
        c = ws.Range(cell)
        return f"Value: {c.Value2}, Format: {c.NumberFormat}"
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def write_cell(path: str, sheet: str, cell: str, value: Any) -> str:
    """Write a value to a single cell and save."""
    excel, wb = _open_excel(path)
    try:
        wb.Sheets(sheet).Range(cell).Value = value
        wb.Save()
        return f"Cell {cell} on sheet '{sheet}' set to: {value}"
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def write_cells(path: str, sheet: str, start_cell: str, data: list[list[Any]]) -> str:
    """Write a 2-D array of values starting from a given cell and save."""
    excel, wb = _open_excel(path)
    try:
        ws = wb.Sheets(sheet)
        num_rows = len(data)
        num_cols = max(len(row) for row in data) if data else 0
        if num_rows == 0 or num_cols == 0:
            return "No data provided."
        start = ws.Range(start_cell)
        end = ws.Cells(start.Row + num_rows - 1, start.Column + num_cols - 1)
        padded = [row + [None] * (num_cols - len(row)) for row in data]
        ws.Range(start, end).Value = padded
        wb.Save()
        return f"Wrote {num_rows}x{num_cols} block starting at {start_cell} on sheet '{sheet}'."
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def add_sheet(path: str, name: str, position: int = -1) -> str:
    """Add a new worksheet to the workbook."""
    excel, wb = _open_excel(path)
    try:
        if position == -1 or position > wb.Sheets.Count:
            new_sheet = wb.Sheets.Add(After=wb.Sheets(wb.Sheets.Count))
        else:
            new_sheet = wb.Sheets.Add(Before=wb.Sheets(position))
        new_sheet.Name = name
        wb.Save()
        return f"Sheet '{name}' added."
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


@mcp.tool()
def save_workbook(path: str, save_as: str = "") -> str:
    """Save the workbook to its current path or to a new path."""
    excel, wb = _open_excel(path)
    try:
        if save_as:
            abs_save = os.path.abspath(save_as)
            wb.SaveAs(abs_save)
            return f"Workbook saved as: {abs_save}"
        else:
            wb.Save()
            return f"Workbook saved: {os.path.abspath(path)}"
    finally:
        wb.Close(False)
        excel.Quit()
        pythoncom.CoUninitialize()


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

---

### Step 5-M（macOS）：建立 Office 文件工具（跨平台版）

**Bob 請向使用者說明：**
「macOS 版的 Microsoft Office 不支援 Windows 的 COM Automation 技術，因此我將改用跨平台的 Python 套件（`python-docx`、`python-pptx`、`openpyxl`）來建立 Office 文件工具。功能與 Windows 版相同，但不需要安裝 Microsoft Office 也能運作（可直接讀寫檔案）。我會逐一詢問你要建立哪幾個工具。」

**Bob 請先執行安裝（取得使用者同意後）：**

```bash
export PYTHONIOENCODING=utf-8
pip3 install --upgrade python-docx python-pptx openpyxl
```

#### Step 5-M-A：Word 文件工具（macOS）

**Bob 請向使用者說明：**「是否要建立 **Word 文件工具（macOS 版）**？安裝後 Bob 可以讀取和編輯 .docx 檔案，不需要安裝 Microsoft Word。」

**取得同意後，Bob 請建立檔案 `.bob/mcp-servers/word-mcp/server.py`，內容如下：**

```python
"""Word MCP Server — cross-platform implementation using python-docx."""

import os
from docx import Document
from docx.shared import Pt
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("word-mcp")


@mcp.tool()
def read_document(path: str) -> str:
    """Read the full text content of a .docx file, including paragraph styles.

    Args:
        path: Absolute or relative path to the .docx file.

    Returns:
        A text string with each paragraph on its own line, prefixed by its style name.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    doc = Document(abs_path)
    lines = []
    for para in doc.paragraphs:
        style = para.style.name
        text = para.text.strip()
        lines.append(f"[{style}] {text}")
    return "\n".join(lines)


@mcp.tool()
def write_paragraph(path: str, text: str, style: str = "Normal", position: int = -1) -> str:
    """Append or insert a paragraph into a .docx document.

    Args:
        path: Absolute or relative path to the .docx file.
        text: The paragraph text to insert.
        style: Word style name, e.g. 'Normal', 'Heading 1', 'Heading 2'.
        position: 0-based paragraph index to insert before. Use -1 to append at end.

    Returns:
        Confirmation message.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    doc = Document(abs_path)
    if position == -1 or position >= len(doc.paragraphs):
        para = doc.add_paragraph(text)
        para.style = doc.styles[style]
    else:
        from docx.oxml.ns import qn
        from copy import deepcopy
        import lxml.etree as etree
        new_para = doc.add_paragraph(text)
        new_para.style = doc.styles[style]
        ref_para = doc.paragraphs[position]._element
        ref_para.addprevious(new_para._element)
        doc.paragraphs[-1]._element.getparent().remove(new_para._element)
    doc.save(abs_path)
    return f"Paragraph inserted with style '{style}'."


@mcp.tool()
def replace_text(path: str, find: str, replace: str) -> str:
    """Find and replace all occurrences of a string throughout the document.

    Args:
        path: Absolute or relative path to the .docx file.
        find: The text to search for.
        replace: The replacement text.

    Returns:
        Confirmation message with count of replacements made.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    doc = Document(abs_path)
    count = 0
    for para in doc.paragraphs:
        if find in para.text:
            for run in para.runs:
                if find in run.text:
                    run.text = run.text.replace(find, replace)
                    count += 1
    doc.save(abs_path)
    return f"Replaced {count} occurrence(s) of '{find}' with '{replace}'."


@mcp.tool()
def save_document(path: str, save_as: str = "") -> str:
    """Save the document to its current path or to a new path.

    Args:
        path: Absolute or relative path to the .docx file to open.
        save_as: If provided, saves a copy to this new path. Otherwise saves in place.

    Returns:
        Confirmation message with the saved file path.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    doc = Document(abs_path)
    if save_as:
        abs_save = os.path.abspath(save_as)
        doc.save(abs_save)
        return f"Document saved as: {abs_save}"
    else:
        doc.save(abs_path)
        return f"Document saved: {abs_path}"


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

#### Step 5-M-B：PowerPoint 文件工具（macOS）

**Bob 請向使用者說明：**「是否要建立 **PowerPoint 文件工具（macOS 版）**？安裝後 Bob 可以讀取和編輯 .pptx 投影片，不需要安裝 Microsoft PowerPoint。」

**取得同意後，Bob 請建立檔案 `.bob/mcp-servers/ppt-mcp/server.py`，內容如下：**

```python
"""PowerPoint MCP Server — cross-platform implementation using python-pptx."""

import os
from pptx import Presentation
from pptx.util import Inches
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("ppt-mcp")


@mcp.tool()
def read_presentation(path: str) -> str:
    """Read the text content of all slides in a .pptx file.

    Args:
        path: Absolute or relative path to the .pptx file.

    Returns:
        A text string listing slide numbers and their text content.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    prs = Presentation(abs_path)
    output = []
    for i, slide in enumerate(prs.slides, 1):
        output.append(f"--- Slide {i} ---")
        for shape in slide.shapes:
            if shape.has_text_frame:
                for para in shape.text_frame.paragraphs:
                    text = para.text.strip()
                    if text:
                        output.append(text)
    return "\n".join(output)


@mcp.tool()
def update_slide_text(path: str, slide_index: int, shape_index: int, new_text: str) -> str:
    """Update the text of a specific shape on a specific slide.

    Args:
        path: Absolute or relative path to the .pptx file.
        slide_index: 1-based slide number.
        shape_index: 1-based shape number on that slide.
        new_text: The replacement text.

    Returns:
        Confirmation message.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    prs = Presentation(abs_path)
    slide = prs.slides[slide_index - 1]
    shape = slide.shapes[shape_index - 1]
    if not shape.has_text_frame:
        raise ValueError(f"Shape {shape_index} on slide {slide_index} has no text frame.")
    shape.text_frame.text = new_text
    prs.save(abs_path)
    return f"Slide {slide_index}, shape {shape_index} updated to: '{new_text}'"


@mcp.tool()
def add_slide(path: str, position: int, layout_index: int = 1) -> str:
    """Add a new slide at the specified position using the given layout.

    Args:
        path: Absolute or relative path to the .pptx file.
        position: 1-based position where the new slide will be inserted.
        layout_index: 1-based index of the slide layout to apply (default: 1).

    Returns:
        Confirmation message with the new slide's position.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    prs = Presentation(abs_path)
    layout = prs.slide_layouts[layout_index - 1]
    slide = prs.slides.add_slide(layout)
    # python-pptx always appends; move to target position via XML manipulation
    xml_slides = prs.slides._sldIdLst
    inserted = xml_slides[-1]
    xml_slides.remove(inserted)
    xml_slides.insert(position - 1, inserted)
    prs.save(abs_path)
    return f"New slide added at position {position} using layout {layout_index}."


@mcp.tool()
def save_presentation(path: str, save_as: str = "") -> str:
    """Save the presentation to its current path or to a new path.

    Args:
        path: Absolute or relative path to the .pptx file to open.
        save_as: If provided, saves a copy to this new path. Otherwise saves in place.

    Returns:
        Confirmation message with the saved file path.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    prs = Presentation(abs_path)
    if save_as:
        abs_save = os.path.abspath(save_as)
        prs.save(abs_save)
        return f"Presentation saved as: {abs_save}"
    else:
        prs.save(abs_path)
        return f"Presentation saved: {abs_path}"


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

#### Step 5-M-C：Excel 文件工具（macOS）

**Bob 請向使用者說明：**「是否要建立 **Excel 文件工具（macOS 版）**？安裝後 Bob 可以讀取和編輯 .xlsx 試算表，不需要安裝 Microsoft Excel。」

**取得同意後，Bob 請建立檔案 `.bob/mcp-servers/excel-mcp/server.py`，內容如下：**

```python
"""Excel MCP Server — cross-platform implementation using openpyxl."""

import os
from typing import Any
import openpyxl
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("excel-mcp")


@mcp.tool()
def list_sheets(path: str) -> str:
    """List all sheet names in an Excel workbook.

    Args:
        path: Absolute or relative path to the .xlsx file.

    Returns:
        Newline-separated list of sheet names.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path, read_only=True, data_only=True)
    names = wb.sheetnames
    wb.close()
    return "\n".join(names)


@mcp.tool()
def read_sheet(path: str, sheet: str, cell_range: str = "") -> str:
    """Read cell data from a specified sheet, optionally limited to a range.

    Args:
        path: Absolute or relative path to the .xlsx file.
        sheet: Sheet name to read.
        cell_range: Optional cell range like 'A1:D10'. If empty, reads the used range.

    Returns:
        Tab-separated table of cell values, one row per line.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path, read_only=True, data_only=True)
    ws = wb[sheet]
    rows = ws[cell_range] if cell_range else ws.iter_rows()
    result = []
    for row in rows:
        result.append("\t".join("" if c.value is None else str(c.value) for c in row))
    wb.close()
    return "\n".join(result)


@mcp.tool()
def read_cell(path: str, sheet: str, cell: str) -> str:
    """Read the value and number format of a single cell.

    Args:
        path: Absolute or relative path to the .xlsx file.
        sheet: Sheet name.
        cell: Cell address, e.g. 'B3'.

    Returns:
        String describing the cell value and its number format.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path, data_only=True)
    ws = wb[sheet]
    c = ws[cell]
    result = f"Value: {c.value}, Format: {c.number_format}"
    wb.close()
    return result


@mcp.tool()
def write_cell(path: str, sheet: str, cell: str, value: Any) -> str:
    """Write a value to a single cell and save.

    Args:
        path: Absolute or relative path to the .xlsx file.
        sheet: Sheet name.
        cell: Cell address, e.g. 'A1'.
        value: The value to write (string, number, etc.).

    Returns:
        Confirmation message.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path)
    wb[sheet][cell] = value
    wb.save(abs_path)
    wb.close()
    return f"Cell {cell} on sheet '{sheet}' set to: {value}"


@mcp.tool()
def write_cells(path: str, sheet: str, start_cell: str, data: list[list[Any]]) -> str:
    """Write a 2-D array of values starting from a given cell and save.

    Args:
        path: Absolute or relative path to the .xlsx file.
        sheet: Sheet name.
        start_cell: Top-left cell address, e.g. 'A1'.
        data: 2-D list — [[row1col1, row1col2, ...], [row2col1, ...], ...]

    Returns:
        Confirmation message.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path)
    ws = wb[sheet]
    start = ws[start_cell]
    start_row, start_col = start.row, start.column
    for r_idx, row in enumerate(data):
        for c_idx, val in enumerate(row):
            ws.cell(row=start_row + r_idx, column=start_col + c_idx, value=val)
    wb.save(abs_path)
    wb.close()
    return f"Wrote {len(data)} row(s) starting at {start_cell} on sheet '{sheet}'."


@mcp.tool()
def add_sheet(path: str, name: str, position: int = -1) -> str:
    """Add a new worksheet to the workbook.

    Args:
        path: Absolute or relative path to the .xlsx file.
        name: Name for the new sheet.
        position: 1-based position to insert before. Use -1 to append at end.

    Returns:
        Confirmation message.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path)
    idx = None if position == -1 else position - 1
    wb.create_sheet(title=name, index=idx)
    wb.save(abs_path)
    wb.close()
    return f"Sheet '{name}' added."


@mcp.tool()
def save_workbook(path: str, save_as: str = "") -> str:
    """Save the workbook to its current path or to a new path.

    Args:
        path: Absolute or relative path to the .xlsx file to open.
        save_as: If provided, saves a copy to this new path. Otherwise saves in place.

    Returns:
        Confirmation message with the saved file path.
    """
    abs_path = os.path.abspath(path)
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"File not found: {abs_path}")
    wb = openpyxl.load_workbook(abs_path)
    if save_as:
        abs_save = os.path.abspath(save_as)
        wb.save(abs_save)
        wb.close()
        return f"Workbook saved as: {abs_save}"
    else:
        wb.save(abs_path)
        wb.close()
        return f"Workbook saved: {abs_path}"


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

---

## Step 6：整合建立 mcp.json

**Bob 請向使用者說明：**
「我將把以上你同意安裝的所有工具整合寫入 `.bob/mcp.json`。這個設定檔讓 Bob 知道有哪些工具可以使用，以及如何啟動它們。**只有你在前面步驟中同意建立的工具，才會被寫入這個設定檔。**」

**取得同意後，Bob 請執行以下步驟：**

1. 根據 Step 3 記錄的 `UVX_PATH` 與 Step 4 記錄的 `PROJECT_ROOT`，以及使用者在 Step 3–5 的選擇，動態組合 `mcp.json` 的內容
2. 只寫入使用者同意的工具項目
3. 路徑分隔符號依 OS 決定：Windows 使用 `\\`，macOS 使用 `/`

**mcp.json 結構範本（Bob 根據實際情況填入，未同意的項目完整省略）：**

```json
{
  "mcpServers": {
    "mcp-fetch": {
      "command": "<UVX_PATH>",
      "args": [
        "--from", "mcp-server-fetch==2025.4.7",
        "--with", "mcp==1.9.4",
        "mcp-server-fetch",
        "--ignore-robots-txt",
        "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
      ],
      "timeout": 10000
    },
    "playwright": {
      "command": "npx",
      "args": [
        "@playwright/mcp@latest",
        "--config",
        "<PROJECT_ROOT>/.bob/playwright-mcp-config.json",
        "--init-script",
        "<PROJECT_ROOT>/.bob/playwright-init-script.js"
      ]
    },
    "word-mcp": {
      "type": "stdio",
      "command": "python3",
      "args": ["<PROJECT_ROOT>/.bob/mcp-servers/word-mcp/server.py"]
    },
    "ppt-mcp": {
      "type": "stdio",
      "command": "python3",
      "args": ["<PROJECT_ROOT>/.bob/mcp-servers/ppt-mcp/server.py"]
    },
    "excel-mcp": {
      "type": "stdio",
      "command": "python3",
      "args": ["<PROJECT_ROOT>/.bob/mcp-servers/excel-mcp/server.py"]
    }
  }
}
```

> Bob 注意：Windows 環境下 `python3` 應改為 `python`；路徑分隔符號 Windows 使用 `\\`。

建立完成後，請向使用者顯示最終寫入的 `mcp.json` 完整內容供確認。

---

## Step 7：驗證所有設定檔案（自動執行，不需詢問）

Bob 請根據作業系統與使用者的選擇，列出應存在的檔案並逐一確認：

**Windows（PowerShell）：**
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 > $null;
$files = @(
    ".bob\playwright-mcp-config.json",
    ".bob\playwright-init-script.js",
    ".bob\mcp-servers\word-mcp\server.py",
    ".bob\mcp-servers\ppt-mcp\server.py",
    ".bob\mcp-servers\excel-mcp\server.py",
    ".bob\mcp.json"
)
foreach ($f in $files) {
    if (Test-Path $f) { Write-Host "[OK] $f" }
    else { Write-Host "[未建立或已略過] $f" }
}
```

**macOS（Terminal）：**
```bash
for f in ".bob/playwright-mcp-config.json" ".bob/playwright-init-script.js" \
         ".bob/mcp-servers/word-mcp/server.py" ".bob/mcp-servers/ppt-mcp/server.py" \
         ".bob/mcp-servers/excel-mcp/server.py" ".bob/mcp.json"; do
  if [ -f "$f" ]; then echo "[OK] $f"; else echo "[未建立或已略過] $f"; fi
done
```

---

## Step 8：完成提示

Bob 請向使用者說明：

「第二篇 MCP Server 設定已完成！以下是本次的設定摘要：」

然後列出：
- 已建立的工具清單（各一行說明其用途）
- 已略過的項目（如有）

最後說明：

「**重要：請重新啟動 Bob IDE**（關閉再開啟），讓所有 Rule 與 MCP Server 正式生效。

重啟後，請前往 Bob IDE 的設定頁面，點擊 **MCP 頁籤**，確認已啟用的工具都顯示為**綠色連線狀態**。

**若遇到連線問題：**

| 工具 | 可能原因 | 解決方法 |
|------|----------|----------|
| `mcp-fetch` | uvx 路徑不正確 | 執行 `uvx --version` 確認，並重新確認 mcp.json 中的路徑 |
| `playwright` | Node.js 未安裝或路徑有誤 | 執行 `npx --version` 確認 |
| `word-mcp` / `ppt-mcp` / `excel-mcp` (Windows) | Office 未安裝或 pywin32 未安裝 | 執行 `pip install --upgrade pywin32` |
| `word-mcp` / `ppt-mcp` / `excel-mcp` (macOS) | 套件未安裝 | 執行 `pip3 install python-docx python-pptx openpyxl` |

若需要進一步協助，請將錯誤訊息貼給 Bob，Bob 會協助排查。」
