ほりひログ

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

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側でサポートされているのがメリットか。

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


Azure AI Foundry Agent ServiceのAzure Functionsツールを試すはずだったのに、気づいたらIaCしてた

Azure AI Foundry Agent ServiceでAzure Functionsツールを使ってみたかった。

手始めに、AzureポータルからAzure AI Foundry -> Foundryプロジェクト -> エージェントと作っていき、エージェントにアクションを追加してみると、

なんと、ポータルからは追加できない。。。

大丈夫。俺たちにはREST APIがある。

このドキュメントを頼りに、az restでリクエストしてみると、
Enterprise Standardじゃないとダメ(超意訳)、とな。。。

確かにこっちのドキュメントにも
と書いてある。

調べてみると、Azure AI Foundryのプロジェクトの構成には、いくつかパターンがあるらしい。
learn.microsoft.com

曰く、

  • 基本的なセットアップ: AzureポータルやAI Foundryのポータルから作れるプロジェクト。
    Azure AI Foundry Agent Serviceのエージェント管理用ストレージとして、Azureプラットフォームが準備したマネージドなストレージを使う。
  • 標準セットアップ: エージェント管理用ストレージ類を、ユーザが用意してBYOxするプロジェクト。
    プロジェクトレベルのデータ分離のコントロールができる。
    ただしAzureポータルやAI Foundryのポータルからは作れないし、エージェントが使えるツールの違いはAzure Functionsくらい。

最初に作ったプロジェクトはポータルから作ったので、「基本的なセットアップ」というものだったようだ。

Azure Functionsツールを使うには「基本的なセットアップ」のプロジェクトではダメなので「標準セットアップ」が必要だけど、AzureポータルやAI Foundryのポータルから作れないのでテンプレートを書いてみた。

github.com

くいっくすたーと

まず事前に以下のリソースを準備して、それぞれのリソースIDを控えておく。

  • Azure AI Foundry
  • Azure Cosmos DB
  • Azure AI Search
  • Azure Storage Account

あとはリポジトリ内の main.bicep を使って az deployment group create -f main.bicep -g ... の様にデプロイする。

ついでに作ったDeploy to Azureボタンからもデプロイできる。
# 久々過ぎて色々忘れてた

Deploy to Azure

すると、 👆の4つのリソースIDを聞いてくるので、コピーしておいたリソースIDを入れて数分待てばデプロイ完了。

デプロイの流れ

テンプレートでやっていることは、

  1. リソースIDで指定した以下のリソースがちゃんとあるかを確認する
    • Azure AI Foundry
    • Azure Cosmos DB
    • Azure AI Search
    • Azure Storage Account
  2. AI Foundryリソースの下にプロジェクトリソースとリソース接続を作成する
  3. プロジェクトリソースのマネージドIDに以下のロールを割り当てる
    • Cosmos DBのCosmos DB Operator
    • Storage AccountのStorage Blob Data Contributor
    • AI SearchのSearch Index Data ContributorとSearch Service Contributor
  4. AI Foundryリソースとその下のプロジェクトリソースのそれぞれに機能ホストを作成する
    この機能ホストを作ると、プロジェクトのマネージドIDがCosmosDBとStorage Accountの中に以下のコンテナを作る
    • Cosmos DB内のコンテナ
      • <projectWorkspaceId>-thread-message-store
      • <projectWorkspaceId>-system-thread-message-store
      • <projectWorkspaceId>-agent-entity-store
    • Storage AccountのBlobコンテナ
      • <projectWorkspaceId>-azureml-blobstore
      • <projectWorkspaceId>-<12文字のランダム?文字列>-azureml-agent
  5. プロジェクトリソースのマネージドIDに以下のロールを割り当てる
    • Cosmos DBのコンテナのCosmos DB Built-in Data Contributor
    • Storage AccountのStorage Blob Data Owner

という感じ。

<projectWorkspaceId>はUUIDの書式の文字列で、これはプロジェクトリソースにある internalIdという32文字のプロパティのところどころにハイフンを入れていけばできる。
一方で<12文字のランダム?文字列>の部分は謎。どこから来たのかわからない文字列が入っている。

ほぼ👇に書いてある通り(粒度が違うけど)なので、どうぞご参考に。
learn.microsoft.com

少し補足。 こっちのドキュメントには準備するリソースとしてKey Vaultも書いてある。
けど、準備の項目以降一度も出てこないので、何に使うのかさっぱりわからない。

今回のテンプレートには含めていない。

実は

似たような公式テンプレートは既にあった

この公式テンプレートの方で試してみた感じ、Azure AI Foundryのリソースやプロジェクトの名前など、いろんなところにランダム文字列を勝手に追加してしまうので、あまり使い勝手がよくない。

そこで今回、この公式テンプレートを参考にして、自分好みのリソース名でデプロイできる、よりお手軽なテンプレートを作ってみた。

環境を作るところで燃え尽きたので、メインのAzure Functionsとの連携はまた今度。


(続)Azure AI Foundry Agent ServiceのPythonサンプルが動かせなかったのでNode.jsでMCPツールを組み込んでみた

前回のエントリーで謎だったMCPツールの事前承認のやり方がわかったので、急遽続編。

uncaughtexception.hatenablog.com

前エントリーの時点の理解(=誤解)

  • MCPツールの事前承認は、どう頑張っても(とまでは書いてないけど)できそうもない
  • なので、以下の流れで回答を受け取る実装をした
    1. 「実行」を作成してストリームを受け取り、そのストリームからメッセージを取得しつづける
    2. エージェントがMCPツールが必要だと判断したら、承認依頼のメッセージを流す。
    3. エージェントから承認依頼のメッセージが来たら、最初のストリームは終了。
      「実行」に対して承認を送信して次のストリームを受け取る
    4. その後はストリームから再度メッセージを取得して回答を受け取る

承認待ちの段階でストリームのメッセージが中断しまうので、再度ストリームを作るのが若干面倒だった。

その後の(自分の中での)アップデート

  • MCPツールの事前承認は、「実行」の作成時であれば可能*1
  • なので、以下の流れで、中断なしのストリーム取得が可能に。
    1. 「実行」の作成時に事前承認のパラメータを渡してストリームを受け取り、そのストリームからメッセージを取得し続ける
    2. エージェントがMCPツールが必要だと判断しても、MCPツールは事前承認済みなので、
      エージェントからの承認依頼のメッセージが中断することなく、エージェントがMCPツールを実行する
    3. 最初に作成したストリームからメッセージを取得し続けていれば回答まで受け取れる

実際のやり方は、client.runs.createの引数としてtoolResourceを設定する。

例えば、エージェントに設定されている全MCPツールをまとめて事前承認したいなら、client.runs.createの引数を👇のようにしたらいい。

  const streamEventMessages = await client.runs.create(
    thread.id, // threadId
    agent.id, // agentId
    {
      toolResources: {
        // Pre-approve all MCP tools that are set in the agent
        'mcp': agent.tools.filter(t => 'serverLabel' in t).map((t) => ({
          serverLabel: t.serverLabel,
          requireApproval: 'never',
          headers: {}
        }))
      }
    }
  ).stream();

訂正後の全コード

ask_agent.ts

import {
  Agent,
  AgentsClient,
  DoneEvent,
  ErrorEvent,
  MessageDeltaChunk,
  MessageDeltaTextContent,
  MessageStreamEvent,
  RequiredToolCall,
  RunsSubmitToolOutputsToRunOptionalParams,
  RunStreamEvent,
  SubmitToolApprovalAction,
  ThreadRun
} from '@azure/ai-agents';
import { DefaultAzureCredential } from '@azure/identity';

import dotenv from 'dotenv'
dotenv.config();

// check if the object is ThreadRun
const isThreadRun = (x: any): x is ThreadRun => {
  return x && typeof x.id === "string";
}

const main = async () => {
  if (!process.env.PROJECT_ENDPOINT) {
    throw new Error("Missing PROJECT_ENDPOINT environment variable");
  }
  const client = new AgentsClient(process.env.PROJECT_ENDPOINT, new DefaultAzureCredential());

  const agentId = process.argv[2];
  if (!agentId) {
    throw new Error("Please provide the agent ID as a command line argument");
  }
  const ask = process.argv[3] || "Please explain the spec of Azure AI Services.";

  // retrieve the agent
  const agent = await client.getAgent(agentId);

  // create a new thread
  const thread = await client.threads.create();

  // send a user message to the agent
  const message = await client.messages.create(
    thread.id,
    "user",
    ask,
  );

  const streamEventMessages = await client.runs.create(
    thread.id, // threadId
    agent.id, // agentId
    {
      toolResources: {
        // Pre-approve all MCP tools that are set in the agent
        'mcp': agent.tools.filter(t => 'serverLabel' in t).map((t) => ({
          serverLabel: t.serverLabel,
          requireApproval: 'never',
          headers: {}
        }))
      }
    }
  ).stream();
  for await (const eventMessage of streamEventMessages) {
    switch (eventMessage.event) {
      case RunStreamEvent.ThreadRunCreated:
        console.debug(`ThreadRun status: ${(eventMessage.data as ThreadRun).status}`);
        break;
      case MessageStreamEvent.ThreadMessageDelta:
        {
          const messageDelta = eventMessage.data;
          (messageDelta as MessageDeltaChunk).delta.content.forEach((contentPart) => {
            if (contentPart.type === "text") {
              const textContent = contentPart as MessageDeltaTextContent;
              const textValue = textContent.text?.value || "";
              process.stdout.write(textValue);
            }
          });
        }
        break;
      case RunStreamEvent.ThreadRunCompleted:
        process.stdout.write("\n");
        console.debug("Thread Run Completed");
        break;
      case ErrorEvent.Error:
        console.debug(`An error occurred. Data ${eventMessage.data}`);
        break;
      case RunStreamEvent.ThreadRunRequiresAction:
        console.debug("Tool approval required.");
        if (!isThreadRun(eventMessage.data)) {
          console.warn("Received ThreadRunRequiresAction but event data is not a ThreadRun:", eventMessage.data);
          continue;
        }
        eventMessage.data;
        break;
      case DoneEvent.Done:
        console.debug("Stream completed.");
        break;
    }
  }
};

main().catch((err) => {
  console.error("Error in conversation:", err);
});

ちなみにPython

set_approval_modeなるわかりやすいメソッドがあるので、そいつを使うといいらしい。


*1:エージェントの作成時や更新ではできないのは変わらず

Azure AI Foundry Agent ServiceのPythonサンプルが動かせなかったのでNode.jsでMCPツールを組み込んでみた

前回はMPCサーバをAzure Functionsで作ることを試したけど、今度はMCPサーバを使う側を試してみる。

試すのは、Azure AI FoundryのAgent ServiceからのMCPサーバの呼び出し。
ちなみにまだプレビュー。

learn.microsoft.com

サンプルを試す。

サンプルは、SDKを使ったPythonコードと、curlコマンドを使ったREST API実行の2つ。

learn.microsoft.com

Pythonのサンプルを試す。

今まで雰囲気でPython使っていたので、このページだけだと何から始めたらいいかピンとこないけど、少なくとも from ... import に書かれている azure.identity azure.ai.projects azure.ai.agents くらいは入れないといけないはず。

入れてみた。

$ pip install azure.identity azure.ai.projects azure.ai.agents

あとはコードをマルっとコピーして、環境変数をいくつか設定して実行。

。。。

動かない。
肝心の McpTool がインポートできない、と言われた。

Python詳しくないので、早々に諦める。

REST APIのサンプルを試す

bash上でcurlでエンドポイントにアクセスしているだけなので、こっちの方がわかりやすい。

ただ、SDKだと良しなにしてくれる認証に必要なアクセストークンは、ここに書いてある👇のコマンドで取得する。

$ az account get-access-token --resource 'https://ai.azure.com' | jq -r .accessToken | tr -d '"'

実際は、環境変数 AGENT_TOKEN に入れて使うけど、長ーいトークンをコピーするのが面倒なので、

$ AGENT_TOKEN=$(az account get-access-token --resource 'https://ai.azure.com' | jq -r .accessToken | tr -d '"')

としてる。

あと API_VERSION という環境変数も設定。

$ API_VERSION=2025-05-15-preview

ちゃんとプレビューのバージョンを指定。

あとは書かれているcurlコマンドを、ただただ実行。

。。。

初っ端のエージェント作成から失敗。

まず、リクエストボディの指定でJSON文字列全体を " (ダブルクォーテーション)で囲っているのに、JSON内の "エスケープしていない。
JSON内をエスケープするのが面倒だったので、全体を囲む "' (シングルクォーテーション) に変更。

まだある。
付けちゃいけない末尾カンマが付いているのでこれも削除。

まったく、、、そういうとこだぞ。

この修正*1をしたらエージェントの作成は成功。

その後は、

  1. スレッドの作成
  2. スレッドへのメッセージ(質問文)の追加
  3. エージェントを指定してスレッドを実行
  4. 実行の状態を取得

というステップでcurlREST APIを実行していき、無事成功。

すると、実行の状態がMCPツールの実行承認待ち、ということになっているのがわかるので、MCPツールの実行を承認するREST APIを実行と、スレッドメッセージ一覧にエージェントから回答が含まれている。

なるほど、実行の流れはわかった。

ただ、各REST APIのレスポンスに含まれるID(エージェント、スレッド、実行、MCPツールのコール、等々)をコピーして、次のREST APIのパラメータに使うのがややだるい。

やっぱりプログラムで何とかしたい。

Javascriptで試す

どうせ壁に当たるので、Pythonサンプルを何とかして動かすよりも、Javascriptの方が理解しやすいので、Javascriptでサンプルと同じ流れを作ってみた。

そして早速一番デカい壁に当たる。

最新の @azure/ai-agentsパッケージでも MCPツールに対応していない。
MCPツールの登録はエージェント作成の時に行うが、そのインターフェースが元々プレビューの機能なので、このチャレンジを始めた時点でSDKが対応していなかった。

しょうがないので、以下のSDKが対応していない処理はREST APIで代用。

  • エージェント作成時のMCPツール登録
  • MCPツール実行の承認
  • 承認後のメッセージ取得

結果、SDKREST APIが入り混じる、人様に見せられるものではないけど、自分の中ではかなり理解が深まった。

サンプルコード

作成したコードがこちらに、、、というタイミングで、@azure/ai-agentsのベータ版が公開され、なんとMCPツールに対応してしまった。
間が悪い。

なので、頑張ってREST APISDKがごちゃ混ぜになったコードはお蔵入りにして、改めてSDKのみで書き直し。
処理の基本の流れは、REST APIだろうがSDKだろうが特に変わりなし。

超重要な@azure/ai-agentsのバージョンは1.2.0-beta.1
package.json だとこんな感じ👇。

{
    :
  "dependencies": {
    "@azure/ai-agents": "^1.2.0-beta.1",
    "@azure/identity": "^4.12.0",
    "comment-json": "^4.2.5",
    "dotenv": "^17.2.2"
  }
    :
}

全くの好みだけど*2、エージェントを作るスクリプト create_agent.tsと、作ったエージェントに質問をするスクリプト ask_agent.ts の二つに分けた。

二つのスクリプトで使う環境変数は以下の三つ。

  1. PROJECT_ENDPOINT:
    Azure AI Foundry プロジェクトのエンドポイント
    👇のような書式。

    https://.services.ai.azure.com/api/projects/

  2. MODEL_DEPLOYMENT_NAME:
    エージェントが使うLLMモデルのデプロイ名。事前にデプロイされてる前提。

  3. MCP_CONFIG_PATH:
    MCPサーバの名前とURLを書いておくJSONファイル。
    今回は.vscode/mcp.jsonの書式を読めるようにした。
    けど .MCPサーバ名に含められないので注意*3

エージェント作成用スクリプト

最初は、MCPツールを設定したエージェントを作るスクリプト
といっても、半分は mcp.json をパースためのコードで、実質 AgentClient#createAgent するだけ。

一点だけ気になる点があって、ドキュメント上はAgentClient#createAgent の引数の中に toolResources が設定できることになっているけど、これは効果がなかったこと。

これが効くならMCPツールの事前承認ができるので、もう1つの質問用のスクリプトの実装がだいぶ楽になったんだけど、
REST APIでのエージェントの新規作成/更新時に toolResources の設定を追加してもダメだったので、どの言語のSDKでやってもダメだと予想。

SRあげるしかないのかな?

create_agent.ts

import { AgentsClient } from '@azure/ai-agents';
import { DefaultAzureCredential } from '@azure/identity';
import { parse, stringify } from 'comment-json';
import { readFile } from 'fs/promises';

import dotenv from 'dotenv'
dotenv.config();

// load the mcp.json as MCP tool array
const loadMCPToolFromConfigPath = async (mcpConfigPath: any) => {
  const mcpConfigContent = await readFile(mcpConfigPath, 'utf8');
  const mcpConfig = JSON.parse(stringify(parse(mcpConfigContent, null, true)));
  return {
    tools: Object.keys(mcpConfig.servers).map((serverLabel) => {
      const server = mcpConfig['servers'][serverLabel];
      return {
        type: 'mcp',
        serverLabel,
        serverUrl: server.url
      };
    }),
    toolResources:
    {
      'mcp': Object.keys(mcpConfig.servers).map((serverLabel) => {
        return {
          serverLabel,
          requireApproval: 'never',
          headers: {}
        };
      })
    }
  };
};

const main = async (options: { agent?: { name?: string; instructions?: string }, model: { name: string } }) => {
  if (!process.env.PROJECT_ENDPOINT) {
    throw new Error("Missing PROJECT_ENDPOINT environment variable");
  }

  if (!process.env.MODEL_DEPLOYMENT_NAME) {
    throw new Error("Missing MODEL_DEPLOYMENT_NAME environment variable");
  }

  if (!process.env.API_VERSION) {
    throw new Error("Missing API_VERSION environment variable");
  }

  if (!process.env.MCP_CONFIG_PATH) {
    throw new Error("Missing MCP_CONFIG_PATH environment variable");
  }

  const modelDeploymentName = options.model.name;
  const agentName = options?.agent?.name || `my-agent-${Date.now()}`;
  const agentInstructions = options?.agent?.instructions || "You are a helpful assistant";
  const { tools, toolResources } = await loadMCPToolFromConfigPath(process.env.MCP_CONFIG_PATH);

  const client = new AgentsClient(process.env.PROJECT_ENDPOINT, new DefaultAzureCredential());

  const agent = await client.createAgent(
    modelDeploymentName,
    {
      name: agentName,
      instructions: agentInstructions,
      toolResources, // 効果なし
      tools
    }
  );
  return agent
};

main({
  model: {
    name: process.env.MODEL_DEPLOYMENT_NAME || "gpt-4o"
  },
}).then((data: any) => {
  console.log("Agent created:", data.id);
}).catch((err) => {
  console.error("Error creating agent:", err);
});

質問用スクリプト

もう1つが、👆で作成したエージェントに対してプロンプトを送信して、レスポンスを表示するスクリプト

このSDKの使い方としては、プロンプトをエージェントに送信すると、エージェントからの応答はストリームを通してツラツラと流れてくるのでそれを取得する、というのが主流らしい。


(追記)

MCPツールを事前承認する方法がわかったので、ストリームの中断と再取得が不要なように質問用スクリプトを更新。

uncaughtexception.hatenablog.com


一方で、MCPツールを使う場合、プロンプトに回答する上でエージェントがMCPツールが必要と判断すると、MCPツール利用をユーザに承認してもらうために一度ストリームに流れる応答が止まって終了する。
ここで承認用のメソッド submitToolOutputs を実行すると、再度レスポンスのストリームがとれて、そこに応答の続きが流れてくるので、そこからMCPツールを使った回答を取得、というのが大まかな流れ。

ask_agent.ts

import {
  Agent,
  AgentsClient,
  DoneEvent,
  ErrorEvent,
  MessageDeltaChunk,
  MessageDeltaTextContent,
  MessageStreamEvent,
  RequiredToolCall,
  RunsSubmitToolOutputsToRunOptionalParams,
  RunStreamEvent,
  SubmitToolApprovalAction,
  ThreadRun
} from '@azure/ai-agents';
import { DefaultAzureCredential } from '@azure/identity';

import dotenv from 'dotenv'
dotenv.config();

// check if the object is ThreadRun
const isThreadRun = (x: any): x is ThreadRun => {
  return x && typeof x.id === "string";
}

const responseHandler = async (client: AgentsClient, threadId: string, agentOrThreadRun: Agent | ThreadRun) => {
  const streamEventMessages = await (async () => {
    return await (('requiredAction' in agentOrThreadRun)
      ? client.runs.submitToolOutputs(
        threadId, // threadId
        agentOrThreadRun.id, // runId
        [], // toolOutputs
        {
          toolApprovals: (agentOrThreadRun.requiredAction as SubmitToolApprovalAction)?.submitToolApproval.
            toolCalls.map((tc: RequiredToolCall) => ({ toolCallId: tc.id, approve: true })) || []
        } as RunsSubmitToolOutputsToRunOptionalParams // options
      )
      : client.runs.create(
          threadId,
          agentOrThreadRun.id  // agentId
      )
    ).stream();
  })();

  let threadRun: ThreadRun | undefined = undefined;
  for await (const eventMessage of streamEventMessages) {
    switch (eventMessage.event) {
      case RunStreamEvent.ThreadRunCreated:
        console.debug(`ThreadRun status: ${(eventMessage.data as ThreadRun).status}`);
        break;
      case MessageStreamEvent.ThreadMessageDelta:
        {
          const messageDelta = eventMessage.data;
          (messageDelta as MessageDeltaChunk).delta.content.forEach((contentPart) => {
            if (contentPart.type === "text") {
              const textContent = contentPart as MessageDeltaTextContent;
              const textValue = textContent.text?.value || "";
              process.stdout.write(textValue);
            }
          });
        }
        break;
      case RunStreamEvent.ThreadRunCompleted:
        process.stdout.write("\n");
        console.debug("Thread Run Completed");
        break;
      case ErrorEvent.Error:
        console.debug(`An error occurred. Data ${eventMessage.data}`);
        break;
      case RunStreamEvent.ThreadRunRequiresAction: 
        console.debug("Tool approval required.");
        if (!isThreadRun(eventMessage.data)) {
          console.warn("Received ThreadRunRequiresAction but event data is not a ThreadRun:", eventMessage.data);
          continue;
        }
        threadRun = eventMessage.data;
        break;
      case DoneEvent.Done:
        console.debug("Stream completed.");
        break;
    }
  }
  return threadRun;
}

const main = async () => {
  if (!process.env.PROJECT_ENDPOINT) {
    throw new Error("Missing PROJECT_ENDPOINT environment variable");
  }
  const client = new AgentsClient(process.env.PROJECT_ENDPOINT, new DefaultAzureCredential());

  const agentId = process.argv[2];
  if (!agentId) {
    throw new Error("Please provide the agent ID as a command line argument");
  }
  const ask = process.argv[3] || "Please explain the spec of Azure AI Services.";

  // retrieve the agent
  const agent = await client.getAgent(agentId);

  // create a new thread
  const thread = await client.threads.create();

  // send a user message to the agent
  const message = await client.messages.create(
    thread.id,
    "user",
    ask,
  );

  let threadRun: ThreadRun | undefined;
  do {
    // start a new run and stream events
     threadRun = await responseHandler(client, thread.id, threadRun ? threadRun : agent);
  } while (threadRun);
};

main().catch((err) => {
  console.error("Error in conversation:", err);
});

エージェント作成のスクリプトのところに書いた、ツールの事前承認ができれば、応答途中の承認を考えないで済むので、もっと楽な実装になるんだけど、プレビューのせいか、そうもいかない様子。

Pythonのサンプル、その後。

さて、Pythonのサンプルコードが動かなかった件、Javascriptでもプレビュー対応したライブアリが必要だったので、「もしや」と思ったら、ドンピシャだった。
Previewに対応した

  • azure.ai.projects==1.1.0b4
  • azure.ai.agents==1.2.0b4

の二つをインストールしたらいいだけ。

そういうのは書いとけよ*4

まったく、、、そういう(ry


*1:まとめて https://github.com/MicrosoftDocs/azure-ai-docs/pull/546 でプルリクエスト提出済み。

*2:毎回エージェントを作って消す、をするのが嫌だった

*3:Azure AI Foundry Agent Serviceの仕様?

*4:*1👆のプルリクエストに混ぜた

Azure Functions (Node.js)でリモートMCPサーバを作ってみる

ほぼ初めてMCPサーバなるものにチャレンジ。
Function Callingはすっ飛ばしてMCP

何やらMCP界隈では、Server Sent Eventというのがレガシー扱いになりStreamable HTTPというのが主流になったとか(よくわかっていない)。

そのStreamable HTTPにAzure Functionsも対応したらしいので(どう変わったのかわからんけど)、とりあえず久々にAzure Functionsを触ってみる。

言語はいつものNode.jsで。
C#Pythonはこちらのブログが詳しい。

C#:
blog.shibayan.jp

Python:
blog.beachside.dev

実装する

まずはVS CodeからAzure Functions用のプロジェクトを作る。

適当なディレクトリを開いて、以下のメニューから作成。

言語はTypeScriptを選択。

一緒に関数も作るか聞かれるけど、MCPトリガーの関数はここから選べないので、今回はスキップ。

プロジェクトが出来上がるので、まず最初にやらないといけないのは、ExtentionBundleのバージョン指定。

プロジェクトのルートにある host.json を開いて、extensionBundleid.Experimentalを追記。

versionの変更はなし。

なんでこんな変更が必要なのかというと、MCPトリガーに必要な拡張機能 Microsoft.Azure.Functions.Extensions.Mcp がまだ正式にExtensionBundleに含まれていないから。
Experimentalだと入っているので変更している。
github.com

サンプルコード

今回のお題は、Copilotに現在時刻を返させる。

通常は、日時までは返せても、時刻は返してくれない。

この現在時刻をAzure Functions上のMCPサーバで取れるようにしてみる。

以下は、MCPサーバ、というか、MCPToolトリガーの関数のソース。

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

// mcpToolWhatTimeIsIt function - responds with current time
export async function mcpToolWhatTimeIsIt(_toolArguments: unknown, context: InvocationContext): Promise<string> {
  const mcptoolargs = context.triggerMetadata.mcptoolargs as {
    timezone?: string
  }
  const timeZone = mcptoolargs.timezone || 'UTC';
  const formatter = new Intl.DateTimeFormat('ja-JP', {
    timeZone,
    dateStyle: 'full',
    timeStyle: 'long',
  });
  return `It's ${formatter.format(new Date())} in ${timeZone}`;
}

// Register the what time is it tool
app.mcpTool('what-time-is-it', {
  toolName: 'what-time-is-it',
  description: 'MCP Tool that responds with the current time.',
  toolProperties:
  {
    timezone: z.string().describe('The timezone to get the current time for (default is UTC)')
  },
  handler: mcpToolWhatTimeIsIt
});

ついでに、引数でタイムゾーンを指定すると、指定のゾーンでの時刻を返すようにした。

これを src/functions/whatTimeIsItMcpTool.ts として保存してデバッグ実行、、、とはうまくいかない。

パラメータの型を宣言するために使っているzodというライブラリが入っていないので、ソースコードの最初の行でエラーが出ている。

なので zod を入れる。

$ npm install zod@3

気を付けないといけないところは @3を付けてメジャーバージョンを指定すること。
これがないと最新のv4が入ってしまい、どうもうまくJavaScriptへのトランスパイルができない。

このzodを入れればソースのエラーは消えるので、今度こそデバッグ実行、、、またうまくいかない。

Azure上のストレージアカウントか、ローカルのエミュレータを指定しろ、と言ってくる。

幸い、VS Codeに Azurite拡張を入れていたので、今回はローカルエミュレータを使うことにする。

local.settings.jsonを開いて、AzureWebJobsStorageUseDevelopmentStorage=trueという値を指定する。

昔はこの値が思い出せなくて、毎回Web検索してたけど、今はCopilotが入れてくれるので楽になった。

最後に、AzuriteのBlobサービスを起動して、やっとデバッグ実行開始!

無事起動したら、起動したローカルサーバをMCPサーバとして登録しないといけないので、ターミナルに MCP server endpointというURLが表示されるのでこれをコピーする。

再度VS Codeのコマンドパレットを開き、MCP: Add Server...を実行。

MCPサーバのタイプを聞かれるので、「HTTP」を選択。

さっきコピーしたURLを貼り付ける。

MCPサーバIDを指定できるけど、とりあえずこのままEnter。

MCPサーバのスコープは、今回はWorkspaceで。

すると、VS CodeMCP SERVERSに追加される。

これでさっきと同じように時刻を聞いてみると、

と、現在時刻が取れた。

ついでに都市を指定してみると、

何やらそれっぽい時差がついて時刻が取れた。

やったぜ。

👆で書いた通り、まだExperimentalなExtensionBundleなのでProductionには使うのはお勧めしないけど、早くGAしたらいいですな。

その `localhost`、本当に `127.0.0.1` ですか?

localhost の名前解決結果が 127.0.0.1 じゃなかったことで時間が溶けたので、その顛末を備忘録に。

事の起こり

VS Code Dev Days Tokyoのハンズオンの事前準備で、PCのツール類を整えていたところ、DevContainerが欲しくなった。

ハンズオンで使うリポジトリは👇 github.com

なので、これをフォークして、VS Codeの標準設定で DevContainer を追加してみる。

このリポジトリのアプリはPythonとNode.jsの両方いるので、どちらかをベースにしてもう一方をfeatureとして追加したら*1.devcontainerディレクトリに devcontainer.jsonができる*2

その後、改めてDevContainerで開きなおすと、DevContainerが問題なく起動する。

次にセットアップ手順

と書かれているので、./scripts/start-app.shを実行してみると、まずPython製のバックエンドサーバが5100ポートで起動し、続いてNode.js製のフロントエンドサーバが4321ポートで起動する。

DevContainer上でポートを開くと、VS Codelocalhostへの通信を自動でDevContainerへフォワードしてくれるので、試しに先に起動するバックエンドの http://localhost:5100 を開いてみる。

404が出るけど、逆にサーバまでつながっている証拠。
問題なさそう。

次にフロントエンドの http://localhost:4321 を開いてみる。

。。。

おや?開かない。
そのうちタイムアウトする。

と、DevContainerだとフロントエンドへ接続できなかった。

長かったけど、ここまでが前振り。

原因を調べる

バックエンド(Python)にはきちんとつながるので、VS CodeやDevContainerの拡張周りの可能性は低そう。

実行時のターミナルにも

と出てるので、localhost:4321 でリッスンしてそう。

ん?

バックエンドサーバの起動ログは

と、127.0.0.1を指定している。

localhost127.0.0.1、指定の仕方で動きが違う?

リッスンポートの状況を確認。

5100ポートは 127.0.0.1 でリッスンしているけど、4321ポートは ::1IPv6のアドレスでリッスンしている。

つまり、正しくつながるバックエンドサーバの方は、

  1. 127.0.0.1:5100を指定して起動 -> 127.0.0.1:5100でリッスン
  2. VS Codeはローカルの 127.0.0.1:5100 へのリクエストをDevContainerの 127.0.0.1:5100フォワード

なので、無事繋がる。

一方でフロントエンドサーバの方は、

  1. localhost:4321を指定して起動
    -> ::1:4321でリッスン
  2. VS Codeはローカルの 127.0.0.1:4321 へのリクエストをDevContainerの 127.0.0.1:4321フォワード

となり、127.0.0.1:4321でフロントエンドは動いていないので応答しない、という状況っぽい。

確認のため、DevContainer内でcurllocalhost にリクエストしてみると、
確かにlocalhost::1に解決されていて、::1に繋ごうとしているのがわかる。

念のため/etc/hostsを確認。

::1のエントリーが確かにある。
127.0.0.1のエントリーもあるけど、調べてみると、標準では、/etc/hosts 内で同じホスト名でIPv4IPv6の二つエントリーがある時は、IPv6を優先するらしい*3

対応を考える

結局のところ、DevContainerがリッスンしているアドレスとVS Codeフォワードするアドレスがズレているので、どちらかをもう一方に合わせればいい。

ただ、VS Code側でIPv6へのフォワードは、結構前からある問題で未解決だった。

github.com

なので、DevContainerのリッスンアドレスを変える方が手っ取り早い。

次に、OSレベル対応するかアプリレベルで対応するか。

DevContainerとして配布するなら、コンテナのOSレベルでIPv6を無効にしたらいいはず。
けど、他への影響がないことを確認するのも面倒なので、今回はアプリレベル、つまりフロントエンドサーバの起動に使うアドレスを変える方を選択。

幸い astro dev にはリッスンアドレスを指定するオプション --hostがあるので、package.jsondevスクリプトstart-app.shnpm run devの行のどちらかに、--host 127.0.0.1を追加するだけでいい。

実際に--hostオプションを付けて起動してみると、

と、127.0.0.1を使って起動するようになる。
オプション追加前の時のログを再掲すると、

と、localhostを使っていたので、オプションの効果が出ているのがわかる。

ブラウザで127.0.0.1:4321を指定すると

無事開くようになった。

以上、localhost127.0.0.1とは限らない話でした。
めでたしめでたし。


*1:どっちがどっちでもいい

*2:Dockerfileが一緒にできる場合もある

*3:/etc/gai.conf というので変更できるらしい

Azure Portal Plus を支える技術 ~ その1:Azure Resource Graph

弊ブラウザ拡張Azure Portal Plus作っているAzure / Azure Portalならでは技術を、備忘録を兼ねて投稿 。
# タイトルはアレですよ

「その1」*1は、Azure Resource Graphのご紹介。

Azure Resrouce Graphとは

learn.microsoft.com

公式ドキュメントにも

Azure Resource Graph は、効率的でパフォーマンスの高いリソース探索を提供することで、Azure リソース管理を拡張するように設計された Azure サービスです。

とある通り、リソース探索が簡単にできるサービス。

クエリー言語はKQL、Kusto Query Language。

learn.microsoft.com

「Kusto」は「くすと」と読み、フランスの海洋学者・地球科学者、ジャック=イヴ・クストーにちなむ*2
「データの海に潜る」的な意味だとかなんとか。

ja.wikipedia.org

そんなKQLでのリソース探索をAzure Portal Plusの中でもやっているので、それを紹介(再掲含む)。

各リソースグループに含まれるリソース数の探索

まずは直近実装したリソース数に応じてリソースグループの見栄えを変える機能の裏側。

クエリーはこれ。

resources
| summarize count() by subscriptionId, resourceGroup

割とシンプル。

厳密にはリソースグループを探しているんじゃなくて、各リソースが存在するリソースグループとサブスクリプション*3を出して、それを集計している。

でも、空っぽのリソースグループは集計結果に出てこないのが要改善点。

指定のAzure VMと接続可能なAzure Bastionの探索

次。

Advanced Copy機能でAzure VMにだけ追加した、Azure VMとそれにつなげられるAzure BastionのリソースIDをセットでコピーする機能。

ここでもResource Graphを使っている*4

以下の処理をKustoで一気に実行して、指定のVMにアクセスできるBastionのリソースをリソースIDを特定している。

クエリーはこれ。

resources
| where type =~ 'Microsoft.Network/virtualNetworks'
| where array_length(properties.virtualNetworkPeerings) == 0
| project vnetid=tolower(id)
| union (resources
  | where type =~ 'Microsoft.Network/virtualNetworks'
  | mv-expand peering=properties.virtualNetworkPeerings limit 400
  | project vnetid=tolower(id), remoteid=tolower(tostring(peering.properties.remoteVirtualNetwork.id))
)
| join kind= leftouter  (
  resources
  | where type =~ 'Microsoft.Network/networkInterfaces'
  | where properties.virtualMachine.id =~ tolower('${vmId}')
  | project vnetid=tolower(extract('(.*/virtualnetworks/[^/]+)/', 1, tolower(tostring(properties.ipConfigurations[0].properties.subnet.id)))), nicid1=id
  ) on $left.vnetid == $right.vnetid
| join kind= leftouter (
  resources
  | where type =~ 'Microsoft.Network/networkInterfaces'
  | where properties.virtualMachine.id =~ tolower('${vmId}')
  | project vnetid=tolower(extract('(.*/virtualnetworks/[^/]+)/', 1, tolower(tostring(properties.ipConfigurations[0].properties.subnet.id)))), nicid2=id
  ) on $left.remoteid == $right.vnetid
| where not(isempty(nicid1)) or not(isempty(nicid2))
| project vnetid
| join kind=inner (
  resources
  | where type =~ 'microsoft.network/bastionHosts'
  | where sku.name in~ ('standard', 'premium')
  | where properties.enableTunneling == true
  | extend vnetid=tolower(extract('(.*/virtualnetworks/[^/]+)/', 1, tolower(tostring(properties.ipConfigurations[0].properties.subnet.id))))
) on vnetid
| project id

いろいろ書いてあるけど、以下のロジックでBastionのリソースIDを探索している。

  1. 対象のVMからNICを取得
  2. 取得したNICから接続しているVNETを取得
  3. 取得したVNETにピアリングしているVNETも取得
  4. ピアリング先も含んだ全VNETにぶら下がっているBastionを取得

長いので何とかしたい。

REST API

最後に、👆のクエリーを実行するには👇のREST APIでKustoクエリーをPOSTする。

learn.microsoft.com

アクセストークンが必要だけど、それはまた別の話。

まとめ

誰かもっといいクエリーください。

*1:続きがあるかわからんけど

*2:https://en.wikipedia.org/wiki/Azure_Data_Explorer

*3:リソースグループ名がサブスクリプション間で重複するため

*4:これは以前のエントリーでも書いてる。 https://uncaughtexception.hatenablog.com/entry/2025/05/26/205134