以前のエントリーで、Playground (Web UI) で OAuth Identity Passthrough の MCP ツールが動くことを確認した。
uncaughtexception.hatenablog.com
次に SDK(Node.js)から同じエージェントに話しかけたときに、MCP ツール呼び出しがどのように見えるかを検証した。
今回やったこと、観察した挙動、実務で使うときの注意点をまとめる。
なお OAuth Identity Passthrough については、以下の公式ドキュメントにも記載はあるものの、具体的な実装例や細かい運用上の注意までは示されていないため、今回サンプルを作成して挙動を検証してみた。
先に結論
- SDK から会話を作って
responses.createを呼ぶと、Playground と同様にresponse.outputにoauth_consent_requestやmcp_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
- スクリプトは
openAIClient.conversations.createで会話を作り、openAIClient.responses.createでエージェントにメッセージを送信する - エージェントからの応答
responseに応じた処理をするresponse.outputに OAuth の同意リクエスト(oauth_consent_request) を含まれていた場合はconsent_linkをコンソールに表示し、以降30秒間隔で直前のメッセージを再送するresponse.outputに MCP ツールの実行承認リクエスト (mcp_approval_request) が含まれていた場合は承認応答 (mcp_approval_response) を送る- どちらでもなく
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 の例は次のとおり。
oauth_consent_request: OAuth での同意リンクを含む出力
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 ツール利用が必要なメッセージ送信を繰り返すと、エージェントがツールを使わない判断をする場合があるため、同意後にユーザー操作をトリガーに再試行する設計を推奨。

















