ClaudeとOutlook連携|ローカルMCPのクラウドMCP化とつまずき10問
対象読者:Claude で Outlook を操作したい情シス担当・開発者の方/ローカルMCPをクラウドに移したい方
※Azure・Entra ID の管理画面は図解で説明しています(他社製品の画面キャプチャは使用していません)
クラウドMCP(リモートMCP)とは、AIが使うツールをインターネット上のサーバーとして公開し、手元のPCの起動状態に関係なく呼び出せるようにした構成です。 MCP には2種類あります。PC 上でプロセスとして動く「ローカルMCP」と、HTTPS で公開される「リモートMCP」です。後者はクラウドの自動実行から呼び出せます。
わたしたち FROM CENTER では、Outlook のカレンダー操作を自動化するために outlook-mcp というローカルMCPを使っていました。しかし「PCが起動していないと動かない」という制約があり、定期実行が空振りする日が続きました。そこで Microsoft Graph を直接呼ぶ MCP サーバーを自作し、Azure App Service に載せてクラウドMCP化しました。
構築手順を9つに分け、実際にぶつかった10個のつまずきを後半のFAQ編に並べます。特に claude.ai のカスタムコネクタと Entra ID の OAuth が噛み合わない問題は、公式ドキュメントを読んでも書いていない落とし穴でした。
- ローカルMCPをクラウド化すべきか、しなくてよいかの判断基準
- Microsoft Graph を無人実行で叩くための Entra ID 設定(と、権限を絞る必須作業)
- Azure App Service に MCP サーバーを載せてデプロイする手順
- claude.ai のカスタムコネクタが Entra ID の OAuth で繋がらないときの回避策
目次
- 用語ミニ辞典
- その前に:クラウド化は本当に必要か
- 全体像と、先に知らないと後悔する3つのこと
- Part 1|Entra ID の準備(手順1〜3)
- Part 2|MCPサーバーの実装(手順4)
- Part 3|Azure App Service へのデプロイ(手順5〜7)
- Part 4|claude.ai への接続とルーティン(手順8〜9)
- つまずきとFAQ編(10問)
- まとめ
用語ミニ辞典
本文は正式な用語で書いています。IT に馴染みのない方は先にこちらをご覧ください。
| 用語 | 意味 |
|---|---|
| MCP | AI が外部のツールを呼び出すための共通規格。Model Context Protocol の略 |
| ローカルMCP | 手元のPC上でプロセスとして動く MCP。標準入出力で通信するため、そのPCからしか使えない |
| リモートMCP | HTTPS で公開される MCP。インターネット経由でどこからでも呼び出せる |
| 委任権限 | サインインした本人の権限の範囲で API を使う方式。人がログインする前提 |
| アプリケーション権限 | 人のサインインなしに、アプリ自身の権限で API を使う方式。無人実行に使う |
| ルーティン | claude.ai が決めた時刻に自動実行する仕組み。cron で指定する |
その前に:クラウド化は本当に必要か
先に正直なことを書きます。やりたいことが1つの自動処理だけなら、クラウドMCP化は過剰です。
わたしたちは最初、「スクリプトを書いてクラウドの自動実行から叩く」という案を検討していました。必要な作業は Entra ID の設定だけで、サーバーもデプロイも要りません。
| スクリプト方式 | クラウドMCP方式 | |
|---|---|---|
| 月額 | 0円 | 0円〜(無料プランなら0円。有料は約1,900円/月) |
| 必要な作業 | Entra ID の設定のみ | Entra ID + サーバー構築 + デプロイ + コネクタ登録 |
| 障害点 | 自動実行の仕組みだけ | 自動実行 + サーバー + 公開エンドポイント |
| 対話中に使えるか | 使えない | 使える(「来週の予定を見せて」と頼める) |
| 他の業務への転用 | しにくい | しやすい(メール送信なども足せる) |
クラウドMCP化が見合うのは、「AIとの対話中にも使いたい」または「他の業務も同じ基盤に載せる予定がある」場合です。わたしたちは後者の理由で選びました。
ローカルMCPが無料なのは、あなたのPCがその置き場所を負担しているからです。クラウドに移すと、その負担がクラウド事業者に移り、料金として見えるようになります。スクリプト方式は常駐しないので置き場所が要りません。ここが両者の分かれ目です。
全体像と、先に知らないと後悔する3つのこと
構成はこうなります。

作り終えたいま、「最初に知っていれば時間を使わずに済んだ」と思うことが3つあります。
- Microsoft 365 の公式コネクタは読み取り専用です。 予定やメールの検索はできますが、作成・削除のツールがありません。書き込みが必要なら自分でMCPサーバーを用意することになります。
- アプリケーション権限は、既定でテナント全員のメールボックスに届きます。 「アプリ登録に権限を付けた」だけで終わらせると、そのシークレットを持つ人が全社員のカレンダーを操作できる状態になります。
ApplicationAccessPolicyで範囲を絞る作業まで含めて1セットです。 - claude.ai のカスタムコネクタと Entra ID の OAuth は、そのままでは繋がりません。 わたしたちは3回試して断念しました(→FAQ 2)。回避策はありますが、保護は弱くなります。ここを知らずに始めると、最後の工程で手が止まります。

Part 1|Entra ID の準備(手順1〜3)
手順1. 専用のアプリ登録を新規作成する
- Azure Portal → Microsoft Entra ID → 「アプリの登録」→ 新規登録
- 名前を入力(例:
outlook-calendar-mcp) - アカウントの種類は「この組織ディレクトリのみ」(シングルテナント)
- リダイレクト URI は設定しない
- 「概要」画面でアプリケーション(クライアント)ID と ディレクトリ(テナント)ID を控える
- 「証明書とシークレット」→ 新しいクライアントシークレット → 値を控える(一度しか表示されません)
わたしたちは最初、社内で共用しているアプリ登録に権限を足してしまい、同日中に権限を削除して個人用アプリに作り直しました。共用のアプリ登録は、シークレットを複数人が持つ構成になりがちです。そこにアプリケーション権限を付けると、シークレットを持つ全員が全社員のカレンダーを読み書きできる状態になります。用途ごとにアプリ登録を分けてください(→FAQ 8)。
手順2. アプリケーション権限を付与する
- 作成したアプリ → 「API のアクセス許可」→ アクセス許可の追加
- Microsoft Graph → アプリケーションの許可 を選ぶ
- 必要な権限にチェック(カレンダー操作なら
Calendars.ReadWrite) - 「管理者の同意を与えます」 を実行
「種類」列が 「アプリケーション」 になっていることを確認します。ここが「委任済み」だと、人のサインインが必要になり無人実行できません。
| 委任済み | アプリケーション | |
|---|---|---|
| 誰の権限で動くか | サインインした本人 | アプリ自身 |
| サインインが必要か | 必要 | 不要 |
| アクセスできる範囲 | 本人がアクセスできる範囲 | テナント内の任意のユーザー |
| 無人実行 | できない | できる |
手順3. アクセス範囲をメールボックス1つに絞る【必須】
この手順を飛ばすと、シークレットが漏れたときの影響が全社員に及びます。 Azure Portal では設定できず、Exchange Online の PowerShell からのみ操作できます。
- PowerShell を開く(管理者権限は不要)
- モジュールを入れる(初回のみ)
Install-Module ExchangeOnlineManagement -Scope CurrentUser
- Exchange に接続する(ブラウザでサインインを求められます)
Connect-ExchangeOnline -UserPrincipalName you@example.com
- アクセス範囲を絞る
New-ApplicationAccessPolicy -AppId "<クライアントID>" -PolicyScopeGroupId "you@example.com" -AccessRight RestrictAccess -Description "outlook-mcp: 対象メールボックスのみ"
- 効いているか確認する(反映に最大1時間かかります)
Test-ApplicationAccessPolicy -Identity "you@example.com" -AppId "<クライアントID>"
AccessCheckResult : Granted(日本語環境では「許可」)になり、他の人のアドレスで試すと Denied(拒否) になれば成功です。
ポリシーが1つも存在しない状態でも
Test-ApplicationAccessPolicy は「許可」を返します(制限がなければ全部アクセスできるため)。Get-ApplicationAccessPolicy でポリシーの存在を確認し、他の人のアドレスで「拒否」になることまで見てください。わたしたちはここで一度勘違いしました。Part 2|MCPサーバーの実装(手順4)
手順4. MCPサーバーを書く
Python の MCP SDK を使えば、依存は2つで足ります。
mcp>=2.0.0
PyJWT[crypto]>=2.8.0
実装の要点は4つです。
- ツールを関数として定義する(docstring がそのまま AI 向けの説明になります)
- Streamable HTTP のアプリを生成する
- Graph のトークンを client credentials で取得する(受け取ったトークンを Graph に転送してはいけません)
me/は使えないので、すべてusers/{メールアドレス}/形式で呼ぶ
ツールの定義はこれだけです。
import asyncio
from mcp.server import MCPServer
mcp = MCPServer("outlook-calendar")
@mcp.tool()
async def list_calendars() -> dict:
"""自分のメールボックスにあるカレンダーの一覧を返す。"""
calendars = await asyncio.to_thread(graph.list_calendars)
return {"calendars": [{"name": c["name"], "id": c["id"]} for c in calendars]}
1.x の
mcp.server.fastmcp.FastMCP は 2.0 では存在せず、mcp.server.MCPServer になりました。stateless_http はコンストラクタ引数ではなく streamable_http_app() の引数に移動し、独自ルートの追加は @mcp.custom_route デコレータになっています(→FAQ 10)。判定ロジックはサーバー側に寄せてください。 わたしたちは「同期処理」をツール1個にまとめ、除外条件や削除の可否をすべてサーバー側で決めるようにしました。AI 側に判断を残すと、実行のたびに結果が揺れる可能性があります。決定論的に動かしたい処理は、AI に渡す前に閉じておくのが安全です。
Part 3|Azure App Service へのデプロイ(手順5〜7)
手順5. App Service を作成する
- Azure Portal → 「App Service」→ 作成 → Web アプリ
- 設定値は次のとおり
| 項目 | 値 |
|---|---|
| 公開 | コード(コンテナーではない) |
| ランタイム スタック | Python 3.12 |
| オペレーティング システム | Linux |
| 価格プラン | Free F1(無料で試す場合。20分アクセスが無いと停止します) |
- 「デプロイ」タブの継続的デプロイは「無効化」のままにする
継続的デプロイを有効にすると、Azure が独自のワークフローファイルをリポジトリに作成します。自分でワークフローを用意する場合は衝突するため、ここは触りません。
わたしたちは Japan East で
Current Limit (Total VMs): 0 というエラーに当たりました。Free の枠が割り当てられていないリージョンがあります。East Asia に変更したら通りました(→FAQ 1)。手順6. 環境変数とスタートアップコマンドを設定する
「設定」→「構成」→「全般設定」 で2箇所を設定します。
- スタートアップ コマンド(「スタック設定」の中にあります)
python -m uvicorn server:app --host 0.0.0.0 --port 8000
- 「SCM 基本認証の発行資格情報」を「オン」 にする
「設定」→「環境変数」 で資格情報と動作設定を登録します。
| 名前 | 内容 |
|---|---|
MS_TENANT_ID | ディレクトリ(テナント)ID |
MS_CLIENT_ID | アプリケーション(クライアント)ID |
MS_CLIENT_SECRET | クライアントシークレットの値 |
WEBSITES_PORT | 8000 |
SCM_DO_BUILD_DURING_DEPLOYMENT | true |
App Service の Python は既定で gunicorn を使い、
app.py や application.py を探します。ファイル名が違えば見つからず、さらに非同期(ASGI)アプリは gunicorn 単体では起動できません。スタートアップコマンドの設定は省略できない手順です。Azure はセキュリティ強化のため、この設定を既定で無効にしています。発行プロファイルを使うデプロイ方式では有効化が必要です(→FAQ 4)。
手順7. デプロイする
GitHub Actions を使う場合は、発行プロファイルをリポジトリのシークレットに登録します。
- App Service の「概要」→ 「発行プロファイルの取得」 でファイルをダウンロード
- GitHub のリポジトリ → Settings → Secrets and variables → Actions
- シークレット
AZURE_WEBAPP_PUBLISH_PROFILEにファイルの中身(XML全文)を登録 - 変数
AZURE_WEBAPP_NAMEに App Service 名(リソース名)を登録
ワークフローの要点は3つです。
app-nameには App Service のリソース名を渡す(公開URLではない)publish-profileには発行プロファイルのXML全文を渡すpackageにはサーバーのディレクトリを指定する
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ vars.AZURE_WEBAPP_NAME }}
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: <サーバーのディレクトリ>
デプロイ後は /healthz などの死活確認エンドポイントを叩いて、起動を確認します。
作成時に「安全な一意の既定のホスト名」が有効だと、URL にランダムな文字列とリージョン名が入ります(例:
myapp-abc123def456ghi7.eastasia-01.azurewebsites.net)。myapp.azurewebsites.net では届きません。デプロイ設定の app-name に渡すのはリソース名のほうです(→FAQ 3)。Part 4|claude.ai への接続とルーティン(手順8〜9)
手順8. カスタムコネクタとして登録する
claude.ai → 設定 → コネクタ → カスタムコネクタを追加
| 項目 | 値 |
|---|---|
| 名前 | 任意 |
| URL | https://<公開URL>/mcp |
| 詳細設定 | OAuth Client ID / Secret(使う場合のみ) |
接続に成功すると、実装したツールが一覧に表示されます。
Entra ID を認可サーバーにした OAuth は、わたしたちの環境では繋がりませんでした。3回試して原因を特定できず、URL を秘密にする方式に切り替えました。詳しくはFAQ 2 に書きました。
手順9. ルーティンで定期実行する
コネクタを登録したら、claude.ai のルーティン(cron)で自動実行できます。
ツールの権限を「常に許可」に変えてください。 既定は「承認が必要」で、無人実行では承認待ちのまま止まります。定期実行で呼ぶツールだけを許可し、他は「承認が必要」のまま残すのが安全です。
ルーティンに渡す指示は、できるだけ短くします。
- 「このツールを呼んで、結果を報告する」だけにする
- ファイルの読み書き・コミットはさせない
- 判定はサーバー側で完結しているので、AI に条件判断をさせない
わたしたちは当初「実行ログをリポジトリに追記してコミットする」まで指示していましたが、組織のフックに阻まれて停止しました(→FAQ 9)。やることを1つに絞ったら40秒で完走しました。

つまずきとFAQ編(10問)
先に一覧を置きます。症状から探してください。
| # | 症状・疑問 | 分類 |
|---|---|---|
| 1 | Current Limit (Total VMs): 0 で App Service が作れない | エラー |
| 2 | コネクタ接続で Entra エラー 9010010 / 650053 | エラー |
| 3 | デプロイは成功したのに URL にアクセスできない | エラー |
| 4 | GitHub Actions のデプロイが認証エラーで失敗する | エラー |
| 5 | Graph API の応答が空になる(一覧が0件で返る) | エラー |
| 6 | WWW-Authenticate を付けたら 500 になる | エラー |
| 7 | スタートアップコマンドの設定画面が見つからない | 迷子 |
| 8 | 委任権限とアプリケーション権限、どちらを選ぶか | 仕様 |
| 9 | ルーティンが「承認待ち」で止まる | 仕様 |
| 10 | MCP SDK のサンプルコードが動かない | 仕様 |
エラー編
FAQ 1. App Service の作成で「Operation cannot be completed without additional quota. Current Limit (Total VMs): 0」と出る
そのリージョンで、選んだプランの枠が割り当てられていません。リージョンを変えると通ることがあります。 わたしたちは Japan East の Free プランで発生し、East Asia に変更して解決しました。
有料プランは別のクォータで管理されているため、Japan East のままでも通る場合があります。どのリージョンでも通らない場合は、エラー画面からクォータの引き上げを申請できますが、承認に時間がかかります。
FAQ 2. claude.ai のコネクタ接続で Entra エラー「9010010」「650053」が出る
わたしたちが解決できなかった問題です。 経緯を残しておきます。
| 試行 | エラー | 内容 |
|---|---|---|
| 1回目 | 9010010 | The resource parameter provided in the request doesn't match with the requested scopes. |
| 2回目 | 650053 | The application asked for scope that doesn't exist on the resource 'Microsoft Graph'. |
| 3回目 | 650053 | 同上(クライアント側の User.Read を削除、コネクタも作り直したが変化なし) |
サインインログを見ると、リソースが Microsoft Graph に解決されているのが共通していました。
Entra ID の v2.0 エンドポイントは、要求するリソースを scope の値だけで決めます。カスタムAPIのスコープは api://<クライアントID>/<スコープ名> という完全修飾で渡す必要があります。短い名前(calendar.access)だと既定リソースの Microsoft Graph と解釈され、そこに該当スコープがないため失敗します。
MCP 仕様側のメタデータ(scopes_supported)を完全修飾に直しても改善しませんでした。Entra ID 側に「既定リソースを Graph 以外にする」設定はありません。HTTPS URL をアプリケーション ID URI にするには検証済みドメインが必要で、azurewebsites.net は使えません。カスタムドメインは無料プランでは使えないため、有料プランへの移行が前提になります。
回避策:URL を秘密にする方式
わたしたちは MCP エンドポイントのパスにランダムな文字列を入れ、URL 自体を資格情報として扱う方式に切り替えました。
https://<公開URL>/mcp/<43文字のランダム文字列>
- 正しいパスなら 200、それ以外は 404 を返す
- OAuth のメタデータは公開しない(404 にして認証フローの開始を抑止する)
- claude.ai には OAuth の欄を空にして登録する
保護は OAuth より弱くなります。 URL を知っている人は誰でもツールを実行できます。だからこそ、手順3のアクセス範囲の絞り込みが重要になります。被害範囲を「対象メールボックスのカレンダーのみ」に限定しておけば、URL が漏れてもメールやファイルには届きません。
FAQ 3. デプロイは成功したのに URL にアクセスできない
公開URLがリソース名と一致していない可能性があります。作成時に「安全な一意の既定のホスト名」が有効だと、次のような形になります。
myapp-abc123def456ghi7.eastasia-01.azurewebsites.net
サブドメイン乗っ取りを防ぐための Azure の機能です。App Service の「概要」画面の「既定のドメイン」で正確な値を確認してください。デプロイ設定の app-name に渡すのはリソース名(myapp)で、アクセスに使うのは上記の完全な URL です。ここを混同すると、デプロイは成功するのに繋がらない状態になります。
FAQ 4. GitHub Actions のデプロイが認証エラーで失敗する
SCM 基本認証が無効になっています。 App Service → 設定 → 構成 → 全般設定 →「SCM 基本認証の発行資格情報」を「オン」にしてください。
Azure はセキュリティ強化のためこの設定を既定で無効にしました。発行プロファイル方式は基本認証を使うため、無効のままでは通りません。作成画面にも「このアプリの基本認証は現在無効になっており、デプロイに影響する場合があります」という案内が出ます。作成時には変更できないので、作成後に設定します。
FAQ 5. Graph API の応答が空になる(一覧が0件で返る)
レスポンスの空判定に Content-Length を使っていると起こります。Graph は chunked transfer encoding で返すことがあり、その場合 Content-Length が存在しません。
# 誤り: chunked 応答では length が None になり、中身があるのに空扱いになる
if res.status == 204 or not res.length:
return {}
# 正しい: ステータスコードだけで判定し、本文は読んでから判断する
if res.status == 204:
return {}
raw = res.read()
return json.loads(raw) if raw else {}
わたしたちはこれで「カレンダーが0件」という結果に一度騙されました。エラーにならず正常終了するので気づきにくい不具合です。
FAQ 6. WWW-Authenticate ヘッダを付けたら 500 エラーになる
HTTP ヘッダには latin-1 の文字しか入れられません。 日本語のエラー説明をヘッダに入れると UnicodeEncodeError で 500 になります。
# 日本語を含む説明はヘッダに載せず、レスポンスボディ(JSON)側に入れる
if description and description.isascii():
parts.append(f'error_description="{description}"')
ボディは UTF-8 なので日本語で問題ありません。エラーメッセージを日本語で書く方針の場合、ヘッダとボディで扱いを分ける必要があります。
迷子編
FAQ 7. スタートアップコマンドの設定画面が見つからない
App Service の左メニュー → 「設定」 → 「構成」 → 「全般設定」 タブ → 「スタック設定」 の中にあります。
環境変数は最近のポータルでは別画面に分離されており、「設定」→「環境変数」 です。同じ「構成」画面にあった時期もあるため、古い手順書と食い違うことがあります。
仕様編
FAQ 8. 委任権限とアプリケーション権限、どちらを選ぶべきか
無人実行が目的ならアプリケーション権限です。委任権限は人のサインインが前提なので、誰もいない時間に動く処理では使えません。
ただし2点注意があります。
- 既存の共用アプリに追加しないこと。 アプリケーション権限はテナント全員のメールボックスに届きます。共用のアプリ登録は、シークレットを複数人が持つ構成になりがちです。そこに付与すると、シークレットを持つ全員が全社員のデータにアクセスできる状態になります。用途ごとにアプリ登録を分けてください
ApplicationAccessPolicyで範囲を絞ること。 手順3の作業まで含めて1セットです
なお、対話用のツールとして使うだけなら委任権限(On-Behalf-Of)のほうが権限は最小になります。用途で選び分けてください。
FAQ 9. ルーティンが「承認待ち」で止まる
原因は2つ考えられます。
1つめ:ツールの権限が「承認が必要」のまま
claude.ai のコネクタ設定で、ツールごとに権限を設定できます。既定は「承認が必要」なので、無人実行では承認を待って止まります。定期実行で呼ぶツールだけ「常に許可」に変えてください。
2つめ:組織のフックやガードに阻まれている
わたしたちのリポジトリには「自分のフォルダ以外への書き込みを禁止する」フックが入っています。ルーティンの実行環境には作業者の判定に必要な設定ファイル(gitignore 対象)が無いため、書き込みがブロックされました。
PreToolUse:Edit hook error: 作業者(WORKSPACE_MEMBER)が未設定のため、書き込み先を検証できません。
対処は「ルーティンにファイル操作をさせない」ことでした。 実行記録はルーティンの履歴に残るので、リポジトリへの追記をやめれば衝突しません。指示を「ツールを1回呼んで報告する」だけに絞ったところ、40秒で完走しました。
無人実行の指示は、できるだけ短く、副作用を少なくが原則です。
FAQ 10. MCP SDK のサンプルコードが動かない
2.0 で API 名が変わっています。 1.x 向けの記事やサンプルは、そのままでは動きません。
| 項目 | 1.x | 2.0 |
|---|---|---|
| クラス | mcp.server.fastmcp.FastMCP | mcp.server.MCPServer |
stateless_http | コンストラクタ引数 | streamable_http_app() の引数 |
| 独自ルート追加 | Starlette の routes を直接操作 | @mcp.custom_route(path, methods=[...]) |
| クライアント | streamablehttp_client | mcp.Client(URL文字列を渡せる) |
| 属性名 | inputSchema | input_schema(snake_case) |
もう1点、DNS リバインディング保護が既定で有効です。公開ドメインを許可リストに入れないと、リバースプロキシ経由のリクエストが全て拒否されます。
security = TransportSecuritySettings(allowed_hosts=["myapp.example.net", "localhost:8000"])
app = mcp.streamable_http_app(transport_security=security)
まとめ
- まずクラウド化が必要か見極める。 自動処理を1つ動かすだけならスクリプト方式のほうが軽いです。対話中にも使いたい、他の業務にも広げる、という場合にクラウドMCPが見合います
- Microsoft 365 の公式コネクタは読み取り専用。 書き込みが必要なら自作のMCPサーバーを用意することになります
- アプリケーション権限は
ApplicationAccessPolicyで範囲を絞るまでが1セット。 絞らないとテナント全員のメールボックスに届きます。共用のアプリ登録に付与するのは特に危険です - claude.ai と Entra ID の OAuth は素直に繋がらない。 URL を秘密にする方式で回避できますが、保護は弱くなります。だからこそアクセス範囲の絞り込みが効きます
- 無人実行の指示は短く、副作用を少なく。 ファイル操作やコミットを含めると、フックや権限で止まります。判定はサーバー側に寄せて、AI には「呼んで報告する」だけを任せます
わたしたちの環境では、これで PC の起動状態に関係なく3時間ごとの同期が回るようになりました。なお Free プランは20分アクセスが無いと停止するため、久しぶりの実行では起動待ちで時間がかかります。作業時間は調査を含めて半日ほど、月額費用は0円です(無料プランのため。安定性を求めるなら有料プランを検討してください)。

