AIにExcelやWordを操作させる - ローカルMCPサーバー構築の記録

Claude DesktopやVSCodeから、手元のPCで動くツール(Excel、Word、ファイル操作、GitHub連携など)をAIに直接操作させるため、ローカルMCPサーバーを構築した。 この記事では構築の流れと、途中でハマった認証・環境まわりのトラブルシュートを実録として残しておく。

MCPとは何か、なぜローカルに構築するのか

MCP(Model Context Protocol)は、AIモデルが外部ツールを呼び出すための標準プロトコル。Claude発だが、今ではGemini・Copilot等も対応するオープンな仕様になっている。

クラウド上にMCPサーバーをホストする方法もあるが、以下の理由でローカル(stdio transport)構成を選ぶ。

  • 手元のPC上のアプリ(Excel/Word等)を直接操作したい → クラウド経由では不可能
  • ネットワーク機器へのアクセスなど、外に出したくない処理がある
  • 認証・公開設定の管理コストを避けたい

構成の全体像

  • ホスト側: Claude Desktop、VSCode(Claude Code拡張機能)。複数のホストから同じサーバー実体を呼べるようにしている
  • サーバー側: Python + mcpパッケージ(FastMCP)、stdio transportでホストから直接起動される
  • リポジトリ: GitHubで管理し、main.pyに各ツールモジュールをimportして集約する構成
# main.py
from local_mcp.mcp_instance import mcp
from local_mcp.llm_router import *
from local_mcp.github_tools import *
from local_mcp.office_tools import *
from local_mcp.file_tools import *

@mcp.tool()
def health_check() -> str:
    return "ok"

if __name__ == "__main__":
    mcp.run(transport="stdio")
# mcp_instance.py
from mcp.server.fastmcp import FastMCP

# ローカル(stdio)運用のため、host/port/sse系の設定は不要。
# Claude Desktop / VSCode がこのプロセスを直接起動し、標準入出力で通信する。
mcp = FastMCP("local-mcp")

各ツールモジュールは共有のmcpインスタンスをimportして@mcp.tool()で関数を生やすだけ、というシンプルな作りにした。増やしやすさを優先した設計。

Pythonを「ポータブル版」に統一した理由

当初はWindows標準(Microsoft Store版)のPythonをそのまま使っていたが、ある日突然MCPサーバーが起動しなくなった。調べると、Store版Pythonが裏で勝手にバージョンアップされ、pip installしていたパッケージが消えていたことが原因だった。システムのPythonに依存する限り、いつまた同じことが起きるか分からない。

そこで、Python公式が配布している「埋め込み版(embeddable package)」を使い、依存パッケージ込みでリポジトリのtools/local-mcp-portable/python/に同梱する構成に切り替えた。管理者権限もインストール作業も不要なzip配布形式で、システムのPythonの状態に一切左右されなくなる。

これは副産物として、職場PCのように管理者権限がなくPython自体が入っていない環境でも同じ手順で動かせるというメリットも生んだ。

ハマった話: 埋め込み版PythonはPYTHONPATHを無視する

ポータブル化した直後、ModuleNotFoundError: No module named 'local_mcp'で動かなくなった。原因は、埋め込み版Pythonが仕様としてPYTHONPATH環境変数を読まないこと。通常のPythonでは当たり前に効く設定が、埋め込み版では素通りされる。

対策として、local_mcpパッケージの場所を、環境変数ではなく_pthファイル(Pythonの実行ファイルと同じ場所にあるpython3xx._pth)に直接書き込むようにした。

# ._pthファイルの末尾にsrcフォルダへのパスを追記する
$pthLines = Get-Content $pthFile.FullName
$pthLines += $SrcPath
[System.IO.File]::WriteAllLines($pthFile.FullName, $pthLines, [System.Text.UTF8Encoding]::new($false))

さらに、このパスを相対パス..\..\..\srcにしたのがポイント。ポータブルPython(tools/local-mcp-portable/python/)は、このリポジトリ自身の中に同梱されているので、「python.exeから見て3つ上のフォルダ+src」という相対指定にしておけば、リポジトリフォルダを丸ごと別のPC・別の場所にコピーしても、パスの再設定なしでそのまま動く。自宅PCで作ったものを職場PCへそのまま持っていく、という運用が可能になった。

なお_pthファイルへの書き込みは、PowerShellのSet-Contentだと環境依存の文字コードで保存され、日本語パスが文字化けして再現性のないエラーになったことがある。.NETWriteAllLinesでBOM無しUTF-8を明示するのが安全。

ハマった話: GitHubトークンがプロセスに渡らない

GitHub操作ツール(github_tools.py)にPAT(Personal Access Token)をGITHUB_TOKEN環境変数で渡す設計にしたが、これが一番長引いたトラブルだった。

症状と切り分け

setx GITHUB_TOKEN "..."でユーザー環境変数として恒久化し、PCを再起動しても401 Bad credentials(あるいは「GITHUB_TOKENが未設定」)が解消しない。PowerShellからcurl.exeで直接叩くとトークン自体は生きていることが確認できたので、問題はトークンではなく「MCPサーバーのプロセスがどう認証情報を受け取っているか」に絞り込めた。

原因1: モジュール読み込み時に1回だけ取得していた

GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")  # モジュールimport時に1回だけ

def github_request(method, endpoint, **kwargs):
    headers = {"Authorization": f"Bearer {GITHUB_TOKEN}", ...}  # Noneでも素通り

これだと、サーバー起動後にトークンを変更しても、プロセスを再起動するまで反映されない。しかもGITHUB_TOKENNoneのままでも、コードは止まらず"Bearer None"という文字列をそのまま送り続けるため、エラーメッセージから原因に気づきにくい。

原因2: MSIX版アプリはユーザー環境変数を継承しないことがある

Claude DesktopがMicrosoft Store形式(MSIX)でインストールされている場合、setxで設定したユーザー環境変数が、そのプロセスに継承されないことがある。パッケージアプリ特有のサンドボックス的な挙動で、OSの環境変数を正しく設定しても、それが必ずアプリに届くとは限らない。

対策: 環境変数に頼らず、設定ファイルに直接書き込む

最終的に、GITHUB_TOKENを環境変数経由で渡すのをやめ、MCPサーバーの起動設定(claude_desktop_config.jsonや、VSCode側の~/.claude.json)のenvブロックに直接書き込む方式に統一した。これらの設定ファイルは手編集せず、専用スクリプトの--github-tokenオプションで書き込むようにしてある。

{
  "mcpServers": {
    "local-mcp": {
      "command": "C:\\path\\to\\local-mcp-portable\\python\\python.exe",
      "args": ["-m", "local_mcp.main"],
      "env": {
        "GITHUB_TOKEN": "発行したPAT"
      }
    }
  }
}

args-m local_mcp.mainになっているのもポイント。main.pyを直接パス指定で実行すると、Pythonはそのファイル自身の場所しかモジュール検索パスに加えないため、local_mcpパッケージ自身をimportできず起動に失敗する。モジュールとして-mで起動することで、_pthに登録した相対パスがきちんと効く。

あわせて、コード側も呼び出しのたびに環境変数を読み直す実装に変更した。

def _token() -> str | None:
    # 呼び出しのたびに読み直す。モジュール読み込み時の1回きりだと、
    # トークンを差し替えてもプロセス再起動まで反映されない。
    return os.getenv("GITHUB_TOKEN")

おまけ: Claude Desktopの設定ファイルの実体探し

さらに、MSIX版Claude Desktopではclaude_desktop_config.jsonの実体が%APPDATA%\Claude\ではなく、パッケージ専用の隔離フォルダ(%LOCALAPPDATA%\Packages\Claude_<ハッシュ>\LocalCache\Roaming\Claude\)にあることも分かった。設定を書き換えるスクリプト側で両方の場所を自動的に探すようにして解決している。

学び: 「環境変数が正しく見えている」ことと「実際にそのプロセスに渡っている」ことは別問題。特にMSIX/Store形式のパッケージアプリは、通常のプロセスとは異なる隔離レイヤーを持っていることがあるので、環境変数やファイルパスに依存する設計は疑ってかかった方がいい。

Office(Excel/Word/PowerPoint)をCOM経由で操作する

pywin32を使い、Excel/Word/PowerPointをCOM自動化するツール群を追加した。設計のポイントは以下。

  • 開いたファイルはaliasという任意の名前で管理し、複数ファイルを同時に扱えるようにする
  • Application.DisplayAlerts = Falseで確認ダイアログを抑制する
  • SaveAs時は明示的にFileFormatを指定する(拡張子との不一致による保存失敗を防ぐ)
  • 最後のファイルを閉じたらアプリプロセスも終了する(EXCEL.EXEの残留を防ぐ)
@mcp.tool()
def office_close(alias: str, save: bool = True) -> str:
    """
    指定したaliasの文書を閉じる。save=Trueなら保存してから閉じる。
    同じ種類(Excel/Word/PowerPoint)の文書が他に残っていなければ、
    アプリケーション本体(EXCEL.EXE等)も終了してプロセスを残さない。
    """
    kind, doc = _require_doc(alias)
    if kind == "excel":
        doc.Close(SaveChanges=save)
    elif kind == "word":
        doc.Close(SaveChanges=-1 if save else 0)
    elif kind == "powerpoint":
        if save:
            doc.Save()
        doc.Close()
    del _docs[alias]

    if not any(k == kind for k, _ in _docs.values()) and kind in _apps:
        _apps[kind].Quit()
        del _apps[kind]

    return f"'{alias}' ({kind}) を閉じました。"

なお、pywin32によるCOM自動化がポータブル版(埋め込み版)Pythonでも問題なく動くかは懸念していたが、実際にAI経由でデスクトップにExcelファイルを新規作成・保存・終了させるところまで動作確認できた。管理者権限もpostinstallスクリプトも不要で、通常インストール版と同じ感覚で使えている。

ハマった話: デスクトップへの保存が謎のエラーになる

最初にデスクトップへExcelファイルを作成しようとして、こんなエラーに遭遇した。

ファイル 'C:\Users\xxx\Desktop\495A6100' にアクセスできません。

指定していないランダムな16進数のファイル名でアクセス拒否される。原因は単純で、OneDriveの「デスクトップの同期」により、実際のデスクトップのパスがC:\Users\xxx\OneDrive\デスクトップにリダイレクトされていたこと。AIへの指示で「デスクトップに保存して」と指定したところC:\Users\xxx\Desktopとして解釈され、存在しないパスへの保存になっていた。

対策として、Windows APIから直接パスを解決するツールを追加した。

import ctypes
import uuid

_KNOWN_FOLDERS = {
    "desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
    "documents": "{FDD39AD0-238F-46AF-ADB4-6C85480369C7}",
    "pictures": "{33E28130-4E1E-4676-835A-98395C3BC3BB}",
    "downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
}

class _GUID(ctypes.Structure):
    _fields_ = [
        ("Data1", ctypes.c_ulong), ("Data2", ctypes.c_ushort),
        ("Data3", ctypes.c_ushort), ("Data4", ctypes.c_ubyte * 8),
    ]

@mcp.tool()
def office_resolve_known_folder(name: str) -> str:
    """
    'desktop' / 'documents' / 'pictures' / 'downloads' の実パスを返す。
    OneDriveでリダイレクトされている場合もその実パスを返す。
    """
    guid_str = _KNOWN_FOLDERS[name.lower()]
    # SHGetKnownFolderPathはGUID文字列ではなく、バイナリのGUID構造体を要求する。
    # 文字列のまま渡すとAPI呼び出しが常に失敗し、常にNoneが返ってしまうので注意。
    guid = _GUID()
    ctypes.memmove(ctypes.addressof(guid), uuid.UUID(guid_str).bytes_le, 16)
    path_ptr = ctypes.c_wchar_p()
    ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(path_ptr))
    try:
        return path_ptr.value
    finally:
        ctypes.windll.ole32.CoTaskMemFree(path_ptr)

この関数は最初、GUIDを文字列のまま渡す実装ミスがあり、常に失敗していた(SHGetKnownFolderPathはGUID文字列ではなくバイナリのGUID構造体を要求する)。AI自身にツールを実際に使わせて動作検証させたことで発覚し、その場で修正できたのは、ローカルMCPらしい開発体験だった。

また、OneDriveの「ファイルオンデマンド」機能が有効だと、ローカルに実体がなくクラウドだけに存在するプレースホルダーファイルがあり、これを直接COMで開こうとすると失敗することがある。ファイルを開く前に1バイト読んで実体化を促す処理も入れておいた。

汎用ファイル操作ツール

Office以外の一般的なファイル操作(読み書き・一覧・検索・コピー・移動・削除)も別モジュールとして用意する。ここでは「AIに自動化させる」上での安全設計を意識する。

  • 上書き・削除はデフォルトで拒否し、overwrite=True/confirm=Trueを明示しない限り実行しない
  • フォルダの再帰削除は提供しない(誤操作の被害を小さくするための意図的な制限)
@mcp.tool()
def fs_write_text(path: str, content: str, encoding: str = "utf-8",
                   overwrite: bool = False) -> str:
    """テキストファイルを書き込む。overwrite=Falseの場合、既存ファイルへの書き込みはエラー。"""
    if os.path.exists(path) and not overwrite:
        raise FileExistsError(
            f"'{path}' は既に存在します。上書きする場合は overwrite=True を指定してください。"
        )
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    with open(path, "w", encoding=encoding) as f:
        f.write(content)
    return f"書き込みました: {path} ({len(content)}文字)"

AIに強い権限を渡す以上、「うっかり」で被害が広がらない設計にしておくのは最低限のリスク管理として考慮すべき。

GitHub連携の仕上げ

初期実装では、リポジトリ情報の取得やIssue作成はできても、ファイルの新規作成・更新・削除ができなかった。ブログ記事の編集などをGitHub経由でやりたい用途には不十分だったので、github_commit_file(新規作成・更新)とgithub_delete_fileを追加した。GitHub APIの仕様上、更新・削除には対象ファイルの現在のshaが必要になるため、内部で自動取得するようにしている。

あわせて、以下も直した。

  • ブランチ名やファイルパス、検索クエリをrequestsparams=経由にし、URLエンコード漏れ(日本語やスペースを含むと壊れる問題)を解消
  • 全リクエストにtimeoutを設定(無応答時に無限に待つのを防止)
  • 一覧系エンドポイントにper_page/page引数を追加(GitHub APIの既定30件制限への対応)

まとめ

  • クラウド(Azure VM)からローカル(stdio)へ全面的に構成を作り直した
  • システムのPython(特にStore版)に依存せず、埋め込み版Pythonをリポジトリに同梱する構成にした
  • PYTHONPATHが効かない環境固有の制約は、_pthファイルへの直接書き込み+相対パスで解決した
  • 複数のMCPホスト(Desktop app / エディタ拡張)がそれぞれ別の経路で設定・環境変数を扱っていることを痛感した
  • OSの環境変数の可視性と、実際にプロセスへ伝播するかは別問題(特にMSIX/Storeアプリ)
  • OneDriveのようなクラウド同期は、自動化スクリプトとのパス不一致が生じる

ネットワーク以外には余り触れてこなかったので、環境由来のトラブルに振り回されることが多々ありつつも 新鮮な気持ちで楽しんで構築することができた。 最低限、作りたかったツールは作成できたので一旦ローカルMCPサーバの構築はここまで。 今後は必要に応じてツールを追加しつつ、そのうちローカルLLMにも挑戦してみようかな。