以前のエントリーで、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.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})`);
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})`);
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)});
}
}
})(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 ツール利用が必要なメッセージ送信を繰り返すと、エージェントがツールを使わない判断をする場合があるため、同意後にユーザー操作をトリガーに再試行する設計を推奨。