ほりひログ

所属組織の製品 (Azure とか) に関連する内容が多めだけど、個人の見解であって、所属組織を代表する公式情報ではないです。

Microsoft Foundry Agent Service × Node.js SDKで整理する OAuth同意フロー

以前のエントリーで、Playground (Web UI) で OAuth Identity Passthrough の MCP ツールが動くことを確認した。

uncaughtexception.hatenablog.com

次に SDK(Node.js)から同じエージェントに話しかけたときに、MCP ツール呼び出しがどのように見えるかを検証した。
今回やったこと、観察した挙動、実務で使うときの注意点をまとめる。

なお OAuth Identity Passthrough については、以下の公式ドキュメントにも記載はあるものの、具体的な実装例や細かい運用上の注意までは示されていないため、今回サンプルを作成して挙動を検証してみた。

learn.microsoft.com

先に結論

  • SDK から会話を作って responses.create を呼ぶと、Playground と同様に response.outputoauth_consent_requestmcp_approval_request といった要素が返る
    ブラウザで consent_link を開いて同意すると、その会話コンテキストでツール実行が進行する
  • 同意はトークンの有効期間に依存する(数十分〜1時間程度が目安。環境依存である)
    有効期限切れ直後は 401 相当の認証エラーが返り、再度同意が必要になる場合がある

環境と用意したもの

  • サンプルスクリプト

chatBasic.js

  const { DefaultAzureCredential, getBearerTokenProvider } = require("@azure/identity");
  const { AIProjectClient } = require("@azure/ai-projects");

  const projectEndpoint = process.env["FOUNDRY_PROJECT_ENDPOINT"];
  const agentName = process.env["AGENT_NAME"];

  const MAX_RETRY_ATTEMPTS = 5;
  async function main() {
    const credential = new DefaultAzureCredential();
    const project = new AIProjectClient(projectEndpoint, credential);
    console.log("Connecting to Foundry Project MCP endpoint and initializing client...");
    console.log(`Project Endpoint: ${projectEndpoint}`);
    console.log(JSON.stringify(await project.connections, null, 2));
    const openAIClient = project.getOpenAIClient();

    console.log("Retrieving agent...");
    const agent = await project.agents.get(agentName);
    console.log(`Agent retrieved (id: ${agent.id}, name: ${agent.name}, version: ${agent.version})`);

    // Create conversation with initial user message
    console.log("\nCreating conversation with initial user message...");
    const conversation = await openAIClient.conversations.create({
      items: [
        { type: "message", role: "user", content: "List VMs in the subscription by using MCP tools." },
      ],
    });
    console.log(`Created conversation with initial user message (id: ${conversation.id})`);

    // Generate response using the agent
    console.log("\nGenerating response...");
    const body = {
      conversation: conversation.id,
    };
    const extra_body = {
      body: { agent_reference: { name: agent.name, type: "agent_reference" } },
    };
    const response = await createResponse(openAIClient, body, extra_body);
    console.log(`Response content: ${JSON.stringify(response, null, 2)}`);
  }

  async function createResponse(openAIClient, body, options, stackLevel = 0) {
    if (stackLevel > MAX_RETRY_ATTEMPTS) {
      throw new Error("Maximum retry attempts exceeded");
    }
    const response = await (async (body, options) => {
      while (true) {
        try {
          return await openAIClient.responses.create(body, options);
        } catch (err) {
          if (!err.error.message.startsWith("Authentication failed when connecting to the MCP server: ")) {
            throw err;
          }
          console.error(JSON.stringify(err, null, 2));
          await new Promise((resolve) => {setTimeout(resolve, 5000)}); // Wait for 5 seconds before retrying
        }
      }
    })(body, options);
    console.error(JSON.stringify(response, null, 2));
    console.log(`Types in received response: ${JSON.stringify(response.output.map((out) => out.type), null, 2)}`);

    const consentRequestsResponse = {
      output: response.output.filter((out) => out.type === "oauth_consent_request")
    };

    if (consentRequestsResponse.output.length > 0) {
      return await consentRequestProcessor(consentRequestsResponse, openAIClient, body, options, stackLevel + 1);
    }

    const mcpApprovalResponse = {
      output: response.output.filter((out) => out.type === "mcp_approval_request")
    };

    if (mcpApprovalResponse.output.length > 0) {
      return await mcpApprovalProcessor(mcpApprovalResponse, openAIClient, body, options, stackLevel + 1);
    }
    return response;
  }

  async function consentRequestProcessor(consentRequestResponse, openAIClient, body, options, stackLevel) {
    console.log(`Received consent request`);
    const consentRequests = consentRequestResponse.output.map((out) => ({ consent_link: out.consent_link, server_label: out.server_label }));

    if (stackLevel === 1) {
      console.log(`The following URL(s) need consent:`);
      consentRequests.forEach((consentRequest, index) => {
        console.log(`  ${index + 1}. ${consentRequest.server_label}: ${consentRequest.consent_link}`);
      });
    }

    console.log(`Please provide consent by opening the URL(s) above within 30 seconds...`);
    await new Promise((resolve) => {
      setTimeout(resolve, 30000);
    });
    console.log(`Finished waiting for user consent.`);
    return await createResponse(openAIClient, body, options, stackLevel);
  }

  async function mcpApprovalProcessor(mcpApprovalResponse, openAIClient, body, options, stackLevel) {
    console.log(`Received MCP approval request`);
    const approvalRequests = mcpApprovalResponse.output.filter(o => o.type === 'mcp_approval_request').map(tool => ({
      type: "mcp_approval_response",
      approval_request_id: tool.id,
      approve: true,
    }));
    return await createResponse(openAIClient, { conversation: body.conversation, input: approvalRequests }, options, stackLevel);
  }

  main().catch((err) => {
    console.error("The sample encountered an error:", err);
  });

  • 認証: DefaultAzureCredential が使える環境(Azure CLI ログイン等)
  • 環境変数(必要に応じて):
    • FOUNDRY_PROJECT_ENDPOINT(Foundry Project MCP エンドポイント)
    • AGENT_NAME(試すエージェント名)

スクリプトの動作

以下のように環境変数をセットして chatBasic.js を実行する

FOUNDRY_PROJECT_ENDPOINT="https://.../api/projects/..." AGENT_NAME="my-agent" node chatBasic.js
  1. スクリプトは openAIClient.conversations.create で会話を作り、openAIClient.responses.create でエージェントにメッセージを送信する
  2. エージェントからの応答 response に応じた処理をする
    1. response.output に OAuth の同意リクエスト(oauth_consent_request) を含まれていた場合は consent_link をコンソールに表示し、以降30秒間隔で直前のメッセージを再送する
    2. response.output に MCP ツールの実行承認リクエスト (mcp_approval_request) が含まれていた場合は承認応答 (mcp_approval_response) を送る
    3. どちらでもなく response.output_text に値が入っていれば、それをエージェントからの回答としてコンソールに表示する。

フロー概要(状態遷移)

SDK 呼び出しでの基本的な遷移は次の通り。

[初期]
  ↓ "responses.create" を実行
[oauth_consent_request を含む response.output を受信]
  ↓ ユーザーが consent_link を開き同意を行う(UI 操作)
[同意完了] ※ SDK では検知不可
  ↓ responses.create を再実行
[mcp_approval_request を含む response.output を受信]
  ↓ `mcp_approval_response` を明示的に会話に挿入して再送
[ツール実行 -> response.output_text を受信]
  ↓ `response.output_text` を表示
[ツール実行 / 正常終了]

観察したポイント

response.output は配列で返り、配列内の各要素に type が含まれる。
代表的な type ごとの JSON の例は次のとおり。

consent_link にユーザーに同意を要求するための URL が含まれる。 この URL を開き同意を完了した後に responses.create を再送する(再送しないと次段階に進まないため)。

{
  "output": [
    {
      "type": "oauth_consent_request",
      "id": "oauthreq_...",
      "agent_reference": {
        "type": "agent_reference",
        "name": "...",
        "version": "..."
      },
      "response_id": "resp_...",
      "consent_link": "https://...",
      "server_label": "..."
    },
    :
  ],
  :
}

mcp_approval_request: MCP 承認を求めてくる出力

MCP ツールをどういうパラメータで実行するかを提示し、ユーザーによる承認を待つ。

{
  "output": [
    {
      "type": "mcp_approval_request",
      "id": "mcpr_...",
      "agent_reference": {
        "type": "agent_reference",
        "name": "...",
        "version": "..."
      },
      "response_id": "resp_...",
      "server_label": "...",
      "name": "...",
      "arguments": "..."
    },
    :
  ],
  :
}

output_text: 通常の応答メッセージ

通常のエージェントからの応答メッセージの場合は、response.output 配列内には MCP ツールの実行結果などが格納される。
応答メッセージ自体は response.output_text に格納される。

{
  "output": [
    // MCP ツールの実行結果など
    :
  ],
  "output_text": "...",
  :
}

同意の反映について

ブラウザで consent_link を開くと、Playground での同意と同様の同意画面が開き、同意を完了すると「You can now close this dialog.」のような表示が出る。
ただ SDK 側にはこの「同意が完了した」通知を受け取る仕組みがないため、同意後に再度 responses.create を投げて状態が変わるか確認したり、ユーザー操作で再試行をトリガーする必要がある。

つまずきポイント

実際に動かしてみると、いくつかつまずいた。

  • 同意完了後でもすぐに使えないことがある
    同意の伝搬やトークンの発行にラグがあるように見える
  • トークンの有効期限切れタイミングで 401 が返る場合がある 公式ドキュメントの記載内容と違うので注意が必要
    • 公式ドキュメントには、
      ユーザーがサインインして 1 回同意した後は、今後同意する必要はありません。 とあり、一度同意すると再同意は不要に見える ※そうだとしても、スコープ(エージェント単位?ツール単位?その他?)が不明瞭
    • ところが、実際には一度同意が完了しトークンの有効期限が切れた状態でメッセージを送ると、以下の様に動作する
      • 1 回目のメッセージ送信で 401 エラーが返却(SDK 上は例外発生)
      • 2 回目のメッセージ送信で、oauth_consent_request で改めてユーザーに同意を要求
  • サンプルスクリプトのようにユーザーが同意する前に定期的にメッセージの再送をしていると、エージェントが自律的に "ツールを使わない" と判断することがある
    • プロンプトやモデル選択で回避できるのかもしれないが、ユーザー同意のメッセージ再送方式(ポーリング、ユーザーインタラクション、etc)の設計が必要
    • ちなみに、Playground では consent_link をポップアップウィンドウで開いていて、同意後にウィンドウが閉じられたことが Playground 側で検知できるので、これをトリガーに再送している模様

まとめ

Playground で確認した挙動はそのまま SDK 上でも再現されていた。

ポイントは「同意の検出と再試行フロー」をどう作るか。
サンプルのスクリプトではポーリングでの再試行を実装したが、同意がない状態で MCP ツール利用が必要なメッセージ送信を繰り返すと、エージェントがツールを使わない判断をする場合があるため、同意後にユーザー操作をトリガーに再試行する設計を推奨。

.envを自動解決してCLIを実行するコマンドプロキシ runx

はじめに

ディレクトリごとに使う環境変数を切り替えたい場面は、クラウド運用やアプリ開発で頻出する。特に Azure CLI のように、同じコマンドで複数プロファイルを使い分けるケースでは、毎回 export したり、実行オプションを長く書いたりする手間が積み重なりやすい。

このエントリでは、runx が解決する課題、開発の背景、動作の仕組みを短く整理し、Azure CLI の具体例で使い方をまとめる。

結論

runx は、いつものコマンド名のまま、実行時に env ファイルを解決して環境変数を注入できるコマンドプロキシマネージャ。

  • runx add を一度実行すれば、以後は通常どおり azterraform を実行可能
  • env ファイルは実行ディレクトリから親へ、最後にホームまで探索
  • 複数 --envfile は左から順にマージし、後勝ちで上書き

常時自動読み込みより、コマンド実行時だけ適用したい運用に向いた選択肢。

runxとは

runx は、環境変数ファイルを読み込んでからコマンドを実行するためのクロスプラットフォームツール。単一バイナリで動作し、Linux/macOS/Windows に対応する。

主な特徴は次のとおり。

  • コマンドプロキシを作成し、通常のコマンド呼び出しで env を反映
  • 実行時のディレクトリに応じた env ファイル探索
  • 複数 env ファイルのレイヤー合成(後勝ち)
  • runx exec による一回限り実行
  • runx env で、実際に注入される内容を事前確認

インストール

まずは GitHub Releases から取得するのが簡単。

github.com

Linux では .deb パッケージ、または tar.gz の単体バイナリを利用できる。

curl -LO https://github.com/horihiro/runx/releases/download/v<version>/runx_<version>_<arch>.deb
sudo apt install ./runx_<version>_<arch>.deb
runx --version

tar.gz を使う場合は次のとおり。

curl -LO https://github.com/horihiro/runx/releases/download/v<version>/runx-<os>-<arch>-v<version>.tar.gz
tar xzf runx-<os>-<arch>-v<version>.tar.gz
sudo install -m 0755 runx /usr/local/bin/runx
runx --version

Windows では winget で導入できる。

winget install --id horihiro.runx
runx --version

ソースから試したい場合は、Go が入った環境で go build でもビルド可能。

なぜ作ったか

背景はシンプルで、プロジェクトごとに設定を分けつつ、操作感は変えたくないという要求にある。

例えば Azure CLI では、AZURE_CONFIG_DIR を切り替えることで認証情報や設定を分離できる。一方で、都度エクスポートしたり、ラッパースクリプトを手で管理したりすると、運用が増えるほど管理コストは上がっていく。

runx の設計方針は次の三点。

  • 普段のコマンド体験を壊さない
  • 実行時コンテキスト(カレントディレクトリ)を素直に利用
  • OS 差分をツール側で吸収

仕組みの概要

内部では、runx add で元コマンドを呼ぶ薄いプロキシを作成する。

  • Linux/macOS: シェル設定に関数を追加
  • Windows: .cmd プロキシを配置(User/Machine PATH を判定)

プロキシから runx exec が呼ばれ、指定した env ファイル候補を解決し、環境変数を注入してから実コマンドを実行する流れ。

env ファイル解決ルールは次の順序。

  1. 絶対パス指定ならそのパスのみ確認
  2. ファイル名指定なら、カレントディレクトリから親へ順に探索
  3. 見つからなければホームディレクトリを最後に確認

複数ファイルを指定した場合は左から順に読み込み、同一キーは後ろのファイルで上書きされる。

使い方(Azure CLIを例に)

AZURE_CONFIG_DIR をディレクトリごとに切り替える最小構成の例を示す。

  1. ディレクトリと env ファイルを用意
mkdir -p ~/work/az_profile1 ~/work/az_profile2

cat > ~/work/az_profile1/.azclienv <<'EOF'
AZURE_CONFIG_DIR=~/.azure_profile1
EOF

cat > ~/work/az_profile2/.azclienv <<'EOF'
AZURE_CONFIG_DIR=~/.azure_profile2
EOF
  1. az をプロキシ登録
runx add az --envfile=.azclienv
  1. 通常どおり az を実行
cd ~/work/az_profile1
az account show

cd ~/work/az_profile2
az account show

初回のみ、各プロファイルで az login が必要になる。現在位置で注入される内容を確認したい場合は runx env az が有効。

他ツールとの比較

この領域には実績あるツールが既にあり、runx は置き換えよりも使い分けの位置づけになる。

  • direnv
    • ディレクトリ入退場に応じた自動ロード/アンロード
    • 常時フック型の自動化に強み
    • Windows 非対応
  • dotenv-cli / dotenvx
    • 一回実行の明示的な env 注入に強み
    • コマンドごとの明示実行がわかりやすい
  • runx
    • add/list/remove で永続プロキシを管理
    • コマンド名はそのまま、実行時だけ env を適用
    • Windows の PATH 優先順位まで含めて吸収

どれが優れているかという話より、 自動フック中心か、明示実行中心か、普段のコマンド体験を維持したいかで選ぶのが自然。

制限・注意点

運用前に把握しておくとハマりにくいポイントを列挙する。

  • Linux/macOS では runx add 後にシェル設定の再読み込みが必要
  • Windows では元コマンドが Machine PATH にあると runx add に管理者権限が必要
  • env ファイルが見つからない、または構文不正のときは期待どおりに実行できない
  • 同じコマンドに対して他のラッパー機構を併用すると競合する可能性

調査時は RUNX_DEBUG=2 を使うと探索パスを追えるため、原因切り分けが速い。

まとめ

runx は、環境変数の切り替えを日常のコマンド操作に自然に埋め込むためのツール。

複数クラウド環境や複数プロファイルを行き来するチームでは、 毎回意識して切り替える負担を下げ、操作ミスの抑制にもつながる。

まずは runx exec の一回実行で試し、手応えがあれば runx add で常用コマンドをプロキシ化する流れがおすすめ。

Azure Functions で Easy Auth + OBO な MCP サーバーを作り、Microsoft Foundry Agent Playground から呼び出す

Microsoft Foundry のエージェントから呼ぶ MCP ツールで、OAuth 2.0 On-Behalf-Of(以下、OBO)フローを使って downstream API を実行した、という話。

先に結論

今回確認できた成果は、次の 2 つ。

  1. Easy Auth を有効にした Azure Functions の MCP endpoint へ Microsoft Foundry から OAuth ID パススルーで認証できた。
  2. MCP Tool Trigger 関数内で、Easy Auth 経由で受け取ったユーザートークンを使い、downstream API 実行用トークンを OBO フローで取得できた。

これにより、MCP ツールと downstream API の両方を利用者の権限コンテキストで実行できる。

マネージド ID やエージェント ID の権限設定も不要。

構成

今回の構成はこんな感じ。

  • MCP サーバー: Azure Functions
  • MCP endpoint: https://<function-app>.azurewebsites.net/runtime/webhooks/mcp
  • 入口認証: App Service Authentication (Easy Auth)
  • 関数内メイン処理: Azure Functions の MCP Tool Trigger 関数内で、OBO によるトークン交換と Azure Resource Graph 実行

Functions 側の設定

最低限、ここは揃えておく。

  • Easy Auth 有効化
  • Expose an API のスコープ確認
    api://<client-id>/user_impersonation

MCP Tool Trigger 関数の実装

今回の題材は、AI エージェントに Azure REST API を「チャットしているユーザーの権限」で実行させる MCP ツール。

ユーザーを特定するために Easy Auth で認証し、Azure REST API 呼び出し用トークンの取得には OBO フローを使っている。

サンプル実装は以下。

github.com

関数内でやっていることは、次の 4 ステップ。

  1. 受信リクエストからユーザートークンを取り出す
  2. Entra token endpoint で OBO 交換する
    OBO 交換に必要な以下の情報は、Easy Auth 有効化時に設定されるアプリケーション設定から取得
    • Tenant ID
    • Client ID
    • Client Secret
  3. OBO 交換済みトークンで downstream API (Azure REST API の Resource Graph) を呼ぶ
  4. 実行結果を JSON 文字列で返す

Entra アプリの設定

Easy Auth 有効化時に作成された Entra アプリの API Permissions で、downstream API を追加して管理者同意しておく。

今回は downstream API として Azure REST API を使うので、Azure Service Management を追加し、Grant admin consent を実行する。

これで MCP ツール側の準備は完了。

Microsoft Foundry 側の設定

次に、Microsoft Foundry 側で MCP ツールを追加する。

MCP ツール接続を OAuth ID パススルーで作る場合、設定は概ね次の形。


learn.microsoft.com

「Scope」には Application ID URI ではなく、api://<client-id>/user_impersonation のように実際のスコープを設定する。
加えて、今回はクライアントシークレットの設定も必要だった(後述)。

MCP ツール作成後にリダイレクト URI が表示される。

これを忘れずに Entra のアプリ登録へ追加しておく。

Playground で実験

OAuth ID パススルーの MCP ツールを設定して Microsoft Foundry の Playground でチャットを開始すると、最初に MCP ツール利用許可を求めるポップアップが表示される。

これを許可すると「You can now close this dialog.」と表示される。

ポップアップを閉じると、Playground 側には「サインインに成功しました」と表示される。

正常に認証されていれば、この後チャットで「リソースグループを列挙して」などと依頼すると MCP ツールが実行され、結果が返ってくる。

そう、正常であれば。

実際につまずいたポイント

もちろん最初からはうまくいかず、いくつかつまずきポイントがあった。

そもそもエラーが起こっているかどうかがわからない

うまく動かず試行錯誤していたとき、MCP ツール使用許可を与えて「サインインに成功しました」と表示された直後に、同じメッセージがもう一度出る挙動があった。

「大事なことなので 2 回言った」のかもしれないが、明らかに怪しい。

2 回とも許可後に「サインインに成功しました」と出ていたのに、実際には MCP ツールを使えなかった。

「成功した」と表示されるので、最初は認証後の処理が失敗しているのだと思っていた。実際には、認証そのものが失敗していた。

認証失敗に気づけたのは、ポップアップに「You can now close ...」と出た時の URL に ?error=T0F1dGgyIE... が付いていたから。

いや、わからんて。
認証成功時の動きと比べても、見た目の差分はほぼこの URL だけだった。

この error パラメータ値をコピーして Base64 デコードすると、OAuth2 Authorization Flow failed for service Generic Oauth 2 with PKCE. ... と出て、ここでようやく認証失敗を確信できた。

デコードされたエラーの一部

OAuth2 Authorization Flow failed for service Generic Oauth 2 with PKCE. OAuth 2 sign in 'OAuth2LoginStrategyCore' failed to exchange code for access token. Client ID and secret sent in form body.. Response status code=Unauthorized. Response body: {"error":"invalid_client","error_description":"AADSTS7000218: The request body must contain ...

ここでようやく、トラブルシュートに使える情報が出てきた。

AADSTS7000218 (invalid_client)

OAuth 設定不整合の典型例。

これが起きていた原因は、公式ドキュメントに書かれていた設定例を

そのまま当てはめてしまったこと。

ただ、今回はクライアントシークレットが必要な構成だった。

Easy Auth の Entra アプリで Allow public client flows がオフだった点も、切り分けの重要ポイントだった。

このエラーは、OAuth クライアント種別(public / confidential)と実際の設定値の組み合わせで発生条件が変わる。
Microsoft Foundry で secret 空欄のまま失敗したら、まずこの整合を確認するのが近道。

403 が返る

これは、切り分けのために Visual Studio Code から接続したときに出たエラー。
Microsoft Foundry 上で 403 エラーが見えたわけじゃない。

トークン付きで叩いても 403 の場合は、まず Easy Auth 側を疑うのが早い。

  • Allowed token audiences が aud と一致しているか
  • 制限をかける場合、azp / appid が許可済みか
  • Easy Auth の Client application requirement が実態とあっているか
    検証フェーズは Allow requests from any application で先に疎通確認。本番で段階的に絞るのが安全。

このあたりを順にチェックしていく。

MCP Tool Trigger 関数でのトークン取得

MCP Tool Trigger 関数の実装でもつまずいた。

公式ドキュメントの HTTP Trigger 向け説明をそのまま当てはめると、Easy Auth では X-MS-TOKEN-AAD-ACCESS-TOKEN ヘッダーにトークンが入る想定になる。

learn.microsoft.com

一方、今回の MCP Tool Trigger では HTTP ヘッダーが関数の第一引数(toolArguments)側の transport.properties.headers に入ってきた(ドキュメントは見つけられなかったが、実装上は ここここ あたりが該当しそう)。

Azure Functions 上で実行して X-MS-TOKEN-AAD-ACCESS-TOKEN ヘッダーの有無を確認したが、今回は見つからなかった。
代わりに Authorization ヘッダーがそのまま入っていたため、Bearer を除去してトークンを取り出した。

この点は実行環境やクライアント実装差の影響を受ける可能性があるため、まずは実際の入力ヘッダーをログで確認するのが安全。

トークン取り出し例:

export async function mcpToolTrigger(toolArguments: unknown, context: InvocationContext): Promise<string> {
  const transport = toolArguments['transport'] as {
    properties?: { headers?: { [key: string]: string } }
  } || {};

  const delegatedUserToken =
    transport?.properties?.headers?.Authorization?.split(' ')[1] || '';

  // OBO 交換の処理と downstream API の実行
}

Authorization のヘッダー名はクライアント実装によって大小文字ゆれがあり得るため、必要なら authorization もフォールバックで確認すると安全。

まとめ

Easy Auth で受けたユーザートークンを OBO で downstream API 用トークンに交換し、そのまま Azure Resource Graph を実行する MCP サーバーを Azure Functions 上に実装できた。さらに、その MCP サーバーを Microsoft Foundry の Agent Playground から呼び出せることも確認できた。

これで、エージェントから利用者権限のまま Azure 情報を扱う経路を作れたことになる。MCP ツール認証だけでなく、その先の downstream API 実行まで含めて成立することを確認できたのが今回の収穫だった。

Playground でここまで確認できたので、次は SDK からエージェントと会話したとき、この MCP ツール呼び出しがどう見えるかを試してみたい。

参考

qiita.com

Git の差分から CodeTour を生成する CLI ツールを作った。

はじめに

最近参加した👇のハンズオンイベントで、CodeTour を活用してコードの変更を段階的に説明しているのを目にした。

hack-everything.connpass.com

これはハンズオンを効率的に進められる素晴らしいアイデアだと思い、自分が主催するハンズオンでも CodeTour を活用したいと。

でも、実際に初期状態から完成形へと導く CodeTour ファイルを手作業で作成するには、各ステップでどのファイルのどの行が変更されたかを記録し、説明を付けていく作業が必要。
これは時間が大変そうだ。

変更の記録は Git のコミットのログ差分でわかってんだから、そこからサクっと作れればいいのに...

そう思って作った*1のがこの git2codetour
これを使えば、Git のコミット履歴から CodeTour ファイルを簡単に作れる。

そもそも CodeTour とは?

CodeTour は、コードベースをステップバイステップでガイドするツアーを作成できる VS Code拡張機能

marketplace.visualstudio.com

コードの特定の場所に説明を付けて、順番に閲覧できるため、ハンズオンイベント、チュートリアル、オンボーディングなどに使える。

git2codetour の特徴

今回作った CLI ツール git2codetour は、名前の通り、ローカルの Git リポジトリの各コミットの差分を読み取って CodeTour の設定ファイル形式で出力する。

https://www.npmjs.com/package/@horihiro/git2codetourwww.npmjs.com

1. シンプルな使い方

npx を使って簡単に実行:

npx @horihiro/git2codetour <commit1> <commit2> -o changes.tour

2. 柔軟なコミット指定

比較したいコミットは、

  • コミットハッシュ
  • ブランチ名
  • タグ名

どれでも指定可能*2

3. 3つ以上のコミットにも対応

複数のコミットを指定することで、段階的な変更の流れを1つのツアーとして確認できる:

npx @horihiro/git2codetour commit1 commit2 commit3 -o progression.tour

この例だと、commit1 から commit2 の変更と、commit2 から commit3 への変更の両方が記録される。

4. ファイルフィルタリング

glob パターンを使って、特定のファイルだけを対象にできる:

npx @horihiro/git2codetour main develop -f "src/**/*.ts" "*.md"

5. 豊富な言語サポート

JavaScript/TypeScript、PythonJavaC/C++C#、Go、Rust、Ruby など、主要な言語のシンタックスハイライトに対応*3

実際の使い方例

ケース1: ハンズオンイベント用のチュートリアル作成

ハンズオンで作成する予定のアプリケーションを事前に Git で開発しておき、段階的にコミットしておく:

npx @horihiro/git2codetour initial-state step1 step2 final -o hands-on.tour

生成された .tour ファイルを参加者に配布すれば、initial-state からの各ステップでどこをどう変更するかを明確に示せる。

ケース2: プログラミング学習教材の作成

初心者向けの学習教材として、簡単なアプリケーションを段階的に構築する過程を記録:

npx @horihiro/git2codetour v0.1 v0.2 v0.3 v1.0 -t "TODO アプリを作ろう" -o tutorial.tour

v0.1 v0.2 v0.3 v1.0 の各バージョンタグでどのように機能を追加していったかを、実際のコードとともに説明できる。

ケース3: 新メンバーへのオンボーディング

プロジェクトの重要な機能がどのように実装されたかを説明する際にも活用できる:

npx @horihiro/git2codetour feature-start feature-complete -t "認証機能の実装" -o onboarding.tour

出力形式

git2codetour は CodeTour が定めたスキーマJSON を出力する:

{
  "$schema": "https://aka.ms/codetour-schema",
  "title": "Changes from abc123 to def456",
  "description": "Diff between commits...",
  "steps": [
    {
      "file": "path/to/file.js",
      "description": "**Added:**\n```javascript\ncode here\n```",
      "line": 42
    },
    
      :

    {
      "file": "path/to/file.js",
      "description": "**Added:**\n```javascript\ncode here\n```",
      "selection": {
        "start": {
          "line": 1,
          "character": 1
        },
        "end": {
          "line": 1,
          "character": 15
        }
      }
    }
  ]
}

各ステップ(steps 配列の各要素) に含まれる情報:

  • file: 変更されたファイルのパス
  • description: 追加・削除・変更内容(シンタックスハイライト付き)
  • line: 変更箇所の行番号(変更が1行の場合)
  • selection: 変更範囲(変更が複数行にまたがる場合)

出力ステップ例

出力されるステップは以下の4種類:

  • ファイル作成ステップ
  • コード置換ステップ
  • コード削除ステップ
  • コード追加ステップ

デフォルトではシンプルな指示文(英語)が入る。
実際に使う時は、CodeTour 拡張機能から適当な文言に変更してもらう前提。

インストールと使い方

npx で直接実行

npx @horihiro/git2codetour <from-commit> <to-commit> [options]

オプション

  • -r, --repo <path>: ローカルの Git リポジトリのパス(デフォルト: カレントディレクトリ)
  • -t, --title <title>: CodeTour のタイトル
  • -d, --description <description>: CodeTour の説明
  • -f, --filter <pattern1> <pattern2> ...: 対象ファイルを絞り込む glob パターン
  • -o, --output <file>: 出力ファイルパス(デフォルト: 標準出力)
  • -a, --append: 出力ファイルへ追記するかどうかのフラグ

技術スタック

  • TypeScript: 型安全な開発
  • Commander.js: CLI インターフェース
  • simple-git: Git 操作

使用上の注意

git2codetour というよりも CodeTour 拡張機能自体の注意。

CodeTour 拡張機能から .tour ファイルで定義したツアーを再生すると、.tour ファイル内の line プロパティ(操作対象の行番号)が更新されるっぽい。
line プロパティがあるステップには、行の増減に応じて数値が増えたり減ったりするし、selectionプロパティで範囲指定している line プロパティがないステップには "line": null が強制的に挿入される。

つまり、一度再生した後は.tour ファイル内の各ステップの操作対象の行番号が変わっているので、ファイルだけ元に戻してもう一度再生しても、再生結果が変わってしまう。

なので、リプレイしたければ .tour ファイル自体も元に戻さないといけないことに注意。

これは CodeTour 拡張機能の挙動なので、正直何ともならない。

まとめ

git2codetour を使えば、Git のコミット差分を分かりやすい CodeTour 形式に変換できる。これにより:

  • ハンズオンイベントの準備が楽に: 段階的なチュートリアルを自動生成
  • 学習教材の作成が簡単: コードの変化を追いやすい形で記録
  • オンボーディングが効率化: 新メンバーへの説明がスムーズ
  • ドキュメント化が容易: 実装の過程を記録として保存

といった場面で、手作業で CodeTour ファイルを作成する手間が大幅に削減される。

特に、「コードがどのように進化していったか」を伝える必要がある場面で使える(はず)。

フィードバック募集中

使ってみた感想や要望があれば、GitHub Issues へ。

github.com


*1:最初の実装は、我らが副操縦士にほぼお任せ。便利な世の中だ。

*2: simple-git というモジュールにお任せ

*3:これは変更対象のファイルの拡張子を使ってコードブロックに設定を追加しているので、本当にきれいにレンダリングされるかは、CodeTour 次第だったり

Visual Studio Code で Azure Cloud Shell を開く

2025 年の最後の JAZUG での LT で喋った、Azure Cloud Shell (以下、Cloud Shell)を VSCode.dev で開くデモについて改めて*1

まずは実際の動作をご覧あれ。
youtu.be

やっていることはシンプル。

  1. vscode tunnel コマンドでリモートトンネルを作る
    • 動画では Micorosoft のアカウントを使用
  2. 作ったリモートトンネルを、 insiders.vscode.dev *2で開く
    • リモートトンネルを作った時のアカウントでログインする
    • URL フォーマット: https://insiders.vscode.dev/tunnel/${TUNNEL_NAME}/PATH/TO/WORKSPACE
    • https://insiders.vscode.dev を開いてから、リモートトンネルの拡張機能から指定してもOK

その後は、CloudShell 内を Visual Studio Code 的なユーザーインターフェースで操作できる。

GitHub Codespaces と似てるけど、GitHub Codespaces と違って環境は固定。
とはいえ、ある程度のツールはそろっているので、そこまで困らない気はする。

あとGitHub Codespacesは特定のリポジトリから起動するけど、こっちはリポジトリとの関連は無し。
ローカルからファイルをアップロードして、それを編集した後に、、例えば Azure のリソースを作ったり変更したり、という使い方ができるので、利用シーンで使い分けはできるはず。

補足

以前はCloud Shell 内でも code コマンドを使えばテキストエディターを起動することもできたけど、このコマンドがクラシック UI 限定に。

もちろん vinano とといったエディターが CloudShell には入っているけど、Cloud Shell 内でのファイル編集が億劫に感じてしまい、Cloud Shell 自体を敬遠しがちになってた。

そんな中、たまたま Cloud Shell に Visual Studio CodeCLI を発見。
リモートトンネル機能を試してみたら動いた。

特に Web ブラウザー限定という訳でもなく、GitHub Codespaces 同様、ローカルPC上の Visual Studio Code (Insider 含む) からリモートトンネルに接続すれば問題なく開ける。
なので、自分のような、ファイルを閉じようと ctrl + w でウィンドウごと閉じがちな人には、Visual Studio Code から接続する方が重宝しそう。

超重要な補足

Cloud Shell の動作上、既定のタイムアウトは 20 分間。
20分間、リモートトンネルを作っている Cloud Shell の操作*3がないと、Web ブラウザーと Cloud Shell との間の Web Socket が切断され*4、その後 Web Socket 接続が失った Cloud Shell のインスタンスは削除される。

リモートトンネルは Cloud Shell のインスタンスから作っているので、Cloud Shell のインスタンスが削除されればリモートトンネルも切断されてしまう。

つまり、リモートトンネルを維持するためには、クライアントが Web ブラウザーだろうが VSCode だろうが、vscode tunnel コマンドを実行している Cloud Shell のインスタンスを維持しないといけなく、その為にはCloud Shell 側のウィンドウまたはタブを定期的に操作しないといけない。

宣伝タイム*5

そんな時は、弊拡張機能 TweakIt for Microsoft Azure Portal をお使いいただければ。

chromewebstore.google.com

以前は Azure Portal Plus という名前で公開してたけど、「名前変えろ」と権利者から警告を受けた*6ので名称変更したこの拡張機能、Cloud Shell 周りに機能もいくつかご用意。

そのうちの一つが、Cloud Shell のセッションを 20 分以上維持する機能。

Azure 側での Cloud Shell インスタンスを強制解放されることもあるので、流石に丸一日セッションを維持することは現実的ではないしできないだろうけど*7、2, 3 時間だったらセッション維持できる。

Cloud Shell を使っている時によくある、

ちょっと離席してたらセッションが終了してた

みたいな事態は防げるので、Visual Studio Code から Cloud Shell を操作したい時は、合わせてコチラの利用も検討を。


*1:1月初旬に insiders.vscode.dev でリモートトンネルが開けなくなるバグが発生してたので割と手間取った

*2: https://vscode.dev は組織アカウントが必要。個人アカウント(~@outlook.com とか)では開けない

*3:厳密には、ターミナルへの文字出力や画面サイズ変更、といった画面への反映処理

*4:Web ブラウザー側から切断している模様

*5:ある意味本編

*6:詳細はコチラ https://uncaughtexception.hatenablog.com/entry/2025/08/20/083000

*7:そういう用途では VM たてて、そこからリモートトンネルを作りましょう

Microsoft FoundryになったAgent ServiceをNode.jsから試す

前に、Node.jsでAzure AI FoundryのAIエージェントを呼び出す時、@azure/ai-agents パッケージを使ったサンプルコードを書いた。

uncaughtexception.hatenablog.com

uncaughtexception.hatenablog.com

その1か月半後、Ignite 2025で Azure AI Foundry が Microsoft Foundry になり、Agent Service も結構な更新があり、新しいFoundryポータルで作ったエージェントでも試してみたところ、@azure/ai-agents パッケージを使ったサンプルコードは動かなかった*1

じゃあ何を使ってMicrosoft Foundryのエージェントを呼び出すのかっつーと、Ignite 2025の直前、11/13に 2.0.0-beta.1が公開された@azure/ai-projects パッケージを使えばいいらしい。

https://www.npmjs.com/package/@azure/ai-projects/v/2.0.0-beta.1
github.com

今のところ、@azure/ai-agents パッケージ側は更新する雰囲気はないので、@azure/ai-projects パッケージを使った方法も勉強がてら試してみた。

クラシックのFoundryポータルで作ったエージェント*2に対しては @azure/ai-agentsパッケージのAgentsClientを使って、Thread/Message/RunといったFoundry Agent独自にオブジェクトを作り*3、エージェントと会話をしていた。

一方、新しいFoundryポータルで作ったエージェントは(誤解を恐れずに言うと)OpenAIのパッケージをほぼそのまま使う形。
なので、以前からOpenAIのSDKを使っている人はそのまま使えそう。

エージェントの指定は、オプションで👇のJSONを渡せばいいだけ。

https://github.com/horihiro/microsoft-foundry-agent-simple-conversation-sample/blob/main/src/index.ts#L58

シンプル過ぎて不安になる。

結果、よくあるコンソールチャットアプリが出来上がり。

github.com

MCPツール実行時の承認も実装済み。


*1:クラシックなFoundryポータルで作ったエージェントはまだ動く

*2:Ignite前の唯一の方法

*3: @azure/ai-projects の v1も同じ

Azure AI Foundry Agent ServiceでAzure Functionsツールを使ってみる

前回は環境づくりまで。

uncaughtexception.hatenablog.com

やっと本題のAzure Functionsとの連携。

AIエージェントへのツール設定

標準セットアップのAzure AI Foundry Projectを作ったので、ここにAIエージェントを作り、そのエージェントにツールを登録する。

CLIツールはなさそうなので、curlを使ってツール登録(AIエージェント更新)のREST APIを叩いてみる。

最初に認証用トークンの取得と、更新したいAIエージェントを情報を設定する。

$ TOKEN=$(az account get-access-token --resource https://ai.azure.com --query "accessToken" --output tsv)
$ AI_FOUNDRY_NAME=
$ AI_FOUNDRY_PROJECT_NAME=
$ AGENT_ID=

揃えた情報を使って、curlでAIエージェントを更新する。

$ curl --request POST --header "Content-Type: application/json" \
  --header "Authorization: Bearer ${TOKEN}" \
  --url "https://${AI_FOUNDRY_NAME}.services.ai.azure.com/api/projects/${AI_FOUNDRY_PROJECT_NAME}/assistants/${AGENT_ID}?api-version=2025-05-01" 
  --data '{
  "tools": [{
    "type": "azure_function",
    "azure_function": {
      "function": {
        "name": "queueTrigger1",
        "description": "The function triggered by queue storage",
        "parameters": {
          "type": "object",
          "properties": {
            "param1": { "type": "some_type","description": "Parameter 1"},
            "param2": { "type": "some_type","description": "Parameter 2"},
              :
            "param2": { "type": "some_type","description": "Parameter X"}
          },
          "required": ["param1", ..]
        }
      },
      "input_binding": {
        "type": "storage_queue",
        "storage_queue": {
          "queue_service_endpoint": "https://<STORAGE_ACCOUNT_NAME>.queue.core.windows.net",
          "queue_name": "<INPUT_QUEUE_NAME>"
        }
      },
      "output_binding": {
        "type": "storage_queue",
        "storage_queue": {
          "queue_service_endpoint": "https://<STORAGE_ACCOUNT_NAME>.queue.core.windows.net",
          "queue_name": "<OUTPUT_QUEUE_NAME>"
        }
      }
    }
  }]
}' 

これでツール登録は完了。

以降、ユーザのプロンプトからAIエージェントがこのツールの実行が必要と判断すると、AIエージェントはinput_bindingのキューにメッセージを保存する。
その後、ツールからの応答メッセージがoutput_bindingのキューを監視して、キューにメッセージが入ってきたら、そのメッセージを解析して回答する。

input_bindingoutput_bindingで指定したQueue Storageにメッセージを保存したり取り出したりするので、プロジェクトのマネージドIDがアクセスできる必要がある。
RBACでロールを割り当てておくことを忘れずに。

AIエージェントが使う関数の作成

次にツールとなるAzure Functions側。あまりドキュメントに書かれていないので手探り実装。

ポイントをまとめると、

  • AIエージェントはinput_bindingのキューにメッセージを送ってくるので、まずはそのキューから起動するQueueトリガーの関数を作る
  • AIエージェントはツールから応答としてoutput_bindingのキューで待っているので、キューの出力バインディングも追加する。
  • AIエージェントがinput_bindingのキューに保存してくるメッセージは、

    {
      "CorrelationId": "......",
      "param1": ... ,
      "param2": ... ,
         :
      "paramX": ...
    }
    

    のように、CorrelationIdとツール登録で指定したその他のパラメータ(param1, param2, ... , paramX)を持つJSON

  • 出力バインディングでAIエージェントに応答するメッセージは、CorrelationIdValueの二つのプロパティを持つ。

    {
      "CorrelationId": "......",
      "Value": "..." 
    }
    
    • CorrelationIdは、AIエージェントが入力してきたメッセージのものと同じ。 一致させないと、AIエージェントがどの入力に対する応答なのかがわからなくなるので、関数内で設定することを忘れずに。
    • Valueは文字列。
      JSONの場合は必ず JSON.stringify してから渡さないと[object Object]みたいのがAIエージェントに渡されることになる。

実装例

ザックリと。

import { app, InvocationContext, output } from "@azure/functions";

const queueNames = {
  input: '<INPUT_QUEUE_NAME>',
  output: '<OUTPUT_QUEUE_NAME>'
};

const queueOutput = output.storageQueue({
  queueName: queueNames.output,
  connection: 'STORAGE_CONNECTION'
});

export async function queueTrigger1(queueItem: unknown, context: InvocationContext): Promise<void> {
    const { CorrelationId, param1, param2, ... , paramX } = queueItem;

    const outputMessage = {};

    // 関数の実装
    // param1, param2, ... , paramXを使って何らかの処理をして、その結果をoutputMessageに格納する

    const outputPayload = {
      CorrelationId,                                // 入力メッセージから取得した値を使う
      Value: JSON.stringify(outputMessage, null, 2) // `Value`というプロパティに文字列として格納する
    };
    context.extraOutputs.set(queueOutput, outputPayload);
}

app.storageQueue('queueTrigger1', {
    queueName: queueNames.input,
    connection: 'STORAGE_CONNECTION',
    extraOutputs: [queueOutput],
    handler: queueTrigger1
});

使用上の注意

①処理時間のタイムアウトがわからない。

AIエージェントが入力メッセージを入れてから、応答メッセージを受け取るまで、何秒以内に収めないといけないのか、ドキュメントの記載が見つけられなかった。
無限に待つはずはないので、「○○秒以内に応答」みたいな仕様が必ずある気がする。

②CorrelationIdがデカい

入力メッセージに入ってくるCorrelationIdがだいたい14KBくらいある。
なので、応答メッセージのうち14KBはこのCorrelationIdに占められる。

一方で、Queue Storageのメッセージサイズは64KBまでなので、応答メッセージの本文は50KB程度が限界。

14KBも使わず、UUIDくらいにならんもんか*1

(2025/11/04追記)
100Bくらいに圧縮されたみたい。
短くなってよかった。
(追記終わり)

他の連携手段

Azure FunctionsはOpenAPI互換のHTTPトリガーが使えるし、何だったらリモートMCPサーバにもなるので、Queueトリガーだけが連携手段じゃない。

OpenAPI互換のHTTPトリガーもリモートMCPサーバも、作るのも呼び出すのも簡単。
ただOpenAPI互換の方を使って見た感じ、AIエージェントがAPIを実行する時に、クエリーで定義しているパラメータをリクエストボディに入れて実行することが多々見られたし、 リモートMCPサーバは、AIエージェントがユーザに承認を求めてくるので*2,*3、その部分を考慮する必要がある。

処理時間の制限が見つけられなかったのはどれも一緒。

Queueトリガー関数の場合、リトライの仕組みがAzure Functions側でサポートされているのがメリットか。

どれも一長一短て感じかな。