From de7fa0dfe30b20123f6f889a4fce39ecc1f436b3 Mon Sep 17 00:00:00 2001 From: adilei Date: Mon, 20 Jul 2026 20:22:03 +0300 Subject: [PATCH 1/2] Add m365-langgraph-mcs-tool sample (Agents SDK + LangGraph + Copilot Studio) A pro-code custom engine agent for Teams / Microsoft 365 Copilot, built with the M365 Agents SDK. It runs a LangGraph ReAct orchestrator on Azure OpenAI and exposes a published Copilot Studio agent as a tool, called with the signed-in user's identity (delegated SSO) with streamed responses. - One-command deploy: scripts/deploy.sh / deploy.ps1 (provision + deploy via atk). - Post-deploy smoke test: scripts/smoke-test.mjs (npm run smoke) over Direct Line. - Bicep infra: App Service, Azure Bot, user-assigned managed identity, SsoConnection + mcs OAuth connections, Entra app registration granting Power Platform (CopilotStudio.Copilots.Invoke). - Jekyll page + Azure/local deployment guides; registered in the Agents SDK category README. Cleaned Foundry-era leftovers carried from the source: removed unused deps (@azure/ai-agents, @azure/identity, @azure/core-auth, jsonwebtoken), the dead app-update-sso.bicep module, and stale Foundry references in docs/config. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c64fb4df-84d5-4146-84c2-65ade1d34a7c --- extensibility/agents-sdk/README.md | 1 + .../m365-langgraph-mcs-tool/.gitignore | 23 ++ .../.vscode/extensions.json | 5 + .../.vscode/launch.json | 210 ++++++++++++ .../.vscode/settings.json | 11 + .../.vscode/tasks.json | 261 +++++++++++++++ .../m365-langgraph-mcs-tool/.webappignore | 25 ++ .../m365-langgraph-mcs-tool/LICENSE | 21 ++ .../m365-langgraph-mcs-tool/README.md | 213 ++++++++++++ .../appPackage/color.png | Bin 0 -> 5117 bytes .../appPackage/manifest.json | 78 +++++ .../appPackage/outline.png | Bin 0 -> 492 bytes .../docs/AZURE_DEPLOYMENT.md | 149 +++++++++ .../docs/LOCAL_DEPLOYMENT.md | 92 +++++ .../m365-langgraph-mcs-tool/env/.env.dev | 34 ++ .../m365-langgraph-mcs-tool/env/env.TEMPLATE | 28 ++ .../infra/azure-local.bicep | 195 +++++++++++ .../infra/azure-local.parameters.json | 21 ++ .../m365-langgraph-mcs-tool/infra/azure.bicep | 243 ++++++++++++++ .../infra/azure.parameters.json | 36 ++ .../infra/bicepconfig.json | 5 + .../infra/modules/BOT_OAUTH_CONNECTION.md | 248 ++++++++++++++ .../infra/modules/GUID_ENCODER_GUIDE.md | 175 ++++++++++ .../infra/modules/app-registration.bicep | 189 +++++++++++ .../infra/modules/appinsights.bicep | 73 ++++ .../infra/modules/appservice.bicep | 201 +++++++++++ .../infra/modules/azurebot-local.bicep | 55 +++ .../infra/modules/azurebot.bicep | 42 +++ .../infra/modules/bot-app-registration.bicep | 51 +++ .../infra/modules/bot-managedidentity.bicep | 16 + .../infra/modules/bot-oauth-connection.bicep | 65 ++++ .../infra/modules/guid-encoder.bicep | 66 ++++ .../infra/modules/service-principal.bicep | 24 ++ .../infra/modules/update-bot-endpoint.bicep | 42 +++ .../m365agents.local.yml | 116 +++++++ .../m365-langgraph-mcs-tool/m365agents.yml | 131 ++++++++ .../m365-langgraph-mcs-tool/package.json | 48 +++ .../scripts/deploy.ps1 | 142 ++++++++ .../m365-langgraph-mcs-tool/scripts/deploy.sh | 143 ++++++++ .../scripts/devtunnel.ps1 | 102 ++++++ .../scripts/devtunnel.sh | 115 +++++++ .../m365-langgraph-mcs-tool/scripts/env.js | 52 +++ .../scripts/guid-encoder.js | 132 ++++++++ .../scripts/smoke-test.mjs | 313 ++++++++++++++++++ .../m365-langgraph-mcs-tool/src/agent.ts | 184 ++++++++++ .../m365-langgraph-mcs-tool/src/config.ts | 33 ++ .../m365-langgraph-mcs-tool/src/index.ts | 11 + .../m365-langgraph-mcs-tool/src/logger.ts | 89 +++++ .../m365-langgraph-mcs-tool/src/mcs/index.ts | 3 + .../src/mcs/mcsActivityProcessor.ts | 116 +++++++ .../src/mcs/mcsClientFactory.ts | 29 ++ .../src/mcs/mcsTokenProvider.ts | 47 +++ .../src/mcs/mcsTool.ts | 158 +++++++++ .../src/mcs/orchestrator.ts | 71 ++++ .../m365-langgraph-mcs-tool/tsconfig.json | 27 ++ .../m365-langgraph-mcs-tool/web.config | 60 ++++ 56 files changed, 5020 insertions(+) create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/.gitignore create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/extensions.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/launch.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/settings.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/tasks.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/.webappignore create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/LICENSE create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/color.png create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/manifest.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/outline.png create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/AZURE_DEPLOYMENT.md create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/LOCAL_DEPLOYMENT.md create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/env/.env.dev create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/env/env.TEMPLATE create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.parameters.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.parameters.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/bicepconfig.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/BOT_OAUTH_CONNECTION.md create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/GUID_ENCODER_GUIDE.md create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/app-registration.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appinsights.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appservice.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot-local.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-app-registration.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-managedidentity.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-oauth-connection.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/guid-encoder.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/service-principal.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/update-bot-endpoint.bicep create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.local.yml create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.yml create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/package.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 create mode 100755 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/devtunnel.ps1 create mode 100755 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/devtunnel.sh create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/env.js create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/guid-encoder.js create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/smoke-test.mjs create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/agent.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/config.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/index.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/logger.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/index.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsActivityProcessor.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsClientFactory.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTokenProvider.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTool.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/orchestrator.ts create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/tsconfig.json create mode 100644 extensibility/agents-sdk/m365-langgraph-mcs-tool/web.config diff --git a/extensibility/agents-sdk/README.md b/extensibility/agents-sdk/README.md index dbf59370..e0c21a8c 100644 --- a/extensibility/agents-sdk/README.md +++ b/extensibility/agents-sdk/README.md @@ -14,6 +14,7 @@ Server-side implementations using the M365 Agents SDK to extend Copilot Studio a | Sample | Description | |--------|-------------| | [call-agent-connector/](./call-agent-connector/) | Azure Function connector for calling agents | +| [m365-langgraph-mcs-tool/](./m365-langgraph-mcs-tool/) | LangGraph + Azure OpenAI agent (Teams / M365 Copilot) that calls a Copilot Studio agent as a tool with delegated SSO | | [multilingual-bot/](./multilingual-bot/) | Multilingual bot with automatic translation | | [relay-bot/](./relay-bot/) | Relay bot pattern implementation | | [Copilot Studio Client](./copilotstudio-client/) | Console app to consume an agent (.NET, Node, Python) — *M365 Agents SDK repo* | diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/.gitignore b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.gitignore new file mode 100644 index 00000000..4ef80ac7 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.gitignore @@ -0,0 +1,23 @@ +# TeamsFx files +env/.env.*.user +env/.env.local +env/.env.sandbox +.localConfigs +.notification.localstore.json +appPackage/build + +# dependencies +node_modules/ + + +# misc +.env +.deployment +.DS_Store + +# build +lib/ +dist/ + +# Dev tool directories +/devTools/ \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/extensions.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/extensions.json new file mode 100644 index 00000000..1b70a393 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "TeamsDevApp.ms-teams-vscode-extension" + ] +} \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/launch.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/launch.json new file mode 100644 index 00000000..ccc84fd8 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/launch.json @@ -0,0 +1,210 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Remote in Teams (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}", + "presentation": { + "group": "2-Teams", + "order": 4 + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch Remote in Teams (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}", + "presentation": { + "group": "2-Teams", + "order": 5 + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch App (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{local:TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}", + "cascadeTerminateToConfigurations": [ + "Attach to Local Service" + ], + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen", + "perScriptSourcemaps": "yes" + }, + { + "name": "Launch App (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{local:TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}", + "cascadeTerminateToConfigurations": [ + "Attach to Local Service" + ], + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen", + "perScriptSourcemaps": "yes" + }, + { + "name": "Attach to Local Service", + "type": "node", + "request": "attach", + "port": 9239, + "restart": true, + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch Remote in Teams (Desktop)", + "type": "node", + "request": "launch", + "preLaunchTask": "Start App in Desktop Client (Remote)", + "presentation": { + "group": "2-Teams", + "order": 6 + }, + "internalConsoleOptions": "neverOpen", + }, + { + "name": "(Preview) Launch Remote in Copilot (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${agent-hint}?auth=2&${account-hint}&developerMode=Basic", + "cascadeTerminateToConfigurations": ["Attach to Local Service"], + "presentation": { + "group": "3-M365", + "order": 3 + }, + "internalConsoleOptions": "neverOpen", + "runtimeArgs": [ + "--remote-debugging-port=9222", + "--no-first-run" + ] + }, + { + "name": "(Preview) Launch Remote in Copilot (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${agent-hint}?auth=2&${account-hint}&developerMode=Basic", + "cascadeTerminateToConfigurations": ["Attach to Local Service"], + "presentation": { + "group": "3-M365", + "order": 4 + }, + "internalConsoleOptions": "neverOpen", + "runtimeArgs": [ + "--remote-debugging-port=9223", + "--no-first-run" + ] + }, + { + "name": "Launch in Copilot (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${local:agent-hint}?auth=2&${account-hint}&developerMode=Basic", + "cascadeTerminateToConfigurations": ["Attach to Local Service"], + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen", + "runtimeArgs": [ + "--remote-debugging-port=9222", + "--no-first-run" + ] + }, + { + "name": "Launch in Copilot (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${local:agent-hint}?auth=2&${account-hint}&developerMode=Basic", + "cascadeTerminateToConfigurations": ["Attach to Local Service"], + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen", + "runtimeArgs": [ + "--remote-debugging-port=9223", + "--no-first-run" + ] + } + ], + "compounds": [ + { + "name": "Debug in Teams (Edge)", + "configurations": [ + "Launch App (Edge)", + "Attach to Local Service" + ], + "preLaunchTask": "Start App Locally", + "presentation": { + "group": "2-Teams", + "order": 1 + }, + "stopAll": true + }, + { + "name": "Debug in Teams (Chrome)", + "configurations": [ + "Launch App (Chrome)", + "Attach to Local Service" + ], + "preLaunchTask": "Start App Locally", + "presentation": { + "group": "2-Teams", + "order": 2 + }, + "stopAll": true + }, + { + "name": "Debug in Teams (Desktop)", + "configurations": [ + "Attach to Local Service" + ], + "preLaunchTask": "Start App in Desktop Client", + "presentation": { + "group": "2-Teams", + "order": 3 + }, + "stopAll": true + }, + { + "name": "(Preview) Debug in Copilot (Edge)", + "configurations": [ + "Launch in Copilot (Edge)", + "Attach to Local Service" + ], + "preLaunchTask": "Start App Locally", + "presentation": { + "group": "3-M365", + "order": 1 + }, + "stopAll": true + }, + { + "name": "(Preview) Debug in Copilot (Chrome)", + "configurations": [ + "Launch in Copilot (Chrome)", + "Attach to Local Service" + ], + "preLaunchTask": "Start App Locally", + "presentation": { + "group": "3-M365", + "order": 2 + }, + "stopAll": true + } + ] +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/settings.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/settings.json new file mode 100644 index 00000000..0d3ba10b --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "debug.onTaskErrors": "abort", + "json.schemas": [ + { + "fileMatch": [ + "/aad.*.json" + ], + "schema": {} + } + ] +} \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/tasks.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/tasks.json new file mode 100644 index 00000000..6628eac4 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.vscode/tasks.json @@ -0,0 +1,261 @@ +// This file is automatically generated by Microsoft 365 Agents Toolkit. +// The teamsfx tasks defined in this file require Microsoft 365 Agents Toolkit version >= 5.0.0. +// See https://aka.ms/teamsfx-tasks for details on how to customize each task. +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start App (Sandbox)", + "dependsOn": [ + "Validate prerequisites (Sandbox)", + "Start local tunnel (Sandbox)", + "Provision (Sandbox)", + "Deploy (Sandbox)", + "Start application", + ], + "dependsOrder": "sequence" + }, + { + "label": "Ensure env files", + "type": "shell", + "command": "node ./scripts/env.js", + "isBackground": true, + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "Ensuring env files exist...", + "endsPattern": "Done!" + } + }, + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "reveal": "silent", + "panel": "shared" + } + }, + { + "label": "Start App Locally", + "dependsOn": [ + "Validate prerequisites", + "Ensure env files", + "Ensure DevTunnel", + "Provision", + "Deploy", + "Start application" + ], + "dependsOrder": "sequence" + }, + { + "label": "Validate prerequisites", + "type": "teamsfx", + "command": "debug-check-prerequisites", + "args": { + "prerequisites": [ + "nodejs", + "m365Account", + "portOccupancy" + ], + "portOccupancy": [ + 3978, + 9239 + ] + } + }, + { + "label": "Validate prerequisites (Sandbox)", + "type": "teamsfx", + "command": "debug-check-prerequisites", + "args": { + "prerequisites": [ + "portOccupancy", + "sandbox", + "nodejs" + ], + "portOccupancy": [ + 3978, + 9239 + ] + } + }, + { + "label": "Start local tunnel", + "type": "teamsfx", + "command": "debug-start-local-tunnel", + "args": { + "type": "dev-tunnel", + "ports": [ + { + "portNumber": 3978, + "protocol": "http", + "access": "public", + "writeToEnvironmentFile": { + "endpoint": "BOT_ENDPOINT", + "domain": "BOT_DOMAIN" + } + } + ], + "env": "local" + }, + "isBackground": true, + "problemMatcher": "$teamsfx-local-tunnel-watch" + }, + { + "label": "Start local tunnel (Sandbox)", + "type": "teamsfx", + "command": "debug-start-local-tunnel", + "args": { + "type": "dev-tunnel", + "ports": [ + { + "portNumber": 3978, + "protocol": "http", + "access": "public", + "writeToEnvironmentFile": { + "endpoint": "BOT_ENDPOINT", + "domain": "BOT_DOMAIN" + } + } + ], + "env": "sandbox" + }, + "isBackground": true, + "problemMatcher": "$teamsfx-local-tunnel-watch" + }, + { + "label": "Provision", + "type": "teamsfx", + "command": "provision", + "args": { + "env": "local" + } + }, + { + "label": "Provision (Sandbox)", + "type": "teamsfx", + "command": "provision", + "args": { + "env": "sandbox", + } + }, + { + "label": "Deploy", + "type": "teamsfx", + "command": "deploy", + "args": { + "env": "local" + } + }, + { + "label": "Deploy (Sandbox)", + "type": "teamsfx", + "command": "deploy", + "args": { + "env": "sandbox" + } + }, + { + "label": "Start application", + "type": "shell", + "command": "npm run dev:teamsfx", + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "[nodemon] starting", + "endsPattern": "Server listening to port|[nodemon] app crashed" + } + } + }, + { + "label": "Start App in Desktop Client", + "dependsOn": [ + "Validate prerequisites", + "Ensure env files", + "Ensure DevTunnel", + "Provision", + "Deploy", + "Start application", + "Start desktop client" + ], + "dependsOrder": "sequence" + }, + { + "label": "Start desktop client", + "type": "teamsfx", + "command": "launch-desktop-client", + "args": { + "url": "teams.microsoft.com/l/app/${{local:TEAMS_APP_ID}}?installAppPackage=true" + } + }, + { + "label": "Start App in Desktop Client (Remote)", + "type": "teamsfx", + "command": "launch-desktop-client", + "args": { + "url": "teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true" + } + }, + { + "label": "Ensure DevTunnel", + "type": "shell", + "isBackground": true, + "windows": { + "command": ".\\scripts\\devtunnel.ps1" + }, + "osx": { + "command": "./scripts/devtunnel.sh" + }, + "linux": { + "command": "./scripts/devtunnel.sh" + }, + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "Checking Dev Tunnels login status...|No TUNNEL_ID found. Creating tunnel...|Found existing TUNNEL_ID", + "endsPattern": "Ready to accept connections for tunnel|Failed to (login to Dev Tunnels|create tunnel|create port|create access|host tunnel)|Dev Tunnels CLI not found" + } + }, + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": false, + "clear": true + }, + "dependsOn": [ + "Ensure env files" + ] + } + ] +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/.webappignore b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.webappignore new file mode 100644 index 00000000..1d17d92b --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/.webappignore @@ -0,0 +1,25 @@ +.webappignore +.fx +.deployment +.localConfigs +.notification.localstore.json +.vscode +*.js.map +*.ts.map +.git* +.tsbuildinfo +CHANGELOG.md +readme.md +local.settings.json +test +.DS_Store +m365agents.yml +m365agents.*.yml +/env/ +/appPackage/ +/infra/ +/devTools/ + +# Exclude build artifacts - Azure will build from source with Oryx +/dist/ +/node_modules/ \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/LICENSE b/extensibility/agents-sdk/m365-langgraph-mcs-tool/LICENSE new file mode 100644 index 00000000..2ed5690f --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md b/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md new file mode 100644 index 00000000..54068ce7 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md @@ -0,0 +1,213 @@ +--- +title: M365 LangGraph MCS Tool +parent: Agents SDK +grand_parent: Extensibility +nav_order: 4 +--- + +# LangGraph proxy agent with a Copilot Studio tool + +New +{: .label .label-green } + +A pro-code **custom engine agent** for Microsoft Teams and Microsoft 365 Copilot, +built with the **Microsoft 365 Agents SDK** (TypeScript). It runs a **LangGraph** +orchestrator on **Azure OpenAI** and exposes a published **Copilot Studio** agent as +a tool, calling it with the signed-in user's identity (delegated SSO) and streaming +the answer back into the chat. + +Use this sample when you want an existing AI solution (here, a LangGraph app) to act +as the front door in Microsoft 365 while delegating a specialized skill — in the demo, +finding hotels — to a Copilot Studio agent. + +{: .note } +> The orchestrator is a plain LangGraph ReAct agent. Swap in your own graph, tools, or +> model and keep the Agents SDK hosting, SSO, and Copilot Studio plumbing unchanged. + +## Architecture + +```mermaid +flowchart LR + User([User in Teams / M365 Copilot]) -->|message| Bot + subgraph Azure["Azure App Service"] + Bot[M365 Agents SDK host
ProxyAgent] --> Graph[LangGraph ReAct
orchestrator] + Graph -->|Azure OpenAI| AOAI[(Azure OpenAI
chat model)] + Graph -->|ask_copilot_studio_agent| Tool[Copilot Studio tool] + end + Bot -->|OAuth: delegated token| Entra[Microsoft Entra ID] + Tool -->|CopilotStudioClient
on-behalf-of user| MCS[(Published Copilot
Studio agent)] + Tool -. streamed reply .-> Bot + Bot -. streamed reply .-> User +``` + +1. A user messages the agent in Teams or Microsoft 365 Copilot. +2. The **ProxyAgent** (`src/agent.ts`) handles the turn. Its `MCS` authorization handler + ensures a delegated token for the Power Platform API (a one-time sign-in per user). +3. The **LangGraph orchestrator** (`src/mcs/orchestrator.ts`) decides which tool to call. +4. For hotel/travel questions it calls **`ask_copilot_studio_agent`** (`src/mcs/mcsTool.ts`), + which uses `@microsoft/agents-copilotstudio-client` to talk to the published agent + **as the user** (on-behalf-of token exchange). +5. The Copilot Studio agent's response is **streamed** back to the chat in real time. + +## What you get + +- **Custom engine agent** surfaced in Teams and Microsoft 365 Copilot. +- **LangGraph + Azure OpenAI** orchestration you fully control. +- **Delegated (SSO) access** to Copilot Studio — the agent acts as the signed-in user, + not a shared service account. +- **Streaming** responses. +- **Infrastructure as code** (Bicep) and **Microsoft 365 Agents Toolkit** provisioning. +- A **one-command deploy** script and a **smoke test** you can run after deploying. + +## Prerequisites + +- **Node.js 22 or 24** and npm. +- **Azure subscription** with permission to create resources and assign roles. +- **Microsoft 365 Agents Toolkit CLI** (`atk`): + `npm install -g @microsoft/m365agentstoolkit-cli` +- A **published Copilot Studio agent** — you need its **environment ID** and **schema name** + (Copilot Studio → your agent → *Settings → Advanced → Metadata*). +- An **Azure OpenAI** resource with a chat **deployment** (default `gpt-4o`) and its **API key**. +- A **Microsoft 365 tenant** where you can upload/sideload a custom app. + +## Quick start (one command) + +From this folder: + +```bash +# macOS / Linux +./scripts/deploy.sh +``` + +```powershell +# Windows +./scripts/deploy.ps1 +``` + +The script checks prerequisites, prompts for the handful of values above, writes them to +`env/.env.dev` (secrets to the git-ignored `env/.env.dev.user`), builds the project, then +runs `atk provision` and `atk deploy`. When it finishes it prints how to install the app +package it built at `appPackage/build/appPackage.dev.zip`. + +{: .tip } +> Already know your values? Export them first (for example `MCS_ENVIRONMENT_ID`, +> `MCS_SCHEMA_NAME`, `AZURE_OPENAI_ENDPOINT`, `SECRET_AZURE_OPENAI_API_KEY`) and the +> script runs unattended — handy for CI. + +Then install the app and try it: + +1. In Teams: **Apps → Manage your apps → Upload an app → Upload a custom app** and pick + `appPackage/build/appPackage.dev.zip` (or run + `atk install --file-path appPackage/build/appPackage.dev.zip --env dev`). +2. Open the agent and say hello. The **first message** triggers a one-time sign-in that + grants the agent delegated access to Copilot Studio. +3. Ask a hotel question, e.g. *"What are the available hotels?"* — the LangGraph + orchestrator routes it to your Copilot Studio agent and streams the reply. + +Prefer to run the two toolkit steps yourself, or need the full details? See the +[Azure deployment guide](docs/AZURE_DEPLOYMENT). + +## Configuration + +The deploy script and toolkit read these from `env/.env.dev` +(secrets from `env/.env.dev.user`). The same values are injected as App Service settings +by the Bicep templates, so the running bot needs no separate `.env`. + +| Variable | Required | Description | +|----------|----------|-------------| +| `MCS_ENVIRONMENT_ID` | Yes | Power Platform environment ID of the published Copilot Studio agent. | +| `MCS_SCHEMA_NAME` | Yes | Schema name of the Copilot Studio agent (e.g. `cr123_myAgent`). | +| `MCS_CONNECTION_NAME` | No | Bot Service OAuth connection for Copilot Studio. Defaults to `mcs`. | +| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint, e.g. `https://.openai.azure.com/`. | +| `AZURE_OPENAI_DEPLOYMENT` | No | Chat deployment name. Defaults to `gpt-4o`. | +| `AZURE_OPENAI_API_VERSION` | No | API version. Defaults to `2024-12-01-preview`. | +| `SECRET_AZURE_OPENAI_API_KEY` | Yes | Azure OpenAI API key. Kept in `env/.env.dev.user` (git-ignored). | +| `AZURE_SUBSCRIPTION_ID` | No | Target subscription. Blank ⇒ you're prompted during provision. | +| `AZURE_RESOURCE_GROUP_NAME` | No | Target resource group. Blank ⇒ prompted/created during provision. | +| `RESOURCE_SUFFIX` | No | Suffix that makes Azure resource names globally unique. Auto-generated if blank. | + +## How authentication works + +Provisioning creates **two** Bot Service OAuth connections and an Entra app registration: + +- **`SsoConnection`** — Teams/Microsoft 365 single sign-on into the bot. +- **`mcs`** — the connection the `MCS` authorization handler uses to obtain a **delegated** + Power Platform token (scope `https://api.powerplatform.com/.default`, permission + `CopilotStudio.Copilots.Invoke`) via on-behalf-of exchange. This is what lets the agent + call Copilot Studio *as the signed-in user*. + +The app registration (`infra/modules/app-registration.bicep`) grants the Power Platform API +permissions; the connections are wired up in `infra/azure.bicep`. In production the bot +authenticates with a **user-assigned managed identity** (no client secret). + +## Local development (F5) + +You can run and debug the agent locally with a dev tunnel and the Agents Toolkit VS Code +extension (press **F5**), or from the CLI. Local run uses `env/.env.local` plus +`env/.env.local.user` for secrets. See the [local development guide](docs/LOCAL_DEPLOYMENT) +for the full walkthrough. + +## Testing + +After a deploy you can exercise the live agent from the command line: + +```bash +npm run smoke # drives the deployed bot over Direct Line +``` + +The [smoke test](scripts/smoke-test.mjs) starts a conversation, sends a hotel question, and +prints the streamed reply. Because every turn requires the `MCS` sign-in, the script prints +the one-time sign-in link when the bot asks for it; complete it once and re-run. See the +[testing section](docs/AZURE_DEPLOYMENT#testing-the-deployed-agent) of the deployment guide +for details and the Teams-based alternative. + +## Project structure + +``` +m365-langgraph-mcs-tool/ +├── src/ +│ ├── agent.ts # ProxyAgent: turn handling, MCS auth handler, streaming +│ ├── config.ts # Environment-variable configuration +│ ├── index.ts # Express host entry point +│ └── mcs/ +│ ├── orchestrator.ts # LangGraph ReAct agent (Azure OpenAI + tools) +│ ├── mcsTool.ts # ask_copilot_studio_agent tool +│ ├── mcsClientFactory.ts # Builds CopilotStudioClient +│ ├── mcsTokenProvider.ts # On-behalf-of token for Copilot Studio +│ └── mcsActivityProcessor.ts +├── infra/ # Bicep templates (App Service, Bot, OAuth connections, RBAC) +├── appPackage/ # Teams app manifest and icons +├── env/ # Agents Toolkit environment files +├── scripts/ +│ ├── deploy.sh / deploy.ps1 # One-command provision + deploy +│ ├── smoke-test.mjs # Post-deploy Direct Line test +│ └── devtunnel.sh / .ps1 # Local dev tunnel helpers +├── m365agents.yml # Provision/deploy orchestration (cloud) +├── m365agents.local.yml # Provision/deploy orchestration (local) +└── docs/ # Azure and local deployment guides +``` + +## Troubleshooting + +{: .warning } +> **`MCS_ENVIRONMENT_ID is not configured` on startup.** The environment ID and schema name +> weren't provisioned into the app settings. Re-run the deploy script (or `atk provision`) +> with those values set. + +- **The agent replies but never calls Copilot Studio.** Confirm `MCS_ENVIRONMENT_ID` and + `MCS_SCHEMA_NAME` point at a *published* agent, and that the sign-in completed. +- **Sign-in loops or fails.** Check the `mcs` OAuth connection exists on the Azure Bot and + that the app registration has admin consent for the Power Platform API permissions. +- **`atk provision` fails on name conflicts.** Set a unique `RESOURCE_SUFFIX` (the deploy + script generates one automatically). + +## Additional resources + +- [Microsoft 365 Agents SDK](https://learn.microsoft.com/microsoft-365/agents-sdk/) +- [Microsoft 365 Agents Toolkit](https://learn.microsoft.com/microsoft-365/developer/overview-m365-agents-toolkit) +- [Copilot Studio Client (`@microsoft/agents-copilotstudio-client`)](https://www.npmjs.com/package/@microsoft/agents-copilotstudio-client) +- [LangGraph](https://langchain-ai.github.io/langgraphjs/) + +## License + +Licensed under the MIT License. See [LICENSE](LICENSE). diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/color.png b/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/color.png new file mode 100644 index 0000000000000000000000000000000000000000..01aa37e347d0841d18728d51ee7519106f0ed81e GIT binary patch literal 5117 zcmdT|`#;l<|9y>Z&8;RvbJkV`JZ47uM)M6PqELPD;&L{sk9 z+(Q(S&D_QepWgq)_xrwkbj|4pN5 z=VSkf%}v|F0{}R9{sRa|&lLD4f;^10G=TCxp_P9N*g;)a9RMm5IGA=20N_cwbwl06 z2eg(ol`u1Qw{r|*Pavm8@vy0IeTJUrio9YdcrNJVF>ba}?2AO~S6CFrP5OkYiS|06 zx{fzU?6R7Fo(eA2%!^k4qFLf?HR19`sdTa~&baugKe=zZFSCjbU{I1{cMET*n)L#%LrE`i2_>yDQEDf1?RT znZ&`cB?#^y1N8spgI*BauT4c!%WZ*ig*o^8__URv;@MQk!-OiSLaXA{^yJ3q zxpL@0j<`;1lK^}Wmr+OXI~tEV>+^T$BkMJTouA)B^(qFTz_A#DUtX8adQ7K zOEz?@!dYXM8zdtYH$TJpA-S_Uaivvh_w2&h{Xu9mSe^|L5S zy~F9d8#Ygb$sQx;0{0qeLaq_KOMQu_K z(AbA>Gd18K8TnH~JTwU55 z74bMm{C48jl6yRHvVNkmSz*P?EyruCF8HOI2RvYBA!4qh^aTAaIzUn7xB7CEbwcG- z9nIK(2p`ScIx21Dw)eB)0Q>yKLPMvaf<-Oq4*$IhuIkTww;CcU zKvB6_!`j4fb$T?Q?b!42#5JmN>CXW4H?obQ8?}ZSMR<@NaOus$w3n`ctGNGm%89v0 zn>tl_jbblXxj&NOcU7+VjHe+;-18+9-ieOjOoHx~ykrry&eKlVh3Hy5ylXWE$IBj+ z#v<4E1>$?}okfTJdBgV3b&Ckl9 z1cmPLv57nQ{N9Siva&bnh}V!6=lAs5c^bD*xYp(i32A%shd)EJ^;l2mds?04_`<*o zDNH7!qqD)4IYTGES1uSdt4zr2SMzaYp(>OQ=qt9-ng=LQb5PiK+kK183eY>a?>Bw4 z`s~UlV9S<9c(?jKSZT9r@_}97A=%J}InsV)INMOo=6Wz|+HEc7VvSt00vO`n1HTV@ zVX`o_*(Rc^)EdzS6{xyoyC^z90Qu8<4c{&*F7*a>ikxmO?kh__Q1$t6i|_|pDaij< zyL3b~TsQW^M5Ncloc_z+ak~ENF-DuNY(JtLfgjgvj=Zo``yk|uguX)G;Oek`vzw0# zSw9m~#hHMviTjD+G5)--NT(`KCGjuFn!$B4y1}oV4L}$JDr9{DIfUi<@H7$-p#|SWK52*!dj_$r9bo!hh?Z z=>0M=y(F)3NmUmXw04Dxz;d`P7DcAjeP0n1vz06oMtNo^SRX@OIQB}-->oDto||L& z*t=`?s!O2r&C+1+IK5THFj!D}G_OimWcstGnlTgZ=Pj&Q!DB8CeQHAWc8F{?spl+U zTiH7`AE+GUSU&q95)km`WEb$O1f(<99ow92YO4!kA=&+0BUd;VeCJL%+$UU>4k}QT zmf~map`VML1nF$Qi9XGbGjTPL3l0<8`1Yuqg(f4Vi&vuljfn?oevL*fUQ1@^QXz?c zha9wXD?@X{I;{9GM9i}%pE=lMP2wgYPr!@xFXRf>B_aS~(ANY;!Wsu}uuZhbGlkH& z5@xYQVJ;_oDG2z=Jas4Hk^R_(98o9<7*DWyk5r{TmmGmdlv$eMNMXRs%PEaeRHyJn zz1bg`ivXk60Pjp>lGnJIYy5$K3zI1e3+t$nsnLR0@;mbf`5VAk9HDL#{qbZXfX^PoV&{*B}9p^muB^0Y>7TvcE7D~wK&Bl=v;=0$$YgG za?>g1ZgiA(4|Q-9aj4ki7@3fjPJFkSH%I`bffj^ayiD0hTtf9Rq`VHt;3$hr>O~ux4XhPWgk$X#@8$h^+<08SR^7gR*UitH8`HjQMV!}hd!IGF9O zYV7@2XsvI}6cMS9rOVmOIXtS*ym60NzWX#V0vufS*92hEztF`g>udch->ZG|-H~HOGj~K@r7+S*e}UeWC)Z}) zII;&EcF%xqGOlB`@Gm*4Gx~{YkHuvM;U0!J_#*dfCtIO)L2`*I7woRKB}tZu#`Y!W z^kevopxW6z5!v-A=WlGaK!Hd^q>gaV-u_$tqI>)hnUgn10p5?VdA-RgoVxIyzPr!# z&4r@hf=WsQk}9F^S(|| zsSRPuj%Z|vIRZ9}kkwEqM0#8C{^r<_0QBOa ztxiQFp-A(_ch}jq8hG|K4*|@fr}BZ12p9rGW%F4tOtE6u&I18L&KD`hu9V7o!+?5| z(VY!r%Q2&nB|<iX<0kWA@XE84qe1vfyS605xBrh^8J^%Lg`X93AQS+S!EgQe`XB;1E$J_3@U~Bb) zW|(=SQhUlN1isM&kAeLk$oP5W(aLe$XicJlDZ&%*zn?tUXI?8=&JFC8pF&-YkC-%0 zU3gOAH5y)ew!tW;tL(r@`eliBgm>!V;z#M<3zndR>>pXC^8QCin}%cE5xh*Mv2RhL z4X>XKYwX43Hzr+%2n8u!(Gl1}iD_#=M?4*7o%1re{BJWc+`uS-8!!8!_g>7I2Bag@ znW&GC3!_{vIpsIK7t6HZzV{TDr_%1*f2rDhYZhVzmz`EscVRX@jXqry{Dg8+v1qHV zyH!HC0!iJLiOiyA{M{gyIXuXDe!B+OHh#C7YBihQDjf%NEc#~=N|u|7bxP9R?1#&E zevA=yrTw3FX^_zUg_+;VhesO{(-wk+vGZOL%`*iL zTZWz0%vw25(656o0(-ljzrpW6B(Ejht}*2I8|^ao@RO7MXcIt@XVSlT)w#J}^TSN8 z4$N;0T8*-k=yHh_L&O>+a~TI#6S6A58(++*;ZJC-P|$$Mnf;Zx*KF#lSptCM)zTp^ z>#wVbe1+zS6o2PDk&!CMz5L4VHX?1wy>i%Z`0?(cW%;@8J4cY#%aSq+Nfpe90*UC5 zQCxqaeV)zka&AfZVkgxsolEMz&U=a8`6ZeDSdLHy3@CW??R5VszB*0sUdn0#sn0D& z99Z5Bm~w+!bb|ApEW8s~%5AhRb_>s(xak?r`W+eR=Oq`+!RuEOCWTsx1hTW(vsMbA z%jl8Q@fn}G1e{L}Lpv7z~1IBj#3%SW` z!8xoi@uA(qVEh*#tsaVfCeoXwWqB1z)gLC`##}`v+qhygQwB z{+T0i`?*~3+lzODd_z1O_t5BqA62w3H6J0oXMzSqNT)Ag9hB6x!iWli7x)znBIDbT z_B&A>&jycZK%&mmyrD18H*7g|a|7Ye2A}DTpJLp4A!ebqar=Pu>`{3BYXqOf6ib#= zj}>cZ6stLm6K&kn-Cs-2FKt3SFHzSVVLI8RVNen)!yz z)rrRABNAWDWnTg{D@d}51{PP*E4>GFd> zz-_dSx{vm_AO4LJe70#^_}F@T9%t)?{Ygnj7X!ykJHl4O zw#CW;8}6?Wm8t$eM{@NR#x&_+71LoApFVLZ!#J$4s&@(D!KQ*ov;H)#vM|i@?(5<0 za_)a|G;_Z&U*3-Vdj{p;nd5Z0ZnHbvxZaml>ADd(Zlx+HR0a$GzR`;vg5v) z5J4!uQ&7}tT~u%LVt2J~nOns9T=zgghQKvJ{P1@6);4pOiaC&Ee!pB*W@Z2%C-7_M z-`P>SMtEnhoG0()=Pzr`B_Wf+`^Y1nzhPmiRC>@-mb^FlL)d8F{OqGH@?|TfHLvl5 zJ?ppK>tVYAM|=5b!IoV58qk5n1iqvBa${z9_tQ%}9ptp9YTB&(Dy#GZ31r0po0{3G ze$#q+i>PQ!0;TYlb!->Drt?$XRJ%v=6&|7XoFZlA&2;+hE{pX|4^E4TgC?5 zHKIqHp2X#dHuU{<@aC8FQZ=e9JRTYB;_y&W>kGy<4fxPq&wl)*-kv`K*gK|cM>D(6 z3>Ui}l#Ji9tkY%RN^vR|ZaoM!ENf-g`lFr7o2Gt->E)?X|B>IZzi}ooeBw}PEh)Q` zt6}75vnWx?*nRSHZY;_NVF|0484u!cb^ctNu8CR`^MW+5)Mr?J9pfw-LB}vO()?p4 z-u;n^HSPzuFHxYQh!>}eAsEdIJNI=gtVPmxwFQ~o`oiH$9qYzjd_kzc>ZdJG>UB2% lfBU27kFLW*ueRj?yLQv24`q)3Yv};s)=j+|fQ-;iK$xI(f`$oT17L!(LFfcz168`nA*Cc%I0atv-RTUm zZ2wkd832qx#F%V@dJ3`^u!1Jbu|MA-*zqXsjx6)|^3FfFwG`kef*{y-Ind7Q&tc211>U&A`hY=1aJl9Iuetm z$}wv*0hFK%+BrvIsvN?C7pA3{MC8=uea7593GXf-z|+;_E5i;~j+ukPpM7$AJ + ``` + +2. Provision and deploy: + + ```bash + atk provision --env dev + atk deploy --env dev + ``` + + `atk` will prompt you to sign in to Azure and Microsoft 365 if you aren't already. + +## Configuration + +| Variable | Required | Description | +|----------|----------|-------------| +| `MCS_ENVIRONMENT_ID` | Yes | Power Platform environment ID of the Copilot Studio agent. | +| `MCS_SCHEMA_NAME` | Yes | Schema name of the Copilot Studio agent. | +| `MCS_CONNECTION_NAME` | No | Bot Service OAuth connection name. Defaults to `mcs`. | +| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint URL. | +| `AZURE_OPENAI_DEPLOYMENT` | No | Chat deployment name. Defaults to `gpt-4o`. | +| `AZURE_OPENAI_API_VERSION` | No | Defaults to `2024-12-01-preview`. | +| `SECRET_AZURE_OPENAI_API_KEY` | Yes | Azure OpenAI API key — goes in `env/.env.dev.user`. | +| `AZURE_SUBSCRIPTION_ID` | No | Blank ⇒ prompted during provision. | +| `AZURE_RESOURCE_GROUP_NAME` | No | Blank ⇒ prompted/created during provision. | +| `RESOURCE_SUFFIX` | No | Makes resource names globally unique. Auto-generated if blank. | + +Bicep injects these as **App Service application settings**, so the deployed bot reads its +configuration from Azure — there is no runtime `.env` in the cloud. + +## What gets provisioned + +`infra/azure.bicep` (and its modules) create: + +- A **user-assigned managed identity** used by the bot (no client secret in production). +- An **App Service plan + Web App** hosting the Node.js bot. +- An **Azure Bot** resource with the Teams and Microsoft 365 channels. +- Two **OAuth connections** on the bot: + - **`SsoConnection`** — Teams/Microsoft 365 single sign-on into the bot. + - **`mcs`** — delegated access to the Power Platform API + (scope `https://api.powerplatform.com/.default`, permission + `CopilotStudio.Copilots.Invoke`) used to call Copilot Studio on behalf of the user. +- An **Entra app registration** granted the Power Platform API permissions. +- **Application Insights** for logs and telemetry. + +Provision writes generated values (`BOT_ID`, `WEBAPPID`, `BOT_DOMAIN`, `SSO_APP_ID`, +`TEAMS_APP_ID`, `M365_TITLE_ID`, `M365_APP_ID`, …) back into `env/.env.dev`. + +## Install the app + +After deploy, install the package the toolkit built: + +```bash +atk install --file-path appPackage/build/appPackage.dev.zip --env dev +``` + +Or in Teams: **Apps → Manage your apps → Upload an app → Upload a custom app**, then select +`appPackage/build/appPackage.dev.zip`. The agent also appears in Microsoft 365 Copilot. + +## Testing the deployed agent + +The first time any user messages the agent, they are asked to sign in — this grants the +delegated Copilot Studio access. After that, ask a hotel question (e.g. *"What are the +available hotels?"*) and the Copilot Studio agent's answer streams back. + +**Automated smoke test.** From the sample root: + +```bash +npm run smoke +``` + +`scripts/smoke-test.mjs` enables the Direct Line channel on the provisioned bot (via the +Azure CLI), starts a conversation, sends a hotel prompt, and prints the streamed reply. +Because every turn requires the `MCS` sign-in, when the bot returns a sign-in card the +script prints the sign-in URL — open it once, then re-run. Requirements: the +[Azure CLI](https://learn.microsoft.com/cli/azure/) signed in (`az login`) to the +subscription that holds the bot. + +**Teams / Microsoft 365 Copilot.** For a fully interactive check, open the installed agent +in Teams or Microsoft 365 Copilot, complete the SSO sign-in, and chat — SSO is seamless +there. + +## Update, redeploy, clean up + +- **Code change:** `atk deploy --env dev` (or re-run the deploy script). +- **Infra change:** `atk provision --env dev` again. +- **Remove everything:** delete the Azure resource group, and remove the app from + *Manage your apps* / Teams Admin Center. + +## Troubleshooting + +- **`extendToM365` step fails intermittently.** The resources still deploy; upload + `appPackage/build/appPackage.dev.zip` manually as above. +- **Name conflicts during provision.** Set a unique `RESOURCE_SUFFIX` and re-run. +- **Agent starts but errors on Copilot Studio calls.** Verify `MCS_ENVIRONMENT_ID` / + `MCS_SCHEMA_NAME` point at a *published* agent and that the `mcs` OAuth connection and + Power Platform admin consent are in place. +- **Sign-in fails.** Confirm the Entra app registration has admin consent for the Power + Platform API permissions granted in `infra/modules/app-registration.bicep`. diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/LOCAL_DEPLOYMENT.md b/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/LOCAL_DEPLOYMENT.md new file mode 100644 index 00000000..6b7b1159 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/LOCAL_DEPLOYMENT.md @@ -0,0 +1,92 @@ +--- +nav_exclude: true +search_exclude: false +--- + +# Local development guide + +Run and debug the proxy agent on your machine with a dev tunnel, then test it in Teams or +Microsoft 365 Copilot. For a cloud deployment, see the +[Azure deployment guide](AZURE_DEPLOYMENT). + +## How local run works + +Local development still provisions a few Azure/Entra resources (an app registration, an +Azure Bot pointing at your dev tunnel, and the `SsoConnection` + `mcs` OAuth connections), +but the **bot code runs locally**. The Microsoft 365 Agents Toolkit orchestrates this from +`m365agents.local.yml` and writes runtime settings to `.localConfigs`. + +## Prerequisites + +- **Node.js 22 or 24** and npm. +- **Microsoft 365 Agents Toolkit** — the + [VS Code extension](https://marketplace.visualstudio.com/items?itemName=TeamsDevApp.ms-teams-vscode-extension) + (for F5) or the `atk` CLI. +- **Dev Tunnels CLI** — [install guide](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started). +- **Azure subscription** (for the local Bot resource) and an **Entra tenant** where you can + register apps and upload a custom app. +- A **published Copilot Studio agent** (environment ID + schema name) and an **Azure OpenAI** + deployment + key. + +## 1. Configure environment + +`env/.env.local` is created/seeded automatically (via `scripts/env.js`). Set the Copilot +Studio and Azure OpenAI values there: + +```ini +# env/.env.local +MCS_CONNECTION_NAME=mcs +MCS_ENVIRONMENT_ID= +MCS_SCHEMA_NAME= +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=gpt-4o +``` + +Put secrets in `env/.env.local.user` (git-ignored): + +```ini +# env/.env.local.user +SECRET_AZURE_OPENAI_API_KEY= +``` + +`SECRET_BOT_PASSWORD` is generated for you when the local bot's app registration is created. + +## 2. Run with F5 (VS Code) + +Open the folder in VS Code with the Agents Toolkit extension and press **F5**, then choose a +launch target: + +- **Launch App (Edge)** / **Launch App (Chrome)** — opens the agent in Teams. +- **(Preview) Launch Remote in Copilot (Edge)** — opens it in Microsoft 365 Copilot. + +The toolkit starts a dev tunnel, provisions the local resources, builds, and runs the bot. + +## Alternative: run from the CLI + +```bash +atk provision --env local # creates the local bot + OAuth connections + tunnel config +npm run dev:teamsfx # runs the bot locally against .localConfigs +``` + +Then upload `appPackage/build/appPackage.local.zip` in Teams +(**Apps → Manage your apps → Upload a custom app**). + +The `scripts/devtunnel.sh` / `scripts/devtunnel.ps1` helpers can start and manage the dev +tunnel if you prefer to run it manually. + +## 3. Test + +Message the agent; the first turn prompts a one-time sign-in that grants delegated Copilot +Studio access. Ask a hotel question (e.g. *"What are the available hotels?"*) and watch the +Copilot Studio agent's answer stream back. Set breakpoints in `src/agent.ts` or +`src/mcs/*.ts` to step through orchestration and the tool call. + +## Troubleshooting + +- **Tunnel not found / bot unreachable.** Ensure the Dev Tunnels CLI is installed and you're + signed in (`devtunnel user show`); re-run F5 / `atk provision --env local`. +- **`MCS_ENVIRONMENT_ID is not configured`.** Fill the values in `env/.env.local` and restart. +- **Sign-in loops.** Confirm the local `mcs` OAuth connection was created and the app + registration has admin consent for the Power Platform API permissions. +- **Azure OpenAI 401/404.** Recheck `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT`, and + the key in `env/.env.local.user`. diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/env/.env.dev b/extensibility/agents-sdk/m365-langgraph-mcs-tool/env/.env.dev new file mode 100644 index 00000000..c981e577 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/env/.env.dev @@ -0,0 +1,34 @@ +# This file includes environment variables that WILL be committed to git. +# Secrets (API keys, passwords) must NOT go here — put them in env/.env.dev.user +# (git-ignored). Values left empty are either filled by `scripts/deploy.sh` / +# `scripts/deploy.ps1`, prompted for by `atk provision`, or written back by the +# toolkit after provisioning. + +# --- Microsoft 365 Agents Toolkit (built-in) --- +TEAMSFX_ENV=dev +APP_NAME_SUFFIX=dev +# Globally-unique suffix for Azure resource names (bot service + web app must be +# unique). Leave empty to let the deploy script generate one, or set your own. +RESOURCE_SUFFIX= + +# --- Azure target (empty => you'll be prompted to pick) --- +AZURE_SUBSCRIPTION_ID= +AZURE_RESOURCE_GROUP_NAME= + +# --- Copilot Studio (MCS) agent this proxy calls --- +MCS_CONNECTION_NAME=mcs +MCS_ENVIRONMENT_ID= +MCS_SCHEMA_NAME= + +# --- Azure OpenAI (LangGraph orchestrator model) --- +AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_DEPLOYMENT=gpt-4o + +# --- Written back by `atk provision` (do not edit by hand) --- +TEAMS_APP_ID= +BOT_ID= +WEBAPPID= +BOT_DOMAIN= +SSO_APP_ID= +M365_TITLE_ID= +M365_APP_ID= diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/env/env.TEMPLATE b/extensibility/agents-sdk/m365-langgraph-mcs-tool/env/env.TEMPLATE new file mode 100644 index 00000000..5584d8c7 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/env/env.TEMPLATE @@ -0,0 +1,28 @@ +# rename to .env +# Prerequired environment variables + +# --- Copilot Studio (MCS) configuration --- +MCS_CONNECTION_NAME=mcs +MCS_ENVIRONMENT_ID= +MCS_SCHEMA_NAME= + +# --- Azure OpenAI configuration (for LangGraph orchestrator) --- +AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_DEPLOYMENT=gpt-4o +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_API_VERSION=2024-12-01-preview + +# --- Bot authentication (M365 Agents SDK 1.1.1) --- +connections__serviceConnection__settings__clientId= # App ID of the App Registration used to log in. +connections__serviceConnection__settings__clientSecret= # Client secret of the App Registration used to log in +connections__serviceConnection__settings__tenantId= # Tenant ID of the App Registration used to log in + +connectionsMap__0__connection=serviceConnection +connectionsMap__0__serviceUrl=* + +# To enable debugging for the Agent SDK +DEBUG=agents:*:error + +# Logging configuration +# Options: error, warn, info (default), debug +LOG_LEVEL=info diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.bicep new file mode 100644 index 00000000..7a1b7550 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.bicep @@ -0,0 +1,195 @@ +// Local Development Bicep - Two App Security Model +// App 1: Bot App (created by M365 Agents Toolkit) - uses client secret for Bot Service authentication +// App 2: SSO App (created by this Bicep) - uses federated credentials for user authentication +// This deploys: Bot Service Principal → SSO App Registration → Azure Bot Service → OAuth Connections + +targetScope = 'resourceGroup' + +@description('Name of the bot') +param botName string + +@description('The Bot ID (Microsoft App ID) - created by M365 Agents Toolkit') +param botId string + +@description('Bot messaging endpoint') +param botEndpoint string + +@description('Tenant ID') +param tenantId string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('Bot Service SKU') +param botServiceSku string = 'F0' + +@description('SSO App ID - use 00000000-0000-0000-0000-000000000000 for first-time deployment') +param ssoAppId string = '00000000-0000-0000-0000-000000000000' + +// Variables +var ssoAppName = '${botName}-UserAuth' // Different name to avoid duplicate +var nullGuid = '00000000-0000-0000-0000-000000000000' +var isFirstTimeDeployment = ssoAppId == nullGuid + +// ======================================== +// GUID ENCODING: Encode Tenant ID Once (First-time only) +// ======================================== +// Run GUID encoder once and reuse the encoded value for all OAuth connections +module guidEncoder 'modules/guid-encoder.bicep' = if (isFirstTimeDeployment) { + name: 'encode-tenant-guid-local' + params: { + guidToEncode: tenantId + location: location + } +} + +// ======================================== +// STEP 1: Create SSO App Registration (First-time only) +// ======================================== +// This is a separate app for user authentication using federated credentials +// No client secret - uses federated credentials instead +module ssoAppRegistration 'modules/app-registration.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-sso-app-registration-local' + params: { + aadAppName: ssoAppName + botId: botId + tenantId: tenantId + encodedTenantId: guidEncoder!.outputs.encodedGuid + } +} + +// ======================================== +// STEP 2: Create Azure Bot Service (First-time only) +// ======================================== +// Uses Bot App (with client secret) for authentication +resource botService 'Microsoft.BotService/botServices@2021-03-01' = if (isFirstTimeDeployment) { + kind: 'azurebot' + location: 'global' + name: botName + properties: { + displayName: botName + endpoint: botEndpoint + msaAppId: botId + msaAppTenantId: tenantId + msaAppType: 'SingleTenant' + } + sku: { + name: botServiceSku + } +} + +// Connect to Microsoft Teams (First-time only) +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = if (isFirstTimeDeployment) { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} + +// ======================================== +// STEP 3: Create OAuth Connection for SSO (First-time only) +// ======================================== +// Uses SSO App with federated credentials for user authentication +module botOAuthConnection 'modules/bot-oauth-connection.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-bot-oauth-connection-sso-local' + params: { + botServiceName: botName + connectionName: 'SsoConnection' + aadAppId: ssoAppRegistration!.outputs.aadAppId + aadAppIdUri: ssoAppRegistration!.outputs.aadAppIdUri + federatedCredentialName: ssoAppRegistration!.outputs.fciName + scopes: '${ssoAppRegistration!.outputs.aadAppIdUri}/access_as_user' + tenantId: tenantId + location: 'global' + } + dependsOn: [ + botService + ] +} + +// ======================================== +// STEP 4: Create OAuth Connection for Copilot Studio (First-time only) +// ======================================== +// Used by the MCS tool to call Copilot Studio on behalf of the user. +// The .default scope is required for Bot Service federated credential token exchange — +// do NOT replace with a more specific scope (e.g. CopilotStudio.Copilots.Invoke) +// as that will break the exchange flow. Actual permissions are controlled by the +// app registration's requiredResourceAccess in app-registration.bicep. +module botOAuthConnectionMCS 'modules/bot-oauth-connection.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-bot-oauth-connection-mcs-local' + params: { + botServiceName: botName + connectionName: 'mcs' + aadAppId: ssoAppRegistration!.outputs.aadAppId + aadAppIdUri: ssoAppRegistration!.outputs.aadAppIdUri + federatedCredentialName: ssoAppRegistration!.outputs.fciName + scopes: 'https://api.powerplatform.com/.default' + tenantId: tenantId + location: 'global' + } + dependsOn: [ + botService + ] +} + +// ======================================== +// STEP 5: Create Service Principal for Bot App (First-time only) +// ======================================== +// The Bot App is created by M365 Agents Toolkit with a client secret +// We create its service principal after SSO app registration to avoid replication timing issues +module botServicePrincipal 'modules/service-principal.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-bot-service-principal-local' + params: { + appId: botId + } + dependsOn: [ + ssoAppRegistration + ] +} + +// ======================================== +// STEP 6: Create Service Principal for SSO App (First-time only) +// ======================================== +// The SSO App is created by M365 Agents Toolkit with a client secret +// We create its service principal after SSO app registration to avoid replication timing issues +module SSOServicePrincipal 'modules/service-principal.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-sso-service-principal-local' + params: { + appId: ssoAppRegistration.outputs.aadAppId + } + +} + +// ======================================== +// STEP 7: Update Bot Endpoint (Always - for dev tunnel changes) +// ======================================== +// This runs on every deployment to update the bot endpoint with the latest dev tunnel URL +module updateBotEndpoint 'modules/update-bot-endpoint.bicep' = if (!isFirstTimeDeployment) { + name: 'update-bot-endpoint-local' + params: { + botServiceName: botName + botAppId: botId + botEndpoint: botEndpoint + botAppTenantId: tenantId + botDisplayName: botName + botServiceSku: botServiceSku + } +} + +// ======================================== +// OUTPUTS +// ======================================== +output botServiceName string = isFirstTimeDeployment ? botService.name : botName +output botId string = botId +output tenantId string = tenantId +output SSO_APP_ID_URI string = 'api://botid-${botId}' + +// SSO App outputs +output sso_App_Id string = isFirstTimeDeployment ? ssoAppRegistration!.outputs.aadAppId : ssoAppId + + +// OAuth Connection names +output oauthConnectionName string = 'SsoConnection' +output mcsConnectionName string = 'mcs' diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.parameters.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.parameters.json new file mode 100644 index 00000000..b0d77b9f --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure-local.parameters.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "botName": { + "value": "AzureAgentToM365ATK-${{RESOURCE_SUFFIX}}-${{APP_NAME_SUFFIX}}" + }, + "botId": { + "value": "${{BOT_ID}}" + }, + "botEndpoint": { + "value": "${{BOT_ENDPOINT}}" + }, + "tenantId": { + "value": "${{TEAMS_APP_TENANT_ID}}" + }, + "ssoAppId": { + "value": "${{SSO_APP_ID}}" + } + } +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.bicep new file mode 100644 index 00000000..a64480e0 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.bicep @@ -0,0 +1,243 @@ +// Main orchestration file for M365 Agent deployment +// This deploys: Managed Identity → App Service → Azure Bot → App Registration + +targetScope = 'resourceGroup' + +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources') +param resourceBaseName string + +@maxLength(42) +@description('Display name for the bot') +param botDisplayName string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('The SKU for the App Service Plan') +@allowed([ + 'F1' + 'B1' + 'B2' + 'B3' + 'S1' + 'S2' + 'S3' + 'P1v2' + 'P2v2' + 'P3v2' +]) +param webAppSKU string = 'B1' + +@description('The SKU for the Bot Service') +@allowed([ + 'F0' + 'S1' +]) +param botServiceSku string = 'F0' + +@description('Tenant ID for the Entra ID application') +param tenantId string = tenant().tenantId + +@description('Enable Application Insights') +param enableAppInsights bool = true + +@description('Additional app settings for the Web App') +param additionalAppSettings array = [] + +@description('MCS Environment ID') +param mcsEnvironmentId string + +@description('MCS Agent Schema Name') +param mcsSchemaName string + +@description('Azure OpenAI Endpoint') +param azureOpenAiEndpoint string + +@description('Azure OpenAI Deployment Name') +param azureOpenAiDeployment string = 'gpt-4o' + +@secure() +@description('Azure OpenAI API Key') +param azureOpenAiApiKey string + +@description('MCS (Copilot Studio) OAuth Connection Name') +param mcsConnectionName string = 'mcs' + +// Generate resource names +var identityName = '${resourceBaseName}-identity' +var webAppName = '${resourceBaseName}-app' +var botServiceName = '${resourceBaseName}-bot' +var aadAppName = '${resourceBaseName}-UserAuth' + +// Setp 0: GUID ENCODING: Encode Tenant ID +module guidEncoder 'modules/guid-encoder.bicep' = { + name: 'encode-tenant-guid-local' + params: { + guidToEncode: tenantId + location: location + } +} + + +// Step 1: Create User Assigned Managed Identity for the bot +module botIdentity 'modules/bot-managedidentity.bicep' = { + name: 'deploy-bot-identity' + params: { + identityName: identityName + location: location + } +} + +// Step 1.5: Create Application Insights with Managed Identity (if enabled) +module appInsights 'modules/appinsights.bicep' = if (enableAppInsights) { + name: 'deploy-app-insights' + params: { + resourceBaseName: resourceBaseName + location: location + identityPrincipalId: botIdentity.outputs.identityPrincipalId + applicationType: 'web' + } +} + +// Step 2: Create App Service with the managed identity +module appService 'modules/appservice.bicep' = { + name: 'deploy-app-service' + params: { + resourceBaseName: resourceBaseName + location: location + serverfarmsName: '${resourceBaseName}-plan' + webAppName: webAppName + webAppSKU: webAppSKU + MSIid: botIdentity.outputs.identityId + enableAppInsights: enableAppInsights + appInsightsConnectionString: appInsights.?outputs.?appInsightsConnectionString ?? '' + // Bot Configuration (for appsettings.json template variables) + botId: botIdentity.outputs.identityClientId + botTenantId: tenantId + oauthConnectionName: 'SsoConnection' + mcsConnectionName: mcsConnectionName + mcsEnvironmentId: mcsEnvironmentId + mcsSchemaName: mcsSchemaName + azureOpenAiEndpoint: azureOpenAiEndpoint + azureOpenAiDeployment: azureOpenAiDeployment + azureOpenAiApiKey: azureOpenAiApiKey + additionalAppSettings: additionalAppSettings + } +} + +// Step 3: Create Azure Bot Service +module azureBot 'modules/azurebot.bicep' = { + name: 'deploy-azure-bot' + params: { + resourceBaseName: resourceBaseName + botDisplayName: botDisplayName + botServiceName: botServiceName + botServiceSku: botServiceSku + identityResourceId: botIdentity.outputs.identityId + identityClientId: botIdentity.outputs.identityClientId + identityTenantId: tenantId + botAppDomain: appService.outputs.webAppHostName + } +} + +// Step 4: Create App Registration with all required parameters +module appRegistration 'modules/app-registration.bicep' = { + name: 'deploy-app-registration' + params: { + aadAppName: aadAppName + botId: botIdentity.outputs.identityClientId + tenantId: tenantId + encodedTenantId: guidEncoder.outputs.encodedGuid + } + dependsOn: [ + azureBot + ] +} + +// Step 5: Configure OAuth Connection with Azure AD v2 and Federated Credentials +module botOAuthConnection 'modules/bot-oauth-connection.bicep' = { + name: 'deploy-bot-oauth-connection' + params: { + botServiceName: botServiceName + connectionName: 'SsoConnection' + aadAppId: appRegistration.outputs.aadAppId + aadAppIdUri: appRegistration.outputs.aadAppIdUri + federatedCredentialName: appRegistration.outputs.fciName + scopes: '${appRegistration.outputs.aadAppIdUri}/access_as_user' + tenantId: tenantId + location: 'global' + } +} + +// Step 6: Configure OAuth Connection for Copilot Studio +// Used by the MCS tool to call Copilot Studio on behalf of the user. +// ABS handles consent & token caching. +// The .default scope is required for federated credential token exchange — +// do NOT replace with a specific scope. See app-registration.bicep for permissions. +module botOAuthConnectionMCS 'modules/bot-oauth-connection.bicep' = { + name: 'deploy-bot-oauth-connection-mcs' + params: { + botServiceName: botServiceName + connectionName: 'mcs' + aadAppId: appRegistration.outputs.aadAppId + aadAppIdUri: appRegistration.outputs.aadAppIdUri + federatedCredentialName: appRegistration.outputs.fciName + scopes: 'https://api.powerplatform.com/.default' + tenantId: tenantId + location: 'global' + } +} + + +// ======================================== +// STEP 7: Create Service Principal for SSO App (First-time only) +// ======================================== +// The SSO App is created by M365 Agents Toolkit with a client secret +// We create its service principal after SSO app registration to avoid replication timing issues +module SSOServicePrincipal 'modules/service-principal.bicep' = { + name: 'deploy-sso-service-principal-local' + params: { + appId: appRegistration.outputs.aadAppId + } +} + + +// Outputs for reference and further configuration +output resourceBaseName string = resourceBaseName +output location string = location + +// Identity outputs +output identityName string = identityName +output identityId string = botIdentity.outputs.identityClientId +output identityPrincipalId string = botIdentity.outputs.identityPrincipalId + +// App Service outputs +output webAppName string = appService.outputs.webAppName +output webAppId string = appService.outputs.webAppId +output BOT_DOMAIN string = appService.outputs.webAppHostName +output webAppUrl string = 'https://${appService.outputs.webAppHostName}' +output appServicePlanId string = appService.outputs.appServicePlanId + +// Bot Service outputs +output BOT_ID string = botIdentity.outputs.identityClientId +output botServiceName string = botServiceName +output bot_Endpoint string = 'https://${appService.outputs.webAppHostName}/api/messages' +output Oauth_Connection_Name string =botOAuthConnection.name +output MCS_Connection_Name string = botOAuthConnectionMCS.name + +// App Registration outputs +output SSO_APP_ID string = appRegistration.outputs.aadAppId +output SSO_APP_ID_URI string = appRegistration.outputs.aadAppIdUri +output federatedCredentialName string = appRegistration.outputs.fciName +// Note: fciSubject is used internally for OAuth connection but not exposed as output + +// Application Insights outputs (if enabled) +output appInsightsName string = appInsights.?outputs.?appInsightsName ?? '' +output appInsightsConnectionString string = appInsights.?outputs.?appInsightsConnectionString ?? '' +output appInsightsInstrumentationKey string = appInsights.?outputs.?appInsightsInstrumentationKey ?? '' +output logAnalyticsWorkspaceName string = appInsights.?outputs.?logAnalyticsWorkspaceName ?? '' + + + diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.parameters.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.parameters.json new file mode 100644 index 00000000..f20174ed --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/azure.parameters.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceBaseName": { + "value": "bot${{RESOURCE_SUFFIX}}" + }, + "botDisplayName": { + "value": "AzureAgentToM365ATK${{APP_NAME_SUFFIX}}" + }, + "webAppSKU": { + "value": "B1" + }, + "botServiceSku": { + "value": "F0" + }, + "enableAppInsights": { + "value": true + }, + "mcsEnvironmentId": { + "value": "${{MCS_ENVIRONMENT_ID}}" + }, + "mcsSchemaName": { + "value": "${{MCS_SCHEMA_NAME}}" + }, + "azureOpenAiEndpoint": { + "value": "${{AZURE_OPENAI_ENDPOINT}}" + }, + "azureOpenAiDeployment": { + "value": "${{AZURE_OPENAI_DEPLOYMENT}}" + }, + "azureOpenAiApiKey": { + "value": "${{SECRET_AZURE_OPENAI_API_KEY}}" + } + } + } diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/bicepconfig.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/bicepconfig.json new file mode 100644 index 00000000..cd15f3f3 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/bicepconfig.json @@ -0,0 +1,5 @@ +{ + "extensions": { + "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0" + } +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/BOT_OAUTH_CONNECTION.md b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/BOT_OAUTH_CONNECTION.md new file mode 100644 index 00000000..b0976e4e --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/BOT_OAUTH_CONNECTION.md @@ -0,0 +1,248 @@ +# Bot OAuth Connection Configuration + +## Overview +The `bot-oauth-connection.bicep` module configures Azure AD v2 OAuth connection with federated credentials for your M365 Agent bot. This enables Single Sign-On (SSO) in Microsoft Teams. + +## SSO Flow for Agents in Teams & M365 Copilot + +```mermaid +--- +config: + theme: default +--- +sequenceDiagram + participant User as Agent User + participant Teams as M365 Copilot + participant Bot as Azure Bot Service + participant BF as Azure Bot Service
Token Service + participant Store as Azure Bot Service
Token Store + participant AAD as Microsoft Entra ID + User ->> Teams: 1. Send message to Agent + Teams ->> Bot: Forward message + Bot ->> BF: 2. Request sign-in link + BF ->> Bot: Return sign-in link + Bot ->> Teams: 3. Send OAuth card + Teams ->> Teams: Check if SSO enabled + alt SSO enabled + Teams ->> Bot: 4. Send token exchange request + Bot ->> BF: Forward token exchange + BF ->> AAD: Exchange token + alt First time user + AAD ->> Teams: 5. Request consent + Teams ->> User: Display consent dialog + User ->> Teams: Grant consent + Teams ->> AAD: Consent granted + AAD ->> BF: Return access token + else Returning user + AAD ->> BF: Return access token + end + BF ->> Store: 6. Store token + BF ->> Bot: Token available + Bot ->> Teams: Process request (authenticated) + else SSO disabled or consent fails + Teams ->> User: Display sign-in button + User ->> Teams: Click sign-in + Teams ->> AAD: Redirect to sign-in page + User ->> AAD: Sign in & grant access + AAD ->> BF: Return access token + BF ->> Store: Store token + BF ->> Bot: Token available + Bot ->> Teams: Process request (authenticated) + end + Teams ->> User: Display Agent response + +``` + +**Key Points:** +- **Token Caching**: Azure Bot Service stores tokens for returning users +- **OAuth Card**: Agent receive an OAuth card as a mean to deliver the Authentication Request +- **SSO Experience**: First-time users see a consent dialog (unless admin consent granted before), returning users sign in silently +- **Fallback**: If SSO fails, users see traditional sign-in flow +- **Token Exchange**: Uses federated credentials for secure token exchange no client secrets to configure and manage + +## Module: bot-oauth-connection.bicep + +### Purpose +Creates an OAuth connection setting on the Azure Bot Service that: +- Uses Azure Active Directory v2 as the identity provider +- Leverages federated credentials (no client secret required) +- Enables SSO for seamless user authentication in Teams + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `botServiceName` | string | Yes | - | Name of the Bot Service to configure | +| `connectionName` | string | No | `'SsoConnection'` | Name for the OAuth connection | +| `aadAppId` | string | Yes | - | Azure AD Application (client) ID | +| `aadAppIdUri` | string | Yes | - | Azure AD Application ID URI (e.g., `api://botid-{guid}`) | +| `scopes` | string | No | `'openid profile offline_access'` | Space-separated OAuth scopes | +| `tenantId` | string | Yes | - | Azure AD tenant ID | +| `location` | string | No | `'global'` | Resource location (always 'global' for bot connections) | +| `additionalParameters` | array | No | `[]` | Additional service provider parameters | + +### Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `connectionName` | string | Full name of the connection (format: `botServiceName/connectionName`) | +| `connectionId` | string | Resource ID of the connection | +| `settingId` | string | Setting ID assigned by the Bot Service | +| `provisioningState` | string | Provisioning state of the connection | + +### Key Features + +#### Azure AD v2 Service Provider +- **Service Provider ID**: `30dd229c-58e3-4a48-bdfd-91ec48eb906c` +- **Provider**: Azure Active Directory v2 +- **Authentication**: OAuth 2.0 with OpenID Connect + +#### Federated Credentials +- **No Client Secret Required**: Uses federated identity credentials created in the app registration +- **Secure**: Token exchange happens through Azure AD without storing secrets +- **Modern**: Leverages managed identity and federated credentials + +#### Default Scopes +- `openid`: OpenID Connect authentication +- `profile`: User profile information +- `offline_access`: Refresh token support + +#### Token Exchange +- Configured with `tokenExchangeUrl` pointing to the app ID URI +- Enables seamless SSO in Teams without additional user prompts + +## Integration in azure.bicep + +The OAuth connection is deployed as **Step 5** in the orchestration: + +```bicep +// Step 5: Configure OAuth Connection with Azure AD v2 and Federated Credentials +module botOAuthConnection 'modules/bot-oauth-connection.bicep' = { + name: 'deploy-bot-oauth-connection' + params: { + botServiceName: botServiceName + connectionName: 'SsoConnection' + aadAppId: appRegistration.outputs.aadAppId + aadAppIdUri: appRegistration.outputs.aadAppIdUri + scopes: 'openid profile offline_access' + tenantId: tenantId + location: 'global' + } +} +``` + +### Dependencies +- **Requires**: App Registration module must complete first (provides `aadAppId` and `aadAppIdUri`) +- **Uses**: Bot Service created in Step 3 +- **Implicit Dependency**: Bicep automatically handles dependency through output references + +## Deployment Flow + +1. **Managed Identity Created** → Bot identity established +2. **App Service Deployed** → Web app with managed identity +3. **Bot Service Created** → Bot registered with Teams channel +4. **App Registration Created** → Entra ID app with federated credentials +5. **OAuth Connection Configured** → SSO enabled with AAD v2 ✨ + +## Usage in Bot Code + +Once deployed, your bot can use this connection for SSO: + +```typescript +// Reference the connection name in your bot +const connectionName = "SsoConnection"; // Must match the connectionName parameter + +// Use in Microsoft 365 Agents SDK (Node.js) +import { UserTokenClient } from "@microsoft/agents-hosting"; + +const userTokenClient = new UserTokenClient(process.env.MicrosoftAppId || ""); + +// Get user token for Copilot Studio (Power Platform) access +const tokenResponse = await userTokenClient.getUserToken( + context, + connectionName, + undefined // magicCode +); + +if (tokenResponse?.token) { + // Use token to authenticate with Copilot Studio + const userToken = tokenResponse.token; + // Pass to the CopilotStudioClient for user-authenticated requests +} +``` + +## Customization + +### Additional Scopes +To request additional Microsoft Graph permissions: + +```bicep +scopes: 'openid profile offline_access User.Read Mail.Read' +``` + +### Custom Parameters +Add service provider-specific parameters: + +```bicep +additionalParameters: [ + { + key: 'customParam' + value: 'customValue' + } +] +``` + +## Verification + +After deployment, verify the connection: + +1. **Azure Portal**: + - Navigate to Bot Service → Settings → OAuth Connection Settings + - Verify "SsoConnection" appears with status "Success" + +2. **Test Connection**: + - Click "Test Connection" in Azure Portal + - Sign in with a test user + - Verify successful authentication + +3. **Bot Code**: + - Test SSO flow in Teams + - Verify token acquisition succeeds + +## Troubleshooting + +### Connection Not Visible +- Ensure app registration completed successfully +- Verify federated credential was created +- Check bot service name matches + +### Token Exchange Fails +- Verify `aadAppIdUri` matches app registration (`api://botid-{guid}`) +- Ensure federated credential subject is correct +- Check tenant ID matches + +### SSO Prompt Still Appears +- Verify pre-authorized applications in app registration +- Check scopes are correctly configured +- Ensure Teams app manifest uses correct app ID + +## Security Notes + +✅ **No Client Secrets**: Uses federated credentials for enhanced security +✅ **Managed Identity**: Bot uses managed identity for Azure resources +✅ **Token Exchange**: Secure token exchange through AAD +✅ **Scoped Permissions**: Only requests necessary scopes + +## Next Steps + +After OAuth connection is configured: +1. Update Teams app manifest with correct app IDs +2. Configure bot code to use the connection +3. Test SSO flow in Teams/M365 Copilot chat +4. Add additional Graph API permissions as needed (optional) + +## Resources + +- [Azure Bot Service OAuth Documentation](https://docs.microsoft.com/azure/bot-service/bot-builder-authentication) +- [Azure AD v2 Token Exchange](https://docs.microsoft.com/azure/bot-service/bot-builder-authentication-sso) +- [Teams SSO for Bots](https://docs.microsoft.com/microsoftteams/platform/bots/how-to/authentication/auth-aad-sso-bots) diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/GUID_ENCODER_GUIDE.md b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/GUID_ENCODER_GUIDE.md new file mode 100644 index 00000000..67c14770 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/GUID_ENCODER_GUIDE.md @@ -0,0 +1,175 @@ +# GUID Encoder Integration - Deployment Guide + +## Overview +Your Bicep infrastructure now includes a self-contained GUID encoder that converts GUIDs to Base64URL format during deployment. This eliminates the need for external API calls and ensures proper binary encoding of GUIDs. + +## What Was Updated + +### 1. New Module: `guid-encoder.bicep` +- **Location**: `infra/modules/guid-encoder.bicep` +- **Purpose**: Converts GUIDs to Base64URL encoded format using Azure deployment scripts +- **Method**: Direct binary conversion (no external API needed) +- **Implementation**: Bash script with proper little-endian byte ordering + +### 2. Updated Module: `app-registration.bicep` +- **New Parameters**: + - `location`: Resource location for deployment scripts + - `encodedTenantId`: Optional pre-encoded tenant ID (skips encoding script) + - `encodedAppId`: Optional pre-encoded app ID (skips encoding script) + +- **Removed Parameters**: + - `guidEncoderApiEndpoint`: No longer needed - encoding is self-contained + +- **New Logic**: + - Runs deployment script to encode Tenant ID if not pre-provided + - Runs deployment script to encode Application ID if not pre-provided + - Uses encoded values to construct federated credential subject + +## How It Works + +1. **Deployment starts** → Creates Entra ID Application +2. **Script 1**: Converts Tenant ID GUID to binary bytes → Base64URL encoding +3. **Script 2**: Converts App ID GUID to binary bytes → Base64URL encoding +4. **Constructs** federated credential subject: `/eid1/c/pub/t/{encodedTenantId}/a/{encodedAppId}/{uniqueId}` +5. **Creates** federated identity credential with proper subject + +### Binary Encoding Process +The script performs the same conversion as the C# `Guid.ToByteArray()` method: +- Removes hyphens from GUID string +- Converts to hexadecimal bytes with little-endian ordering +- Encodes bytes as Base64 +- Converts to Base64URL format (URL-safe: replaces `+` with `-`, `/` with `_`, removes `=`) + +## Deployment Options + +### Option 1: Automatic (Uses Deployment Scripts - Recommended) +```powershell +az deployment group create ` + --resource-group "rg-m365agent-dev" ` + --template-file infra/azure.bicep ` + --parameters resourceBaseName="m365agent" ` + botDisplayName="M365 Agent" ` + tenantId="671740f0-0ce9-4b51-bae5-4096de8b66d3" +``` + +### Option 2: Pre-calculated (Skips Scripts - Faster) +```powershell +# Pre-calculate values using PowerShell +$guid = [Guid]::Parse("671740f0-0ce9-4b51-bae5-4096de8b66d3") +$bytes = $guid.ToByteArray() +$base64 = [Convert]::ToBase64String($bytes) +$encodedTenantId = $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') + +# Deploy with pre-calculated values +az deployment group create ` + --resource-group "rg-m365agent-dev" ` + --template-file infra/azure.bicep ` + --parameters resourceBaseName="m365agent" ` + encodedTenantId=$encodedTenantId +``` + +## Deployment Script Details + +The `guid-encoder.bicep` module uses Azure Deployment Scripts: +- **Type**: Azure CLI (Bash) script +- **Runtime**: Azure CLI 2.52.0 +- **Retention**: 1 hour (auto-cleanup) +- **Timeout**: 5 minutes +- **Cost**: Minimal (uses Azure Container Instances briefly) +- **Encoding Method**: Proper binary conversion matching C# `Guid.ToByteArray()` + +## Important Notes + +### Performance +- **First deployment**: ~3-5 minutes (includes deployment script overhead) +- **Subsequent deployments**: Same duration (deployment scripts recreate each time) +- **Pre-calculated values**: Instant (no deployment script needed) + +### Advantages of Self-Contained Approach +✅ **No external dependencies** - Everything runs in Azure +✅ **Reliable** - No external API to fail or throttle +✅ **Secure** - GUIDs never leave your Azure environment +✅ **Proper encoding** - Binary conversion matches C# behavior +✅ **Cost-effective** - No need to maintain separate API service + +### Cost +- Deployment scripts create temporary Azure resources: + - Storage account (for script logs) + - Container instance (to run the script) +- Cost is minimal (~$0.01-0.02 per deployment) +- Resources are auto-deleted after 1 hour + +### Warnings (Can be ignored) +- `use-stable-resource-identifiers`: Using `utcNow()` is intentional to force script re-execution +- `no-unused-params`: The `fciSubject` parameter is kept for backward compatibility + +## Testing + +### Validate Bicep files +```powershell +# Validate guid-encoder module +az bicep build --file infra/modules/guid-encoder.bicep + +# Validate app-registration module +az bicep build --file infra/modules/app-registration.bicep + +# Validate main orchestration +az bicep build --file infra/azure.bicep +``` + +### What-if deployment +```powershell +az deployment group what-if ` + --resource-group "rg-m365agent-dev" ` + --template-file infra/azure.bicep ` + --parameters resourceBaseName="m365agent" +``` + +## Troubleshooting + +### Deployment script fails +- Check deployment script logs in Azure Portal +- Verify Azure CLI version 2.52.0 is available +- Ensure your subscription allows deployment scripts +- Check that `xxd` command is available (included in Azure CLI container) + +### Pre-calculate values to skip scripts +If deployment scripts are unavailable or failing: +```powershell +# PowerShell +$guid = [Guid]::Parse("671740f0-0ce9-4b51-bae5-4096de8b66d3") +$bytes = $guid.ToByteArray() +$base64 = [Convert]::ToBase64String($bytes) +$encodedTenantId = $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') + +# Deploy with pre-calculated value +az deployment group create ... --parameters encodedTenantId=$encodedTenantId +``` + +## Architecture + +``` +azure.bicep + └─> app-registration.bicep + ├─> guid-encoder.bicep (tenantId) → Bash Script → Base64URL + ├─> guid-encoder.bicep (appId) → Bash Script → Base64URL + └─> federatedCredential (uses encoded values) +``` + +## Next Steps + +1. ✅ Test API endpoint availability +2. ✅ Validate Bicep files compile +3. ✅ Run what-if deployment +4. ✅ Deploy to test resource group +5. ✅ Verify federated credential is created correctly +6. ✅ Test bot authentication + +## Support + +If you encounter issues: + +1. Check deployment logs in Azure Portal +2. Verify API is accessible and returning correct values +3. Try pre-calculating values to isolate API issues +4. Review deployment script execution logs diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/app-registration.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/app-registration.bicep new file mode 100644 index 00000000..f39aa1cc --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/app-registration.bicep @@ -0,0 +1,189 @@ +// Application Registration Module +// Required Role: Application Administrator or Cloud Application Administrator +// Deploys: Entra ID app registration, service principal, OAuth settings + +extension microsoftGraphV1 + +@description('Application name for the Entra ID app registration') +param aadAppName string + +@description('BotID this should match the Microsoft App ID in the Azure Bot Service Configuration') +param botId string + +@description('Tenant ID where the application will be registered') +param tenantId string + +@description('Pre-encoded tenant ID in Base64URL format (from guid-encoder module)') +param encodedTenantId string + +// Microsoft Entra ID Application Registration +// Note: identifierUris cannot be set on initial creation with appId reference +// Note: Retdirect URIs might vary based on your bot configuration +// Note: List of redirect URL : https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/azure-bot-user-authorization-federated-credentials#create-the-microsoft-entra-id-identity-provider +resource aadApplication 'Microsoft.Graph/applications@v1.0' = { + displayName: aadAppName + uniqueName: aadAppName + signInAudience: 'AzureADMyOrg' + identifierUris: [ + 'api://botid-${botId}' + ] + web: { + redirectUris: [ + 'https://token.botframework.com/.auth/web/redirect' + ] + implicitGrantSettings: { + enableIdTokenIssuance: false + enableAccessTokenIssuance: false + } + } + + api: { + requestedAccessTokenVersion: 2 + oauth2PermissionScopes: [ + { + id: guid(aadAppName, 'access_as_user') + adminConsentDescription: 'Default scope for Agent SSO access' + adminConsentDisplayName: 'Agent SSO' + userConsentDescription: 'Default scope for Agent SSO access' + userConsentDisplayName: 'Agent SSO' + value: 'access_as_user' + type: 'User' + isEnabled: true + } + ] + preAuthorizedApplications: [ + { + // Teams web client + appId: '1fec8e78-bce4-4aaf-ab1b-5451cc387264' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Teams desktop client + appId: '5e3ce6c0-2b1f-4285-8d4b-75ee78787346' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Microsoft 365 web application + appId: '4765445b-32c6-49b0-83e6-1d93765276ca' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Microsoft 365 desktop application + appId: '0ec893e0-5785-4de6-99da-4ed124e5296c' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Microsoft 365 mobile application Outlook desktop application + appId: 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Outlook web application + appId: 'bc59ab01-8403-45c6-8796-ac3ef710b3e3' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Outlook mobile application + appId: '27922004-5251-4030-b22d-91ecd9a37ea4' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + + ] + } + + requiredResourceAccess: [ + { + // OpenID permissions & offline_access + resourceAppId: '00000003-0000-0000-c000-000000000000' + resourceAccess: [ + { + // openid + id: '37f7f235-527c-4136-accd-4a02d197296e' + type: 'Scope' + } + { + // profile + id: '14dad69e-099b-42c9-810b-d002981feec1' + type: 'Scope' + } + { + // email + id: '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0' + type: 'Scope' + } + { + // offline_access + id: '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' + type: 'Scope' + } + ] + } + { + // Power Platform API + // Required for Copilot Studio (MCS) OBO token exchange + resourceAppId: '8578e004-a5c6-46e7-913e-12f58912df43' + resourceAccess: [ + { + // user_impersonation + id: 'cbd05a27-8576-45c3-add0-7b39ee43d6fc' + type: 'Scope' + } + { + // CopilotStudio.Copilots.Invoke + id: '204440d3-c1d0-4826-b570-99eb6f5e2aeb' + type: 'Scope' + } + ] + } + ] +} + +// Construct federated credential subject using pre-encoded tenant ID +// appId encode value is the Bot Service one. it is hardcoded on purpose. +var myfciSubject ='/eid1/c/pub/t/${encodedTenantId}/a/9ExAW52n_ky4ZiS_jhpJIQ/${guid(aadAppName, 'BotServiceOauthConnection')}' + +// Federated Identity Credential for Bot Framework token exchange +// This must be a separate resource as it's a child resource type +resource federatedCredential 'Microsoft.Graph/applications/federatedIdentityCredentials@v1.0' = { + name: '${aadApplication.uniqueName}/${guid(aadAppName, 'BotServiceOauthConnection')}' + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: '${environment().authentication.loginEndpoint}${tenantId}/v2.0' + subject: myfciSubject + description: 'Federated credential for Bot Framework token exchange' +} + +// Service Principal for the application +resource aadServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: aadApplication.appId + accountEnabled: true + displayName: aadAppName + servicePrincipalType: 'Application' + tags: [ + 'WindowsAzureActiveDirectoryIntegratedApp' + ] +} + +// Outputs for other modules +output aadAppId string = aadApplication.appId +output aadAppObjectId string = aadApplication.id +output aadAppIdUri string = 'api://botid-${botId}' +output servicePrincipalId string = aadServicePrincipal.id +output servicePrincipalObjectId string = aadServicePrincipal.id +output fciName string = federatedCredential.name +output fciSubject string = myfciSubject diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appinsights.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appinsights.bicep new file mode 100644 index 00000000..525f7931 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appinsights.bicep @@ -0,0 +1,73 @@ +// Application Insights Module with Managed Identity Support +// This module deploys Log Analytics Workspace and Application Insights +// Configured to use managed identity authentication (no instrumentation key needed) + +@description('Base name for resources') +param resourceBaseName string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('The managed identity principal ID that will access Application Insights') +param identityPrincipalId string + +@description('Application Insights application type') +@allowed([ + 'web' + 'other' +]) +param applicationType string = 'web' + +// Log Analytics Workspace (required for Application Insights) +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: '${resourceBaseName}-law' + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + workspaceCapping: { + dailyQuotaGb: 1 // Limit to 1GB per day to control costs + } + } +} + +// Application Insights +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: '${resourceBaseName}-ai' + location: location + kind: applicationType + properties: { + Application_Type: applicationType + WorkspaceResourceId: logAnalyticsWorkspace.id + IngestionMode: 'LogAnalytics' + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + DisableLocalAuth: false // Set to true to enforce managed identity only (more secure) + } +} + +// Grant Managed Identity "Monitoring Metrics Publisher" role on Application Insights +// This allows the bot to publish telemetry without using instrumentation key +var monitoringMetricsPublisherRoleId = '3913510d-42f4-4e42-8a64-420c390055eb' // Built-in role ID +resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(appInsights.id, identityPrincipalId, monitoringMetricsPublisherRoleId) + scope: appInsights + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', monitoringMetricsPublisherRoleId) + principalId: identityPrincipalId + principalType: 'ServicePrincipal' + } +} + +// Outputs +output appInsightsId string = appInsights.id +output appInsightsName string = appInsights.name +output appInsightsConnectionString string = appInsights.properties.ConnectionString +output appInsightsInstrumentationKey string = appInsights.properties.InstrumentationKey +output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id +output logAnalyticsWorkspaceName string = logAnalyticsWorkspace.name diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appservice.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appservice.bicep new file mode 100644 index 00000000..077d5081 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/appservice.bicep @@ -0,0 +1,201 @@ +// Azure App Service Module for Node.js 22 LTS Application +// This module deploys an App Service Plan and App Service with managed identity + +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@description('The resource ID of the User Assigned Managed Identity') +param MSIid string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('The name of the App Service Plan') +param serverfarmsName string = resourceBaseName + +@description('The name of the Web App') +param webAppName string = resourceBaseName + +@description('The SKU for the App Service Plan') +param webAppSKU string + +@description('Additional app settings for the Web App') +param additionalAppSettings array = [] + +@description('Enable Application Insights') +param enableAppInsights bool = true + +@description('Application Insights connection string (for managed identity authentication)') +param appInsightsConnectionString string = '' + +// Bot Configuration (for appsettings.json template variables) +@description('Bot ID (Managed Identity Client ID)') +param botId string + +@description('Bot Tenant ID') +param botTenantId string + +@description('OAuth Connection Name') +param oauthConnectionName string + +@description('MCS (Copilot Studio) OAuth Connection Name') +param mcsConnectionName string = 'mcs' + +// MCS Configuration +@description('MCS Environment ID') +param mcsEnvironmentId string = '' + +@description('MCS Agent Schema Name') +param mcsSchemaName string = '' + +// Azure OpenAI Configuration +@description('Azure OpenAI Endpoint') +param azureOpenAiEndpoint string = '' + +@description('Azure OpenAI Deployment Name') +param azureOpenAiDeployment string = 'gpt-4o' + +@secure() +@description('Azure OpenAI API Key') +param azureOpenAiApiKey string = '' + +@description('Azure OpenAI API Version') +param azureOpenAiApiVersion string = '2024-12-01-preview' + +// App Service Plan - Compute resources for your Web App +resource serverfarm 'Microsoft.Web/serverfarms@2023-12-01' = { + name: serverfarmsName + location: location + kind: 'linux' + sku: { + name: webAppSKU + } + properties: { + reserved: true // false = Windows, true = Linux + } +} + +// Web App that hosts your Node.js 22 LTS agent +resource webApp 'Microsoft.Web/sites@2023-12-01' = { + name: webAppName + location: location + kind: 'app,linux' + properties: { + serverFarmId: serverfarm.id + httpsOnly: true + clientAffinityEnabled: false + siteConfig: { + alwaysOn: true + http20Enabled: true + minTlsVersion: '1.2' + ftpsState: 'FtpsOnly' + linuxFxVersion: 'NODE|22-lts' + appCommandLine: 'node dist/index.js' + healthCheckPath: '/health' + logsDirectorySizeLimit: 100 + detailedErrorLoggingEnabled: true + httpLoggingEnabled: true + requestTracingEnabled: true + appSettings: concat([ + { + name: 'NODE_ENV' + value: 'production' + } + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'true' + } + { + name: 'ENABLE_ORYX_BUILD' + value: 'true' + } + { + name: 'clientId' // Lowercase - required by @microsoft/agents-hosting SDK + value: botId + } + { + name: 'tenantId' // Lowercase - required by @microsoft/agents-hosting SDK + value: botTenantId + } + // Application-specific configuration (used by src/config.ts and src/agent.ts) + { + name: 'MCS_CONNECTION_NAME' + value: mcsConnectionName + } + { + name: 'MCS_ENVIRONMENT_ID' + value: mcsEnvironmentId + } + { + name: 'MCS_SCHEMA_NAME' + value: mcsSchemaName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: azureOpenAiEndpoint + } + { + name: 'AZURE_OPENAI_DEPLOYMENT' + value: azureOpenAiDeployment + } + { + name: 'AZURE_OPENAI_API_KEY' + value: azureOpenAiApiKey + } + { + name: 'AZURE_OPENAI_API_VERSION' + value: azureOpenAiApiVersion + } + { + name: 'OAUTHCONNECTIONNAME' + value: oauthConnectionName + } + { + name: 'DEBUG' + value: 'agents:*:error' + } + ], enableAppInsights && !empty(appInsightsConnectionString) ? [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appInsightsConnectionString + } + { + name: 'ApplicationInsightsAgent_EXTENSION_VERSION' + value: '~3' + } + { + name: 'XDT_MicrosoftApplicationInsights_Mode' + value: 'recommended' + } + ] : [], additionalAppSettings) + cors: { + allowedOrigins: [ + 'https://portal.azure.com' + 'https://ms.portal.azure.com' + ] + supportCredentials: false + } + metadata: [ + { + name: 'CURRENT_STACK' + value: 'node' + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${MSIid}': {} + } + } +} + +// Outputs for use in other modules +output webAppName string = webApp.name +output webAppId string = webApp.id +output webAppHostName string = webApp.properties.defaultHostName +output webAppPrincipalId string = reference(MSIid, '2023-01-31').principalId +output appServicePlanId string = serverfarm.id diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot-local.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot-local.bicep new file mode 100644 index 00000000..ad0c739e --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot-local.bicep @@ -0,0 +1,55 @@ +// Azure Bot Service Module for Local Development +// Registers a bot with Single Tenant + Client Secret authentication +// Used for local development (without managed identity) + +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' + +@description('The bot application (client) ID from the bot app registration') +param botAppId string + +@description('The tenant ID for the bot application') +param botAppTenantId string + +@description('The bot messaging endpoint (e.g., https://abc123-5000.usw2.devtunnels.ms/api/messages)') +param botEndpoint string + +// Register your web service as a bot with the Bot Framework (Single Tenant mode) +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: botEndpoint + msaAppId: botAppId + msaAppTenantId: botAppTenantId + msaAppType: 'SingleTenant' // Using Single Tenant authentication + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} + +// Outputs +output botServiceName string = botService.name +output botServiceId string = botService.id +output botEndpoint string = botEndpoint diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot.bicep new file mode 100644 index 00000000..a5a27b8f --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/azurebot.bicep @@ -0,0 +1,42 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' +param identityResourceId string +param identityClientId string +param identityTenantId string +param botAppDomain string + +// Register your web service as a bot with the Bot Framework +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: 'https://${botAppDomain}/api/messages' + msaAppId: identityClientId + msaAppMSIResourceId: identityResourceId + msaAppTenantId:identityTenantId + msaAppType:'UserAssignedMSI' + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-app-registration.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-app-registration.bicep new file mode 100644 index 00000000..0e692b57 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-app-registration.bicep @@ -0,0 +1,51 @@ +// Bot App Registration Module for Local Development +// Creates an Entra ID app registration for bot authentication +// Used for local development with Single Tenant authentication +// Note: Client secret must be created manually in Azure Portal after deployment + +extension microsoftGraphV1 + +@description('Application name for the bot Entra ID app registration') +param appName string + +@description('Tenant ID where the application will be registered') +param tenantId string + +// Bot Application Registration (Single Tenant) +resource botApplication 'Microsoft.Graph/applications@v1.0' = { + displayName: appName + uniqueName: appName + signInAudience: 'AzureADMyOrg' // Single tenant + + // Bot-specific configuration + web: { + redirectUris: [] + implicitGrantSettings: { + enableIdTokenIssuance: false + enableAccessTokenIssuance: false + } + } + + // Required for bot authentication + requiredResourceAccess: [] +} + +// Service Principal for the bot application +resource botServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: botApplication.appId + accountEnabled: true + displayName: appName + servicePrincipalType: 'Application' + tags: [ + 'WindowsAzureActiveDirectoryIntegratedApp' + ] +} + +// Outputs +output appId string = botApplication.appId +output objectId string = botApplication.id +output servicePrincipalId string = botServicePrincipal.id +output tenantId string = tenantId + +// Note: Client secret must be created manually after deployment +// Navigate to Azure Portal → App Registrations → {appName} → Certificates & secrets → New client secret diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-managedidentity.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-managedidentity.bicep new file mode 100644 index 00000000..10c17f6f --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-managedidentity.bicep @@ -0,0 +1,16 @@ +@description('The name of the User Assigned Managed Identity to create.') +param identityName string +@description('Location for all resources.') +param location string = resourceGroup().location + + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + location: location + name: identityName +} + +// Outputs for use in other modules +output identityId string = identity.id +output identityName string = identity.name +output identityClientId string = identity.properties.clientId +output identityPrincipalId string = identity.properties.principalId diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-oauth-connection.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-oauth-connection.bicep new file mode 100644 index 00000000..44371fc5 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/bot-oauth-connection.bicep @@ -0,0 +1,65 @@ +// Bot OAuth Connection Module +// Configures Azure AD v2 OAuth connection with Federated Credentials for SSO +// This enables single sign-on (SSO) for the bot in Teams + +@description('The name of the Bot Service to configure') +param botServiceName string + +@description('The name for the OAuth connection setting') +param connectionName string = 'SsoConnection' + +@description('The Azure AD Application (client) ID from the app registration') +param aadAppId string + +@description('The Azure AD Application ID URI (e.g., api://botid-{guid})') +param aadAppIdUri string + +@description('The federated credential name (unique identifier from the federated credential)') +param federatedCredentialName string + +@description('OAuth scopes to request - should be the app ID URI with access_as_user scope') +param scopes string + +@description('The tenant ID for the Azure AD application') +param tenantId string + +@description('Location for the connection resource') +param location string = 'global' + +// Azure AD v2 OAuth Connection for Bot Service with Federated Credentials +// This enables SSO using federated credentials (no client secret needed) +// Uses the access_as_user scope defined in the app registration +resource botOAuthConnection 'Microsoft.BotService/botServices/connections@2022-09-15' = { + name: '${botServiceName}/${connectionName}' + location: location + properties: { + serviceProviderId: 'c00b44ab-5e16-c44c-af26-2fd5bc55eb18' // AAD v2 with Federated Credentials + serviceProviderDisplayName: 'AAD v2 with Federated Credentials' + clientId: aadAppId + scopes: scopes + parameters: [ + { + key: 'ClientId' + value: aadAppId + } + { + key: 'UniqueIdentifier' + value: federatedCredentialName + } + { + key: 'TokenExchangeUrl' + value: aadAppIdUri + } + { + key: 'TenantId' + value: tenantId + } + ] + } +} + +// Outputs +output connectionName string = connectionName +output connectionId string = botOAuthConnection.id +output settingId string = botOAuthConnection.properties.settingId +output provisioningState string = botOAuthConnection.properties.provisioningState diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/guid-encoder.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/guid-encoder.bicep new file mode 100644 index 00000000..460654bb --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/guid-encoder.bicep @@ -0,0 +1,66 @@ +// GUID Encoder Module +// Converts GUID to Base64URL encoded format using deployment script + +@description('The GUID to encode') +param guidToEncode string + +@description('Location for the deployment script') +param location string = resourceGroup().location + +@description('Timestamp to force script re-execution') +param utcValue string = utcNow() + +resource guidEncoderScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: 'guid-encoder-${uniqueString(guidToEncode, utcValue)}' + location: location + kind: 'AzureCLI' + properties: { + azCliVersion: '2.52.0' + retentionInterval: 'PT1H' + timeout: 'PT5M' + cleanupPreference: 'OnSuccess' + forceUpdateTag: utcValue + scriptContent: ''' + #!/bin/bash + set -e + + GUID_VALUE="$1" + + echo "Converting GUID: $GUID_VALUE" + + # Remove hyphens from GUID + GUID_NO_HYPHENS=$(echo "$GUID_VALUE" | tr -d '-') + + # Extract parts of the GUID + # GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + # Byte order needs to be adjusted for little-endian encoding + PART1="${GUID_NO_HYPHENS:0:8}" # First 8 chars (4 bytes) + PART2="${GUID_NO_HYPHENS:8:4}" # Next 4 chars (2 bytes) + PART3="${GUID_NO_HYPHENS:12:4}" # Next 4 chars (2 bytes) + PART4="${GUID_NO_HYPHENS:16:16}" # Last 16 chars (8 bytes) + + # Reverse byte order for first three parts (little-endian) + BYTES="" + BYTES+="${PART1:6:2}${PART1:4:2}${PART1:2:2}${PART1:0:2}" + BYTES+="${PART2:2:2}${PART2:0:2}" + BYTES+="${PART3:2:2}${PART3:0:2}" + BYTES+="$PART4" + + echo "Hex bytes: $BYTES" + + # Convert hex to binary and then to base64 + BASE64=$(echo "$BYTES" | xxd -r -p | base64) + + # Convert to Base64URL (remove padding, replace + with -, / with _) + BASE64URL=$(echo "$BASE64" | tr '+' '-' | tr '/' '_' | tr -d '=\n') + + echo "Base64URL encoded: $BASE64URL" + + # Output result as JSON + echo "{\"encodedGuid\":\"$BASE64URL\"}" > $AZ_SCRIPTS_OUTPUT_PATH + ''' + arguments: guidToEncode + } +} + +output encodedGuid string = guidEncoderScript.properties.outputs.encodedGuid diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/service-principal.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/service-principal.bicep new file mode 100644 index 00000000..ee2013a7 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/service-principal.bicep @@ -0,0 +1,24 @@ +// Service Principal Creation Module +// Creates a service principal for an existing App Registration +// This is required for the Bot Service to work with SingleTenant authentication + +extension microsoftGraphV1 + +@description('The App ID (Client ID) of the existing application registration') +param appId string + +// Create Service Principal for the existing application +// Note: Display name will automatically match the App Registration +resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: appId + accountEnabled: true + servicePrincipalType: 'Application' + tags: [ + 'WindowsAzureActiveDirectoryIntegratedApp' + ] +} + +// Outputs +output servicePrincipalId string = servicePrincipal.id +output servicePrincipalObjectId string = servicePrincipal.id +output appId string = servicePrincipal.appId diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/update-bot-endpoint.bicep b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/update-bot-endpoint.bicep new file mode 100644 index 00000000..f001cebc --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/infra/modules/update-bot-endpoint.bicep @@ -0,0 +1,42 @@ +// Module to update an existing Azure Bot Service endpoint +// This is used when the bot already exists and only the endpoint needs to be updated + +@description('The name of the existing bot service') +param botServiceName string + +@description('The new bot messaging endpoint (dev tunnel URL)') +param botEndpoint string + +@description('The bot application (client) ID') +param botAppId string + +@description('The tenant ID for the bot application') +param botAppTenantId string + +@description('The bot display name') +param botDisplayName string + +@description('The SKU for the Bot Service') +param botServiceSku string + +// Reference the existing bot service and update its properties +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: botEndpoint // This is the key property we're updating + msaAppId: botAppId + msaAppTenantId: botAppTenantId + msaAppType: 'SingleTenant' + } + sku: { + name: botServiceSku + } +} + +// Outputs +output botServiceName string = botService.name +output botServiceId string = botService.id +output botEndpoint string = botEndpoint diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.local.yml b/extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.local.yml new file mode 100644 index 00000000..e63e5188 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.local.yml @@ -0,0 +1,116 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.8/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.8 + +provision: + # Creates an app + - uses: teamsApp/create + with: + # app name + name: AzureAgentToM365ATK-${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + # Create or reuse an existing Microsoft Entra application for bot. + - uses: aadApp/create + with: + # The Microsoft Entra application's display name + name: AzureAgentToM365ATK-${{RESOURCE_SUFFIX}}-${{APP_NAME_SUFFIX}}-Bot + generateClientSecret: true + signInAudience: AzureADMyOrg + writeToEnvironmentFile: + # The Microsoft Entra application's client id created for bot. + clientId: BOT_ID + # The Microsoft Entra application's client secret created for bot. + clientSecret: SECRET_BOT_PASSWORD + # The Microsoft Entra application's object id created for bot. + objectId: BOT_OBJECT_ID + + # Deploy Azure infrastructure for local development (Bot Service + OAuth Connection) + # This creates: SSO App Registration + Azure Bot Service + OAuth Connection + - uses: arm/deploy + with: + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure-local.bicep + parameters: ./infra/azure-local.parameters.json + deploymentName: Deploy-local-bot-infrastructure + bicepCliVersion: v0.38.33 + # Note: arm/deploy outputs are automatically captured in ARM_OUTPUTS variable + # We extract them and write to environment file in the next step + + # Validate using manifest schema - TEMPORARILY DISABLED due to Microsoft schema server issues + # - uses: teamsApp/validateManifest + # with: + # # Path to manifest template + # manifestPath: ./appPackage/manifest.json + + # Build app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + + # Apply the app manifest to an existing app in + # Developer Portal. + # Will use the app id in manifest file to determine which app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID + +deploy: + # Run npm command + - uses: cli/runNpmCommand + name: install dependencies + with: + args: install --no-audit + + # Generate runtime appsettings to JSON file + - uses: file/createOrUpdateEnvironmentFile + with: + target: ./.localConfigs + envs: + # Bot auth (SDK 1.1.1 hierarchical format) + connections__serviceConnection__settings__clientId: ${{BOT_ID}} + connections__serviceConnection__settings__clientSecret: ${{SECRET_BOT_PASSWORD}} + connections__serviceConnection__settings__tenantId: ${{TEAMS_APP_TENANT_ID}} + connectionsMap__0__connection: serviceConnection + connectionsMap__0__serviceUrl: "*" + # Also keep flat format for backward compat + clientId: ${{BOT_ID}} + tenantId: ${{TEAMS_APP_TENANT_ID}} + clientSecret: ${{SECRET_BOT_PASSWORD}} + # MCS auth handler — SDK reads {handlerId}_connectionName + MCS_connectionName: ${{MCS_CONNECTION_NAME}} + # MCS config + MCS_CONNECTION_NAME: ${{MCS_CONNECTION_NAME}} + MCS_ENVIRONMENT_ID: ${{MCS_ENVIRONMENT_ID}} + MCS_SCHEMA_NAME: ${{MCS_SCHEMA_NAME}} + # Azure OpenAI + AZURE_OPENAI_ENDPOINT: ${{AZURE_OPENAI_ENDPOINT}} + AZURE_OPENAI_DEPLOYMENT: ${{AZURE_OPENAI_DEPLOYMENT}} + AZURE_OPENAI_API_KEY: ${{SECRET_AZURE_OPENAI_API_KEY}} + DEBUG: agents:* \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.yml b/extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.yml new file mode 100644 index 00000000..7f72e435 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/m365agents.yml @@ -0,0 +1,131 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.8/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.8 + +environmentFolderPath: ./env + +additionalMetadata: + sampleTag: microsoft-365-agents-toolkit-samples:ProxyAgent-NodeJS + +# Triggered when 'teamsapp provision' is executed +provision: + # Creates an app + - uses: teamsApp/create + with: + # app name + name: AzureAgentToM365ATK${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + - uses: arm/deploy # Deploy given ARM templates parallelly. + with: + # AZURE_SUBSCRIPTION_ID is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select a subscription. + # Referencing other environment variables with empty values + # will skip the subscription selection prompt. + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + # AZURE_RESOURCE_GROUP_NAME is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select or create one + # resource group. + # Referencing other environment variables with empty values + # will skip the resource group selection prompt. + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure.bicep # Relative path to this file + # Relative path to this yaml file. + # Placeholders will be replaced with corresponding environment + # variable before ARM deployment. + parameters: ./infra/azure.parameters.json + # Required when deploying ARM template + deploymentName: Create-resources-for-bot + # Microsoft 365 Agents Toolkit will download this bicep CLI version from github for you, + # will use bicep CLI in PATH if you remove this config. + bicepCliVersion: v0.38.33 + + # Validate using manifest schema - TEMPORARILY DISABLED due to Microsoft schema server issues + # - uses: teamsApp/validateManifest + # with: + # # Path to manifest template + # manifestPath: ./appPackage/manifest.json + + # Build app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the app manifest to an existing app in + # Developer Portal. + # Will use the app id in manifest file to determine which app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID + +# Triggered when 'teamsapp deploy' is executed +deploy: + # Deploy source code - Azure will build via Oryx (needs SCM_DO_BUILD_DURING_DEPLOYMENT=true) + - uses: azureAppService/zipDeploy + with: + # Deploy base folder + artifactFolder: . + # Ignore file location + ignoreFile: .webappignore + # The resource id of the cloud resource to be deployed to. + resourceId: ${{WEBAPPID}} + +# Triggered when 'teamsapp publish' is executed +publish: + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + # Build app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the app manifest to an existing app in + # Developer Portal. + # Will use the app id in manifest file to determine which app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Publish the app to + # Teams Admin Center (https://admin.teams.microsoft.com/policies/manage-apps) + # for review and approval + - uses: teamsApp/publishAppPackage + with: + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + publishedAppId: TEAMS_APP_PUBLISHED_APP_ID +projectId: dbd0c86c-28b8-490d-85c0-c681b372f990 diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/package.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/package.json new file mode 100644 index 00000000..cb0ed4e7 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/package.json @@ -0,0 +1,48 @@ +{ + "name": "azureagenttom365atk", + "version": "1.0.0", + "msteams": { + "teamsAppId": null + }, + "description": "Proxy Agent to connect existing AI Solutions to M365 Copilot using the M365 Agents SDK", + "engines": { + "node": "22 || 24" + }, + "author": "Microsoft", + "license": "MIT", + "main": "./dist/index.js", + "scripts": { + "clean": "node -e \"require('fs').rmSync('dist', {recursive:true, force:true})\"", + "prebuild": "npm run clean", + "build": "tsc", + "dev:teamsfx": "env-cmd --silent -f .localConfigs npm run dev", + "dev": "nodemon --signal SIGINT --exec \"node --inspect=9239 --require ts-node/register\" ./src/index.ts", + "start": "node ./dist/index.js", + "smoke": "node scripts/smoke-test.mjs", + "test": "echo \"Error: no test specified\" && exit 1", + "watch": "tsc --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com" + }, + "dependencies": { + "@langchain/core": "^1.1.32", + "@langchain/langgraph": "^1.2.2", + "@langchain/openai": "^1.2.13", + "@microsoft/agents-activity": "^1.1.1", + "@microsoft/agents-copilotstudio-client": "^1.3.1", + "@microsoft/agents-hosting": "^1.1.1", + "@microsoft/agents-hosting-express": "^1.1.1", + "langchain": "^1.2.32", + "typescript": "^5.9.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/express": "^5.0.5", + "@types/node": "^22.10.2", + "env-cmd": "^10.1.0", + "nodemon": "^3.1.7", + "ts-node": "^10.9.2" + } +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 new file mode 100644 index 00000000..f74c95ee --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + One-command deploy for the M365 LangGraph MCS Tool sample. + +.DESCRIPTION + Checks prerequisites, collects the handful of values the sample needs, + writes them to env/.env.dev (+ secrets to env/.env.dev.user), then runs + `atk provision` and `atk deploy` to stand up the Azure resources and push + the bot. When it finishes it tells you how to install the app package. + + Any value already present (as an environment variable or already in + env/.env.dev) is reused, so re-running is idempotent and CI can pre-seed. + +.EXAMPLE + ./scripts/deploy.ps1 +.EXAMPLE + ./scripts/deploy.ps1 -EnvName dev +#> +[CmdletBinding()] +param( + [string]$EnvName = $(if ($env:ENV) { $env:ENV } else { 'dev' }) +) + +$ErrorActionPreference = 'Stop' +$ProjectDir = Split-Path -Parent $PSScriptRoot +$EnvFile = Join-Path $ProjectDir "env/.env.$EnvName" +$SecretFile = Join-Path $ProjectDir "env/.env.$EnvName.user" +Set-Location $ProjectDir + +function Write-Head($m) { Write-Host $m -ForegroundColor Cyan } +function Write-Info($m) { Write-Host " $m" } +function Fail($m) { Write-Host "Error: $m" -ForegroundColor Red; exit 1 } + +Write-Head "M365 LangGraph MCS Tool - deploy (env: $EnvName)" + +# --- 1. Prerequisites ------------------------------------------------------- +Write-Head "1. Checking prerequisites" +function Need($cmd, $hint) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { Fail "$cmd not found. $hint" } +} +Need node "Install Node.js 22 or 24: https://nodejs.org" +Need npm "npm ships with Node.js: https://nodejs.org" +Need atk "Install the M365 Agents Toolkit CLI: npm install -g @microsoft/m365agentstoolkit-cli" +$nodeMajor = [int](node -p "process.versions.node.split('.')[0]") +if ($nodeMajor -lt 22) { Fail "Node.js $nodeMajor detected; this sample requires Node 22 or 24." } +Write-Info "node $(node -v), npm $(npm -v), atk $((atk --version 2>$null | Select-Object -First 1))" + +# --- helpers to read/write .env files -------------------------------------- +function Read-Env($key, $file) { + if (-not (Test-Path $file)) { return '' } + $line = Select-String -Path $file -Pattern "^$([regex]::Escape($key))=" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $line) { return '' } + return ($line.Line -replace "^$([regex]::Escape($key))=", '') +} +function Upsert-Env($key, $value, $file) { + $dir = Split-Path -Parent $file + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + if (-not (Test-Path $file)) { New-Item -ItemType File -Path $file -Force | Out-Null } + $lines = @(Get-Content -Path $file) + $out = New-Object System.Collections.Generic.List[string] + $found = $false + foreach ($l in $lines) { + if ($l -match "^$([regex]::Escape($key))=") { $out.Add("$key=$value"); $found = $true } + else { $out.Add($l) } + } + if (-not $found) { $out.Add("$key=$value") } + Set-Content -Path $file -Value $out +} +function Prompt-Value($key, $prompt, $default, [switch]$Secret) { + $cur = [Environment]::GetEnvironmentVariable($key) + if ([string]::IsNullOrEmpty($cur)) { $cur = Read-Env $key $EnvFile } + if ([string]::IsNullOrEmpty($cur)) { $cur = Read-Env $key $SecretFile } + if ([string]::IsNullOrEmpty($cur)) { + if ($Secret) { + $sec = Read-Host -AsSecureString " $prompt" + $cur = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)) + } else { + $label = if ($default) { " $prompt [$default]" } else { " $prompt" } + $cur = Read-Host $label + if ([string]::IsNullOrEmpty($cur)) { $cur = $default } + } + } + return $cur +} + +# --- 2. Collect configuration ---------------------------------------------- +Write-Head "2. Collecting configuration (press Enter to accept a shown default)" +$McsEnvId = Prompt-Value 'MCS_ENVIRONMENT_ID' 'Copilot Studio environment ID (GUID)' '' +if (-not $McsEnvId) { Fail "MCS_ENVIRONMENT_ID is required." } +$McsSchema = Prompt-Value 'MCS_SCHEMA_NAME' 'Copilot Studio agent schema name (e.g. cr123_myAgent)' '' +if (-not $McsSchema) { Fail "MCS_SCHEMA_NAME is required." } +$AoaiEndpoint = Prompt-Value 'AZURE_OPENAI_ENDPOINT' 'Azure OpenAI endpoint (https://.openai.azure.com/)' '' +if (-not $AoaiEndpoint) { Fail "AZURE_OPENAI_ENDPOINT is required." } +$AoaiDeploy = Prompt-Value 'AZURE_OPENAI_DEPLOYMENT' 'Azure OpenAI deployment name' 'gpt-4o' +$AoaiKey = Prompt-Value 'SECRET_AZURE_OPENAI_API_KEY' 'Azure OpenAI API key' '' -Secret +if (-not $AoaiKey) { Fail "Azure OpenAI API key is required." } + +$SubId = Prompt-Value 'AZURE_SUBSCRIPTION_ID' 'Azure subscription ID (blank = choose during provision)' '' +$RgName = Prompt-Value 'AZURE_RESOURCE_GROUP_NAME' 'Azure resource group (blank = choose/create during provision)' '' + +$ResSuffix = Read-Env 'RESOURCE_SUFFIX' $EnvFile +if (-not $ResSuffix) { + $ResSuffix = -join ((48..57) + (97..122) | Get-Random -Count 6 | ForEach-Object { [char]$_ }) + Write-Info "Generated RESOURCE_SUFFIX=$ResSuffix" +} + +# --- 3. Persist to env files ----------------------------------------------- +Write-Head "3. Writing env/.env.$EnvName and env/.env.$EnvName.user" +Upsert-Env 'TEAMSFX_ENV' $EnvName $EnvFile +Upsert-Env 'RESOURCE_SUFFIX' $ResSuffix $EnvFile +Upsert-Env 'AZURE_SUBSCRIPTION_ID' $SubId $EnvFile +Upsert-Env 'AZURE_RESOURCE_GROUP_NAME' $RgName $EnvFile +Upsert-Env 'MCS_CONNECTION_NAME' 'mcs' $EnvFile +Upsert-Env 'MCS_ENVIRONMENT_ID' $McsEnvId $EnvFile +Upsert-Env 'MCS_SCHEMA_NAME' $McsSchema $EnvFile +Upsert-Env 'AZURE_OPENAI_ENDPOINT' $AoaiEndpoint $EnvFile +Upsert-Env 'AZURE_OPENAI_DEPLOYMENT' $AoaiDeploy $EnvFile +Upsert-Env 'SECRET_AZURE_OPENAI_API_KEY' $AoaiKey $SecretFile +Write-Info "Secrets written to env/.env.$EnvName.user (git-ignored)." + +# --- 4. Build -------------------------------------------------------------- +Write-Head "4. Installing dependencies and building" +npm install +npm run build + +# --- 5. Provision + deploy ------------------------------------------------- +Write-Head "5. Provisioning Azure resources (atk provision)" +Write-Info "You may be prompted to sign in to Azure and Microsoft 365." +atk provision --env $EnvName + +Write-Head "6. Deploying the bot (atk deploy)" +atk deploy --env $EnvName + +# --- Done ------------------------------------------------------------------ +$Pkg = "appPackage/build/appPackage.$EnvName.zip" +Write-Head "Done. Next steps" +Write-Info "1. Install the app package: $Pkg" +Write-Info " - Teams: Apps -> Manage your apps -> Upload an app -> Upload a custom app" +Write-Info " - Or run: atk install --file-path $Pkg --env $EnvName" +Write-Info "2. Open the agent in Teams / Microsoft 365 Copilot and say hello." +Write-Info "3. First message triggers a one-time sign-in (delegated Copilot Studio access)." diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh new file mode 100755 index 00000000..25fc2b07 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# One-command deploy for the M365 LangGraph MCS Tool sample. +# +# It checks prerequisites, collects the handful of values the sample needs, +# writes them to env/.env.dev (+ secrets to env/.env.dev.user), then runs +# `atk provision` and `atk deploy` to stand up the Azure resources and push +# the bot. When it finishes it tells you how to install the app package. +# +# Usage: scripts/deploy.sh # interactive (prompts for anything missing) +# ENV=dev scripts/deploy.sh # target a different toolkit environment +# +# Any value already present (in the environment or in env/.env.dev) is reused, +# so re-running is idempotent and CI can pre-seed everything. + +set -euo pipefail + +ENV_NAME="${ENV:-dev}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ENV_FILE="${PROJECT_DIR}/env/.env.${ENV_NAME}" +SECRET_FILE="${PROJECT_DIR}/env/.env.${ENV_NAME}.user" + +cd "${PROJECT_DIR}" + +bold() { printf '\033[1m%s\033[0m\n' "$1"; } +info() { printf ' %s\n' "$1"; } +fail() { printf '\033[31mError:\033[0m %s\n' "$1" >&2; exit 1; } + +bold "M365 LangGraph MCS Tool — deploy (env: ${ENV_NAME})" + +# --- 1. Prerequisites ------------------------------------------------------- +bold "1. Checking prerequisites" +need() { command -v "$1" >/dev/null 2>&1 || fail "$1 not found. $2"; } +need node "Install Node.js 22 or 24: https://nodejs.org" +need npm "npm ships with Node.js: https://nodejs.org" +need atk "Install the M365 Agents Toolkit CLI: npm install -g @microsoft/m365agentstoolkit-cli" +NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" +if [ "${NODE_MAJOR}" -lt 22 ]; then + fail "Node.js ${NODE_MAJOR} detected; this sample requires Node 22 or 24." +fi +info "node $(node -v), npm $(npm -v), atk $(atk --version 2>/dev/null | head -n1)" + +# --- helpers to read/write .env files -------------------------------------- +read_env() { # read_env KEY FILE -> prints value (may be empty) + [ -f "$2" ] || { printf ''; return; } + sed -n "s/^$1=//p" "$2" | head -n1 +} +upsert_env() { # upsert_env KEY VALUE FILE + local key="$1" val="$2" file="$3" tmp + mkdir -p "$(dirname "${file}")" + touch "${file}" + if grep -q "^${key}=" "${file}"; then + tmp="$(mktemp)" + # replace the whole line; value is written literally + awk -v k="${key}" -v v="${val}" 'BEGIN{FS=OFS="="} + $1==k {print k "=" v; next} {print}' "${file}" >"${tmp}" + mv "${tmp}" "${file}" + else + printf '%s=%s\n' "${key}" "${val}" >>"${file}" + fi +} + +# prompt_value KEY PROMPT DEFAULT [secret] +prompt_value() { + local key="$1" prompt="$2" def="$3" secret="${4:-}" + # precedence: existing shell env var > value already in the file > prompt + local cur="${!key:-}" + [ -n "${cur}" ] || cur="$(read_env "${key}" "${ENV_FILE}")" + [ -n "${cur}" ] || cur="$(read_env "${key}" "${SECRET_FILE}")" + if [ -z "${cur}" ]; then + if [ ! -t 0 ]; then + [ -n "${def}" ] && cur="${def}" || fail "${key} is required but no TTY is available to prompt." + elif [ "${secret}" = "secret" ]; then + read -r -s -p " ${prompt}: " cur; echo + else + read -r -p " ${prompt}${def:+ [${def}]}: " cur + [ -n "${cur}" ] || cur="${def}" + fi + fi + printf '%s' "${cur}" +} + +# --- 2. Collect configuration ---------------------------------------------- +bold "2. Collecting configuration (press Enter to accept a shown default)" + +MCS_ENVIRONMENT_ID="$(prompt_value MCS_ENVIRONMENT_ID 'Copilot Studio environment ID (GUID)' '')" +[ -n "${MCS_ENVIRONMENT_ID}" ] || fail "MCS_ENVIRONMENT_ID is required." +MCS_SCHEMA_NAME="$(prompt_value MCS_SCHEMA_NAME 'Copilot Studio agent schema name (e.g. cr123_myAgent)' '')" +[ -n "${MCS_SCHEMA_NAME}" ] || fail "MCS_SCHEMA_NAME is required." +AZURE_OPENAI_ENDPOINT="$(prompt_value AZURE_OPENAI_ENDPOINT 'Azure OpenAI endpoint (https://.openai.azure.com/)' '')" +[ -n "${AZURE_OPENAI_ENDPOINT}" ] || fail "AZURE_OPENAI_ENDPOINT is required." +AZURE_OPENAI_DEPLOYMENT="$(prompt_value AZURE_OPENAI_DEPLOYMENT 'Azure OpenAI deployment name' 'gpt-4o')" +SECRET_AZURE_OPENAI_API_KEY="$(prompt_value SECRET_AZURE_OPENAI_API_KEY 'Azure OpenAI API key' '' secret)" +[ -n "${SECRET_AZURE_OPENAI_API_KEY}" ] || fail "Azure OpenAI API key is required." + +# Optional Azure targeting — leave empty to let atk prompt you. +AZURE_SUBSCRIPTION_ID="$(prompt_value AZURE_SUBSCRIPTION_ID 'Azure subscription ID (blank = choose during provision)' '')" +AZURE_RESOURCE_GROUP_NAME="$(prompt_value AZURE_RESOURCE_GROUP_NAME 'Azure resource group (blank = choose/create during provision)' '')" + +# Globally-unique suffix for resource names — generate one if not set. +RESOURCE_SUFFIX="$(read_env RESOURCE_SUFFIX "${ENV_FILE}")" +if [ -z "${RESOURCE_SUFFIX}" ]; then + RESOURCE_SUFFIX="$(LC_ALL=C tr -dc 'a-z0-9' &1 + + if ($LASTEXITCODE -ne 0 -or $loginStatus -like "*not logged in*" -or $loginStatus -like "*expired*") { + Write-Host "Login required or token expired. Logging in to Dev Tunnels..." + devtunnel user login + + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to login to Dev Tunnels. Please try again." + exit 1 + } + Write-Host "Successfully logged in to Dev Tunnels." + } else { + Write-Host "Already logged in to Dev Tunnels." + } +} + +$tunnelId = "" +$envFile = ".\env\.env.local" +$envFileContent = Get-Content $envFile +$envFileContent | ForEach-Object { + if ($_ -like "TUNNEL_ID=*") { + $tunnelId = $_.Split("=")[1].Trim() + } +} + +if ($tunnelId -eq "") { + Write-Host "No TUNNEL_ID found. Creating tunnel..." + + Ensure-DevTunnelLogin + + Write-Host "Creating tunnel..." + $tunnel = devtunnel.exe create + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to create tunnel." + exit 1 + } + + $tunnelId = $tunnel -split '\r?\n' | Select-String 'Tunnel ID' | ForEach-Object { ($_ -split ':')[1].Trim() } + + Write-Host "Creating port and access..." + $port = 3978 + devtunnel port create $tunnelId -p $port > $null + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to create port." + exit 1 + } + + devtunnel access create $tunnelId -p $port -a > $null + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to create access." + exit 1 + } + + Write-Host "Updating env\.env.local..." + + $hostname = $tunnelId.split('.')[0] + $cluster = $tunnelId.split('.')[1] + + $domain = "$hostname-$port.$cluster.devtunnels.ms" + $endpoint = "https://$domain" + + $envFileContent | ForEach-Object { + $line = $_ + if ($line -like "BOT_ENDPOINT=*") { + $line = "BOT_ENDPOINT=$endpoint/api/messages" + } + if ($line -like "BOT_DOMAIN=*") { + $line = "BOT_DOMAIN=$domain" + } + if ($line -like "TUNNEL_ID=*") { + $line = "TUNNEL_ID=$tunnelId" + } + $line + } | Set-Content $envFile + + Write-Host "TUNNEL_ID: $tunnelId" + Write-Host "BOT_ENDPOINT: $endpoint" + Write-Host "BOT_DOMAIN: $domain" +} else { + Write-Host "Found existing TUNNEL_ID: $tunnelId" + Ensure-DevTunnelLogin +} + +Write-Host "Starting tunnel host..." +devtunnel.exe host $tunnelId + +if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to host tunnel. This might be due to:" + Write-Host " - Expired login token (try deleting TUNNEL_ID from .env.local)" + Write-Host " - Tunnel no longer exists (delete TUNNEL_ID from .env.local to create new one)" + Write-Host " - Network connectivity issues" + exit 1 +} \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/devtunnel.sh b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/devtunnel.sh new file mode 100755 index 00000000..58a87b6a --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/devtunnel.sh @@ -0,0 +1,115 @@ +#!/bin/bash + +# Ensure ~/bin is in PATH for devtunnel +export PATH="$HOME/bin:$PATH" + +exe=$(which devtunnel) +if [ $? -ne 0 ]; then + echo "Dev Tunnels CLI not found. Please install: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started" + exit 1 +fi + +# Function to check and ensure login +ensure_devtunnel_login() { + echo "Checking Dev Tunnels login status..." + loginStatus=$(devtunnel user show 2>&1) + + if [ $? -ne 0 ] || [[ $loginStatus == *"not logged in"* ]] || [[ $loginStatus == *"expired"* ]]; then + echo "Login required or token expired. Logging in to Dev Tunnels..." + devtunnel user login + + if [ $? -ne 0 ]; then + echo "Failed to login to Dev Tunnels. Please try again." + exit 1 + fi + echo "Successfully logged in to Dev Tunnels." + else + echo "Already logged in to Dev Tunnels." + fi +} + +tunnelId="" +envFile="env/.env.local" + +while IFS= read -r line; do + if [[ $line == TUNNEL_ID=* ]]; then + tunnelId="${line#*=}" + fi +done <"$envFile" + +if [ -z "$tunnelId" ]; then + echo "No TUNNEL_ID found. Creating tunnel..." + + ensure_devtunnel_login + + echo "Creating tunnel..." + tunnel=$(devtunnel create) + if [ $? -ne 0 ]; then + echo "Failed to create tunnel." + exit 1 + fi + + tunnelId=$(echo "$tunnel" | grep 'Tunnel ID' | cut -d ':' -f2 | xargs) + + echo "Creating port and access..." + port=3978 + devtunnel port create $tunnelId -p $port + if [ $? -ne 0 ]; then + echo "Failed to create port." + exit 1 + fi + + devtunnel access create $tunnelId -p $port -a + if [ $? -ne 0 ]; then + echo "Failed to create access." + exit 1 + fi + + echo "Updating env/.env.local..." + + hostname=$(echo $tunnelId | cut -d '.' -f1) + cluster=$(echo $tunnelId | cut -d '.' -f2) + + domain="$hostname-$port.$cluster.devtunnels.ms" + endpoint="https://$domain" + + # read file into an array + lines=() + while IFS= read -r line; do + lines+=("$line") + done <"$envFile" + + # update lines + for i in "${!lines[@]}"; do + if [[ ${lines[i]} == BOT_ENDPOINT=* ]]; then + lines[i]="BOT_ENDPOINT=$endpoint/api/messages" + fi + if [[ ${lines[i]} == BOT_DOMAIN=* ]]; then + lines[i]="BOT_DOMAIN=$domain" + fi + if [[ ${lines[i]} == TUNNEL_ID=* ]]; then + lines[i]="TUNNEL_ID=$tunnelId" + fi + done + + # write array to file + printf "%s\n" "${lines[@]}" >"$envFile" + + echo "TUNNEL_ID: $tunnelId" + echo "BOT_ENDPOINT: $endpoint" + echo "BOT_DOMAIN: $domain" +else + echo "Found existing TUNNEL_ID: $tunnelId" + ensure_devtunnel_login +fi + +echo "Starting tunnel host..." +devtunnel host $tunnelId + +if [ $? -ne 0 ]; then + echo "Failed to host tunnel. This might be due to:" + echo " - Expired login token (try deleting TUNNEL_ID from .env.local)" + echo " - Tunnel no longer exists (delete TUNNEL_ID from .env.local to create new one)" + echo " - Network connectivity issues" + exit 1 +fi \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/env.js b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/env.js new file mode 100644 index 00000000..bcf3d205 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/env.js @@ -0,0 +1,52 @@ +const fs = require("fs"); +const path = require("path"); + +console.log("Ensuring env files exist..."); + +const envPath = path.join(__dirname, "..", "env"); +const envs = [ + { + name: ".env.local", + requiredVars: ["TEAMSFX_ENV", "TUNNEL_ID", "BOT_ENDPOINT", "BOT_DOMAIN", "SSO_APP_ID", "MCS_CONNECTION_NAME", "MCS_ENVIRONMENT_ID", "MCS_SCHEMA_NAME", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT"], + content: `TEAMSFX_ENV=local\nTUNNEL_ID=\nBOT_ENDPOINT=\nBOT_DOMAIN=\nSSO_APP_ID=00000000-0000-0000-0000-000000000000\nMCS_CONNECTION_NAME=mcs\nMCS_ENVIRONMENT_ID=\nMCS_SCHEMA_NAME=\nAZURE_OPENAI_ENDPOINT=\nAZURE_OPENAI_DEPLOYMENT=gpt-4o`, + } +]; + +envs.forEach((env) => { + const envFilePath = path.join(envPath, env.name); + + if (!fs.existsSync(envFilePath)) { + // Create new file + fs.mkdirSync(envPath, { recursive: true }); + fs.writeFileSync(envFilePath, env.content); + console.log(`Created ${env.name}`); + } else { + // Check and add missing variables to existing file + let content = fs.readFileSync(envFilePath, "utf8"); + let modified = false; + + env.requiredVars.forEach((varName) => { + const regex = new RegExp(`^${varName}=(.*)$`, "m"); + const match = content.match(regex); + + if (!match) { + // Variable doesn't exist, add it with default value + const defaultValue = varName === "SSO_APP_ID" ? "00000000-0000-0000-0000-000000000000" : ""; + content += `\n${varName}=${defaultValue}`; + modified = true; + console.log(`Added ${varName} to ${env.name}`); + } else if (varName === "SSO_APP_ID" && match[1].trim() === "") { + // SSO_APP_ID exists but is empty, set default GUID + content = content.replace(regex, `${varName}=00000000-0000-0000-0000-000000000000`); + modified = true; + console.log(`Set default GUID for ${varName} in ${env.name}`); + } + }); + + if (modified) { + fs.writeFileSync(envFilePath, content); + } + } +}); + +console.log("Done!"); \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/guid-encoder.js b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/guid-encoder.js new file mode 100644 index 00000000..70cff80e --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/guid-encoder.js @@ -0,0 +1,132 @@ +/** + * GUID Encoder - Converts GUID to Base64URL encoded format + * + * This script converts a GUID (UUID) to Base64URL encoding using little-endian + * byte order for the first three parts (matching .NET GUID structure). + * + * Usage: + * node guid-encoder.js + * + * Example: + * node guid-encoder.js "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + */ + +const crypto = require('crypto'); + +/** + * Converts a GUID string to Base64URL encoded format + * @param {string} guid - The GUID to encode (with or without hyphens) + * @returns {string} Base64URL encoded GUID + */ +function encodeGuidToBase64Url(guid) { + // Remove hyphens and convert to lowercase + const guidNoDashes = guid.replace(/-/g, '').toLowerCase(); + + // Validate GUID format (32 hex characters) + if (!/^[0-9a-f]{32}$/i.test(guidNoDashes)) { + throw new Error(`Invalid GUID format: ${guid}`); + } + + // Extract parts of the GUID + // GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + // Byte order needs to be adjusted for little-endian encoding + const part1 = guidNoDashes.substring(0, 8); // First 8 chars (4 bytes) + const part2 = guidNoDashes.substring(8, 12); // Next 4 chars (2 bytes) + const part3 = guidNoDashes.substring(12, 16); // Next 4 chars (2 bytes) + const part4 = guidNoDashes.substring(16, 32); // Last 16 chars (8 bytes) + + // Reverse byte order for first three parts (little-endian) + // This matches how .NET stores GUIDs in memory + let hexBytes = ''; + + // Part 1: Reverse 4 bytes (8 hex chars) + hexBytes += part1.substring(6, 8) + part1.substring(4, 6) + + part1.substring(2, 4) + part1.substring(0, 2); + + // Part 2: Reverse 2 bytes (4 hex chars) + hexBytes += part2.substring(2, 4) + part2.substring(0, 2); + + // Part 3: Reverse 2 bytes (4 hex chars) + hexBytes += part3.substring(2, 4) + part3.substring(0, 2); + + // Part 4: Keep original order (big-endian) + hexBytes += part4; + + // Convert hex string to Buffer + const buffer = Buffer.from(hexBytes, 'hex'); + + // Convert to base64 + const base64 = buffer.toString('base64'); + + // Convert to Base64URL format: + // - Replace + with - + // - Replace / with _ + // - Remove padding (=) + const base64Url = base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + return base64Url; +} + +/** + * Main execution + */ +function main() { + // Get GUID from command line arguments + const guid = process.argv[2]; + const quietMode = process.argv.includes('--quiet') || process.argv.includes('-q'); + + if (!guid) { + console.error('Error: GUID argument is required'); + console.error(''); + console.error('Usage: node guid-encoder.js [--quiet]'); + console.error(''); + console.error('Example:'); + console.error(' node guid-encoder.js "a1b2c3d4-e5f6-7890-abcd-ef1234567890"'); + console.error(' node guid-encoder.js "a1b2c3d4-e5f6-7890-abcd-ef1234567890" --quiet'); + process.exit(1); + } + + try { + // Suppress debug output in quiet mode + if (!quietMode) { + console.log(`Converting GUID: ${guid}`); + } + + const encoded = encodeGuidToBase64Url(guid); + + if (quietMode) { + // Quiet mode: Only output EncodedTenantID=xxxx + console.log(`EncodedTenantID=${encoded}`); + } else { + // Verbose mode: Output detailed information + const result = { + guid: guid, + encodedGuid: encoded + }; + + console.log(''); + console.log('Result:'); + console.log(JSON.stringify(result, null, 2)); + + console.log(''); + console.log('Encoded GUID:'); + console.log(encoded); + } + + return encoded; + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// Export for use as module +module.exports = { encodeGuidToBase64Url }; + +// Run if executed directly +if (require.main === module) { + main(); +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/smoke-test.mjs b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/smoke-test.mjs new file mode 100644 index 00000000..b4688b67 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/smoke-test.mjs @@ -0,0 +1,313 @@ +#!/usr/bin/env node +// +// Post-deploy smoke test for the M365 LangGraph MCS Tool sample. +// +// It drives the *deployed* bot over the Direct Line channel: +// 1. Resolves the bot name + resource group from env/.env. (written by `atk provision`). +// 2. Enables the Direct Line channel and reads its secret via the Azure CLI +// (or uses DIRECTLINE_SECRET if you set it yourself). +// 3. Starts a conversation, sends a hotel prompt, and polls for the streamed reply. +// 4. Because every turn requires the `MCS` sign-in, if the bot returns a sign-in card +// the script prints the sign-in URL — open it once, then re-run. +// +// Usage: +// npm run smoke # env dev, default hotel prompt +// ENV=dev npm run smoke # target a different toolkit environment +// SMOKE_PROMPT="Hotels in the policy" npm run smoke +// DIRECTLINE_SECRET=xxxxx npm run smoke # skip the Azure CLI step +// +// Requirements: Node 22+ (global fetch), and either DIRECTLINE_SECRET or the Azure CLI +// (`az login`) signed in to the subscription that holds the bot. + +import { readFileSync, existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_DIR = join(__dirname, ".."); +const ENV_NAME = process.env.ENV || "dev"; +const PROMPT = process.env.SMOKE_PROMPT || "What are the available hotels?"; +const DL_BASE = "https://directline.botframework.com/v3/directline"; +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 120000; +const USER_ID = "smoke-tester"; + +const c = { + bold: (s) => `\u001b[1m${s}\u001b[0m`, + red: (s) => `\u001b[31m${s}\u001b[0m`, + green: (s) => `\u001b[32m${s}\u001b[0m`, + yellow: (s) => `\u001b[33m${s}\u001b[0m`, + dim: (s) => `\u001b[2m${s}\u001b[0m`, +}; + +function step(msg) { + console.log(`\n${c.bold(msg)}`); +} +function info(msg) { + console.log(` ${msg}`); +} +function fail(msg) { + console.error(`${c.red("Error:")} ${msg}`); + process.exit(1); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// --- Load env/.env. (+ .user) into a plain object ---------------------- +function loadEnvFile(file) { + const out = {}; + if (!existsSync(file)) return out; + for (const raw of readFileSync(file, "utf8").split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + return out; +} + +function loadConfig() { + const envDir = join(PROJECT_DIR, "env"); + const merged = { + ...loadEnvFile(join(envDir, `.env.${ENV_NAME}`)), + ...loadEnvFile(join(envDir, `.env.${ENV_NAME}.user`)), + ...process.env, + }; + return merged; +} + +// --- Azure CLI helpers ------------------------------------------------------ +function az(args) { + try { + const stdout = execFileSync("az", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, stdout }; + } catch (err) { + return { + ok: false, + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? err.message, + }; + } +} + +function resolveDirectLineSecret(cfg) { + if (cfg.DIRECTLINE_SECRET) { + info("Using DIRECTLINE_SECRET from the environment."); + return cfg.DIRECTLINE_SECRET; + } + + const botName = + cfg.BOTSERVICENAME || + (cfg.RESOURCE_SUFFIX ? `bot${cfg.RESOURCE_SUFFIX}-bot` : ""); + const resourceGroup = cfg.AZURE_RESOURCE_GROUP_NAME; + + if (!botName || !resourceGroup) { + fail( + "Cannot locate the bot. Provision first (scripts/deploy.sh), or set DIRECTLINE_SECRET.\n" + + ` Looked for BOTSERVICENAME/RESOURCE_SUFFIX and AZURE_RESOURCE_GROUP_NAME in env/.env.${ENV_NAME}.\n` + + ` Found: bot='${botName || "(none)"}', resourceGroup='${resourceGroup || "(none)"}'.` + ); + } + + info(`Enabling Direct Line on bot '${botName}' (resource group '${resourceGroup}')...`); + const probe = az(["account", "show", "-o", "none"]); + if (!probe.ok) { + fail( + "Azure CLI is not signed in. Run `az login` (and `az account set --subscription `), " + + "or set DIRECTLINE_SECRET.\n " + + probe.stderr.trim() + ); + } + + const res = az([ + "bot", + "directline", + "create", + "--name", + botName, + "--resource-group", + resourceGroup, + "-o", + "json", + ]); + if (!res.ok) { + fail( + "`az bot directline create` failed. Ensure the Azure CLI targets the right subscription " + + "and the bot exists.\n " + + res.stderr.trim() + ); + } + + let secret; + try { + const parsed = JSON.parse(res.stdout); + const sites = + parsed?.properties?.properties?.sites ?? parsed?.properties?.sites ?? []; + secret = sites.find((s) => s?.key)?.key; + } catch { + /* fall through */ + } + if (!secret) { + fail( + "Enabled Direct Line but could not read the channel secret from the CLI output. " + + "Retrieve it from the Azure portal (Bot → Channels → Direct Line) and pass DIRECTLINE_SECRET." + ); + } + info(c.green("Direct Line secret acquired.")); + return secret; +} + +// --- Direct Line REST ------------------------------------------------------- +async function dlFetch(path, token, options = {}) { + const res = await fetch(`${DL_BASE}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + const text = await res.text(); + let body; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + if (!res.ok) { + throw new Error( + `Direct Line ${options.method || "GET"} ${path} → ${res.status}: ${ + body?.error?.message || text + }` + ); + } + return body; +} + +function extractSignInUrl(activity) { + for (const att of activity.attachments || []) { + const ct = att.contentType || ""; + if ( + ct === "application/vnd.microsoft.card.oauth" || + ct === "application/vnd.microsoft.card.signin" + ) { + const buttons = att.content?.buttons || []; + const url = buttons.find((b) => b?.value)?.value; + return { url: url || null, connectionName: att.content?.connectionName }; + } + } + return null; +} + +function textFromActivity(a) { + if (a.text) return a.text; + // Streaming responses arrive as typing activities carrying incremental text. + if (a.type === "typing" && a.text) return a.text; + return ""; +} + +async function main() { + console.log(c.bold(`M365 LangGraph MCS Tool — smoke test (env: ${ENV_NAME})`)); + const cfg = loadConfig(); + + step("1. Resolving Direct Line channel"); + const secret = resolveDirectLineSecret(cfg); + + step("2. Starting a Direct Line conversation"); + const conv = await dlFetch("/conversations", secret, { method: "POST" }); + const conversationId = conv.conversationId; + const token = conv.token || secret; + if (!conversationId) fail("Direct Line did not return a conversationId."); + info(`conversationId: ${conversationId}`); + + step("3. Sending the prompt"); + info(`"${PROMPT}"`); + await dlFetch(`/conversations/${conversationId}/activities`, token, { + method: "POST", + body: JSON.stringify({ + type: "message", + from: { id: USER_ID, name: "Smoke Tester" }, + text: PROMPT, + }), + }); + + step("4. Waiting for the agent's reply"); + const deadline = Date.now() + POLL_TIMEOUT_MS; + let watermark = null; + let finalText = ""; + let sawTyping = false; + + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const qs = watermark ? `?watermark=${watermark}` : ""; + const page = await dlFetch( + `/conversations/${conversationId}/activities${qs}`, + token + ); + watermark = page.watermark ?? watermark; + + const botActivities = (page.activities || []).filter( + (a) => a.from?.id && a.from.id !== USER_ID + ); + + for (const a of botActivities) { + const signIn = extractSignInUrl(a); + if (signIn) { + console.log(`\n${c.yellow("Sign-in required (one-time).")}`); + info( + `The bot's '${ + signIn.connectionName || cfg.MCS_CONNECTION_NAME || "mcs" + }' connection needs delegated Copilot Studio access.` + ); + if (signIn.url) { + info("Open this URL, complete sign-in, then re-run `npm run smoke`:"); + console.log(`\n ${c.bold(signIn.url)}\n`); + } else { + info( + "The sign-in card did not include a link (token-exchange card). " + + "Complete the sign-in once in Teams / Microsoft 365 Copilot, then re-run." + ); + } + process.exit(2); + } + + if (a.type === "typing") sawTyping = true; + const t = textFromActivity(a); + // Later chunks carry the cumulative answer; keep the longest we've seen. + if (t && t.length >= finalText.length) finalText = t; + else if (a.type === "message" && t) finalText = t; + } + + if (finalText) { + const done = botActivities.some((a) => a.type === "message" && a.text); + if (done) break; + } + } + + step("5. Result"); + if (!finalText) { + if (sawTyping) { + fail( + "The agent started responding (typing) but no message completed within " + + `${POLL_TIMEOUT_MS / 1000}s. Check the App Service logs.` + ); + } + fail( + "No reply received. Verify the deploy succeeded, MCS_ENVIRONMENT_ID / MCS_SCHEMA_NAME " + + "point at a published agent, and the sign-in completed." + ); + } + + console.log(`\n${c.green("Agent reply:")}\n`); + console.log(finalText.trim()); + console.log(`\n${c.green("Smoke test passed.")} The full chain responded end-to-end.`); +} + +main().catch((err) => { + console.error(`\n${c.red("Smoke test failed:")} ${err.message}`); + process.exit(1); +}); diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/agent.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/agent.ts new file mode 100644 index 00000000..ff2c1e9d --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/agent.ts @@ -0,0 +1,184 @@ +import { ActivityTypes } from "@microsoft/agents-activity"; +import { + AgentApplication, + MemoryStorage, + TurnContext, + TurnState, +} from "@microsoft/agents-hosting"; +import { HumanMessage, AIMessageChunk } from "@langchain/core/messages"; + +import config from "./config"; +import { + createOrchestrator, + McsToolContext, + McsToolSharedState, +} from "./mcs"; +import { logger } from "./logger"; + +// Custom conversation state for persisting MCS conversationId across turns +interface CustomConversationState { + mcsConversationId?: string; +} + +type AppTurnState = TurnState; + +class ProxyAgent extends AgentApplication { + private readonly _graph: ReturnType; + + constructor() { + // Validate required configuration + if (!config.mcsEnvironmentId) { + throw new Error("MCS_ENVIRONMENT_ID is not configured."); + } + if (!config.mcsSchemaName) { + throw new Error("MCS_SCHEMA_NAME is not configured."); + } + if (!config.azureOpenAiEndpoint) { + throw new Error("AZURE_OPENAI_ENDPOINT is not configured."); + } + + super({ + storage: new MemoryStorage(), + authorization: { + MCS: { + name: config.mcsConnectionName, + title: "Sign in", + text: "Please sign in to continue", + }, + }, + }); + + // Build the LangGraph orchestrator + this._graph = createOrchestrator(); + + logger.info( + `ProxyAgent initialized with MCS connection: ${config.mcsConnectionName}` + ); + + this.onMessage("--signout", this._handleSignOut); + this.onActivity(ActivityTypes.Message, this._handleMessage, ["MCS"]); + } + + private _handleSignOut = async ( + context: TurnContext, + turnState: AppTurnState + ): Promise => { + try { + await this.authorization.signOut(context, turnState, "MCS"); + logger.info("User signed out successfully"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error(`Error signing out: ${errorMessage}`); + } + await context.sendActivity("You have signed out"); + }; + + private _handleMessage = async ( + context: TurnContext, + turnState: AppTurnState + ): Promise => { + const userMessage = context.activity.text || ""; + logger.info( + `Processing message (Activity ID: ${context.activity.id})` + ); + logger.debug(`User message: ${userMessage}`); + + let streamEnded = false; + + try { + context.streamingResponse.queueInformativeUpdate("Just a moment..."); + + const mcsConversationId = turnState?.conversation?.mcsConversationId; + + // Shared mutable state — the tool writes to it, we read after graph completes + const mcsSharedState: McsToolSharedState = {}; + + // Build the configurable context injected into tool invocations. + // Cast to Record to satisfy LangGraph's configurable type constraint. + const configurable: Record = { + mcsAuthorization: this.authorization as unknown as McsToolContext["mcsAuthorization"], + mcsTurnContext: context, + mcsStreamingResponse: context.streamingResponse, + mcsConversationId, + mcsSharedState, + }; + + // Stream graph execution: LLM tokens + tool calls + const stream = await this._graph.stream( + { messages: [new HumanMessage(userMessage)] }, + { + configurable, + streamMode: "messages", + } + ); + + for await (const [messageChunk, metadata] of stream as AsyncGenerator< + [AIMessageChunk, { langgraph_node: string }] + >) { + // Only forward LLM tokens from the "agent" node (not tool results) + if ( + metadata?.langgraph_node === "agent" && + messageChunk?.content && + typeof messageChunk.content === "string" + ) { + // Check if this is a tool call message (no text content to stream) + if ( + messageChunk.tool_calls && + messageChunk.tool_calls.length > 0 + ) { + const toolNames = messageChunk.tool_calls + .map((tc: { name?: string }) => tc.name) + .join(", "); + logger.debug(`LLM calling tools: ${toolNames}`); + continue; + } + + // Don't forward LLM's post-tool-call commentary if MCS already streamed. + if (mcsSharedState.mcsStreamed) { + logger.debug( + "Skipping LLM post-tool commentary (MCS already streamed)" + ); + continue; + } + + // Forward LLM reasoning tokens to the user + context.streamingResponse.queueTextChunk(messageChunk.content); + } + } + + // Persist MCS conversationId from shared state + if (mcsSharedState.conversationId && turnState?.conversation) { + turnState.conversation.mcsConversationId = mcsSharedState.conversationId; + logger.debug( + `MCS conversationId persisted: ${mcsSharedState.conversationId}` + ); + } + + await context.streamingResponse.endStream(); + streamEnded = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = + error instanceof Error ? error.stack : undefined; + logger.error(`Error processing message: ${errorMessage}`); + if (errorStack) { + logger.debug(`Stack trace:\n${errorStack}`); + } + if (!streamEnded) { + try { + context.streamingResponse.queueTextChunk( + `An error occurred while processing your request. ${errorMessage}` + ); + await context.streamingResponse.endStream(); + streamEnded = true; + } catch (streamEndError) { + logger.error("Error ending stream after error:", streamEndError); + } + } + } + }; +} + +export const agentApp = new ProxyAgent(); diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/config.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/config.ts new file mode 100644 index 00000000..757420a5 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/config.ts @@ -0,0 +1,33 @@ +export interface Config { + // MCS (Copilot Studio) configuration + mcsConnectionName: string; + mcsEnvironmentId: string; + mcsSchemaName: string; + + // Azure OpenAI configuration (for LangGraph orchestrator) + azureOpenAiEndpoint?: string; + azureOpenAiDeployment: string; + azureOpenAiApiKey?: string; + azureOpenAiApiVersion: string; + + // Bot auth connection name + ssoConnectionName: string; +} + +const config: Config = { + // MCS + mcsConnectionName: process.env.MCS_CONNECTION_NAME || "mcs", + mcsEnvironmentId: process.env.MCS_ENVIRONMENT_ID || "", + mcsSchemaName: process.env.MCS_SCHEMA_NAME || "", + + // Azure OpenAI + azureOpenAiEndpoint: process.env.AZURE_OPENAI_ENDPOINT, + azureOpenAiDeployment: process.env.AZURE_OPENAI_DEPLOYMENT || "gpt-4o", + azureOpenAiApiKey: process.env.AZURE_OPENAI_API_KEY, + azureOpenAiApiVersion: process.env.AZURE_OPENAI_API_VERSION || "2024-12-01-preview", + + // OAuth connection used for Teams SSO (provisioned by infra as "SsoConnection") + ssoConnectionName: process.env.OAUTHCONNECTIONNAME || "SsoConnection", +}; + +export default config; diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/index.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/index.ts new file mode 100644 index 00000000..9f50be22 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/index.ts @@ -0,0 +1,11 @@ +// Import required packages +import { startServer } from "@microsoft/agents-hosting-express"; + + +// This bot's main dialog. +import { agentApp } from "./agent"; + + + +// Start the server with streaming support +startServer(agentApp); \ No newline at end of file diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/logger.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/logger.ts new file mode 100644 index 00000000..596fd045 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/logger.ts @@ -0,0 +1,89 @@ +/** + * Simple logging utility for the Proxy Agent application. + * Supports different log levels that can be controlled via environment variables. + * + * Log Levels (in order of severity): + * - ERROR: Critical errors that need immediate attention + * - WARN: Warning messages for potentially problematic situations + * - INFO: General informational messages about application flow + * - DEBUG: Detailed diagnostic information for debugging + * + * Set LOG_LEVEL environment variable to control verbosity: + * - error: Only ERROR messages + * - warn: ERROR and WARN messages + * - info: ERROR, WARN, and INFO messages (default) + * - debug: All messages including DEBUG + */ + +enum LogLevel { + ERROR = 0, + WARN = 1, + INFO = 2, + DEBUG = 3 +} + +class Logger { + private currentLevel: LogLevel; + + constructor() { + // Default to INFO level, can be overridden by LOG_LEVEL env var + const envLevel = process.env.LOG_LEVEL?.toLowerCase(); + + switch (envLevel) { + case 'error': + this.currentLevel = LogLevel.ERROR; + break; + case 'warn': + this.currentLevel = LogLevel.WARN; + break; + case 'info': + this.currentLevel = LogLevel.INFO; + break; + case 'debug': + this.currentLevel = LogLevel.DEBUG; + break; + default: + // Default to INFO for production-ready sample code + this.currentLevel = LogLevel.INFO; + } + } + + /** + * Log error messages - critical errors that need attention + */ + error(message: string, ...args: unknown[]): void { + if (this.currentLevel >= LogLevel.ERROR) { + console.error(`[ERROR] ${message}`, ...args); + } + } + + /** + * Log warning messages - potentially problematic situations + */ + warn(message: string, ...args: unknown[]): void { + if (this.currentLevel >= LogLevel.WARN) { + console.warn(`[WARN] ${message}`, ...args); + } + } + + /** + * Log informational messages - general application flow + */ + info(message: string, ...args: unknown[]): void { + if (this.currentLevel >= LogLevel.INFO) { + console.log(`[INFO] ${message}`, ...args); + } + } + + /** + * Log debug messages - detailed diagnostic information + */ + debug(message: string, ...args: unknown[]): void { + if (this.currentLevel >= LogLevel.DEBUG) { + console.log(`[DEBUG] ${message}`, ...args); + } + } +} + +// Export a singleton instance +export const logger = new Logger(); diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/index.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/index.ts new file mode 100644 index 00000000..01456f89 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/index.ts @@ -0,0 +1,3 @@ +export { createOrchestrator } from "./orchestrator"; +export { mcsTool } from "./mcsTool"; +export type { McsToolContext, McsToolSharedState } from "./mcsTool"; diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsActivityProcessor.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsActivityProcessor.ts new file mode 100644 index 00000000..88753af5 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsActivityProcessor.ts @@ -0,0 +1,116 @@ +import { logger } from "../logger"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyActivity = any; + +export interface StreamingResponseLike { + queueInformativeUpdate(text: string): void; + queueTextChunk(text: string): void; +} + +export interface McsStreamResult { + finalText: string; + conversationId: string | undefined; +} + +/** + * Processes the Activity stream from MCS and forwards streaming text to the + * M365 streaming response. + * + * The SDK accumulates text internally — each typing activity with streaming info + * has activity.text set to the FULL accumulated text so far. We extract just the + * new delta by comparing with the previous accumulated text, then forward the + * delta via queueTextChunk. + */ +export async function processMcsStream( + stream: AsyncGenerator, + streamingResponse: StreamingResponseLike, + initialConversationId?: string +): Promise { + let conversationId: string | undefined = initialConversationId; + let lastText = ""; + let finalText = ""; + + for await (const activity of stream) { + // Full activity dump for debugging + logger.info(`MCS RAW ACTIVITY: ${JSON.stringify({ + type: activity.type, + text: activity.text ?? null, + textLen: activity.text?.length ?? 0, + channelData: activity.channelData ?? null, + entities: activity.entities ?? null, + conversationId: activity.conversation?.id ?? null, + })}`); + + const channelData = (activity.channelData ?? {}) as Record; + const entities = (activity.entities ?? []) as Array>; + const streamingEntity = entities.find( + (e) => e.type === "streaminfo" && e.streamType === "streaming" + ); + + // Capture conversationId + if (!conversationId && activity.conversation?.id) { + conversationId = activity.conversation.id; + } + + // Streaming text (typing with streaminfo or channelData delta/streaming) + if (activity.type === "typing") { + if ( + streamingEntity || + channelData.chunkType === "delta" || + channelData.streamType === "streaming" + ) { + // activity.text is the accumulated text so far — extract delta. + // Normally monotonically growing, but handle revision/reset gracefully. + const accumulated = activity.text ?? ""; + if (accumulated.length > lastText.length) { + const delta = accumulated.substring(lastText.length); + streamingResponse.queueTextChunk(delta); + logger.debug( + `MCS chunk: +${delta.length} chars (total: ${accumulated.length})` + ); + } else if (accumulated.length < lastText.length && accumulated.length > 0) { + // Non-monotonic text — SDK sent a revision or reset. Log and re-send full text. + logger.warn( + `MCS text revision: ${lastText.length} -> ${accumulated.length} chars, re-sending` + ); + streamingResponse.queueTextChunk(accumulated); + } + lastText = accumulated; + continue; + } + + // Informative status + if (channelData.streamType === "informative" && activity.text) { + streamingResponse.queueInformativeUpdate(activity.text); + continue; + } + + continue; + } + + // Final message + if (activity.type === "message") { + finalText = activity.text ?? ""; + // If nothing was streamed yet, send the full text as one chunk + if (!lastText && finalText) { + streamingResponse.queueTextChunk(finalText); + } + continue; + } + + if (activity.type === "endOfConversation") { + logger.debug("MCS conversation ended"); + } + } + + // Use streamed text if final message was empty or absent + if (!finalText && lastText) { + finalText = lastText; + } + + logger.debug( + `MCS stream complete. Text length: ${finalText.length}, conversationId: ${conversationId}` + ); + return { finalText, conversationId }; +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsClientFactory.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsClientFactory.ts new file mode 100644 index 00000000..0792e064 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsClientFactory.ts @@ -0,0 +1,29 @@ +import { + CopilotStudioClient, + ConnectionSettings, +} from "@microsoft/agents-copilotstudio-client"; +import { TurnContext } from "@microsoft/agents-hosting"; +import { AuthorizationLike, getMcsOboToken } from "./mcsTokenProvider"; +import config from "../config"; +import { logger } from "../logger"; + +/** + * Builds a CopilotStudioClient for the current turn. + * A new client is created per turn because the token is baked in at construction time. + */ +export async function createMcsClient( + authorization: AuthorizationLike, + turnContext: TurnContext +): Promise { + // 'MCS' is the auth handler ID (registered in agent.ts constructor). + // The handler's OAuth connection ('mcs') handles the token exchange. + const token = await getMcsOboToken(authorization, turnContext, "MCS"); + + const settings = new ConnectionSettings({ + environmentId: config.mcsEnvironmentId, + schemaName: config.mcsSchemaName, + }); + + logger.debug("Creating CopilotStudioClient"); + return new CopilotStudioClient(settings, token); +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTokenProvider.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTokenProvider.ts new file mode 100644 index 00000000..372656f8 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTokenProvider.ts @@ -0,0 +1,47 @@ +import { TurnContext } from "@microsoft/agents-hosting"; +import { logger } from "../logger"; + +/** + * Minimal interface matching the M365 Agents SDK Authorization service. + */ +export interface AuthorizationLike { + getToken( + turnContext: TurnContext, + authHandlerId: string + ): Promise<{ token: string | undefined; status?: string }>; +} + +/** + * Extracts the token from the MCS auth handler. + * The token is acquired via the Bot Service OAuth connection configured for the handler. + */ +export async function getMcsOboToken( + authorization: AuthorizationLike, + turnContext: TurnContext, + authHandlerId: string +): Promise { + const response = await authorization.getToken(turnContext, authHandlerId); + + if (!response.token) { + throw new Error( + `MCS token unavailable. Handler: '${authHandlerId}', status: ${response.status ?? "unknown"}. ` + + `Ensure the OAuth connection is configured in Azure Bot Service.` + ); + } + + // Decode JWT to verify audience and scopes + try { + const parts = response.token.split("."); + if (parts.length >= 2) { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); + logger.debug( + `MCS token claims — aud: ${payload.aud}, scp: ${payload.scp ?? "N/A"}` + ); + } + } catch { + logger.debug("Could not decode MCS token for inspection"); + } + + logger.debug(`MCS token acquired (length: ${response.token.length})`); + return response.token; +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTool.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTool.ts new file mode 100644 index 00000000..da640e94 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/mcsTool.ts @@ -0,0 +1,158 @@ +import { tool } from "@langchain/core/tools"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { z } from "zod"; +import { TurnContext } from "@microsoft/agents-hosting"; +import { createMcsClient } from "./mcsClientFactory"; +import { + processMcsStream, + StreamingResponseLike, +} from "./mcsActivityProcessor"; +import { AuthorizationLike } from "./mcsTokenProvider"; +import { logger } from "../logger"; + +/** + * Shape of what must be injected via config.configurable before invoking the graph. + * These are per-turn values that the tool needs but can't get from LangGraph state. + */ +export interface McsToolContext { + mcsAuthorization: AuthorizationLike; + mcsTurnContext: TurnContext; + mcsStreamingResponse: StreamingResponseLike; + mcsConversationId?: string; + /** Shared mutable state — tool writes, agent.ts reads after graph completes */ + mcsSharedState: McsToolSharedState; +} + +/** + * Shared state object passed via configurable. + * Mutated by the tool, read by agent.ts after graph completion. + * This avoids runId-mismatch issues with Map-based side channels. + */ +export interface McsToolSharedState { + /** Set by the tool after MCS conversation is established */ + conversationId?: string; + /** Set to true when MCS streams content — used to suppress LLM echo */ + mcsStreamed?: boolean; +} + +const inputSchema = z.object({ + userMessage: z + .string() + .describe("The user message to send to the Copilot Studio agent"), +}); + +export const mcsTool = tool( + async ( + input: z.infer, + config: RunnableConfig + ): Promise => { + const ctx = config?.configurable as McsToolContext | undefined; + if ( + !ctx?.mcsAuthorization || + !ctx?.mcsTurnContext || + !ctx?.mcsStreamingResponse + ) { + throw new Error( + "MCS tool missing required context (mcsAuthorization, mcsTurnContext, mcsStreamingResponse). " + + "Ensure these are passed via configurable when invoking the graph." + ); + } + + const { userMessage } = input; + const { mcsAuthorization, mcsTurnContext, mcsStreamingResponse } = ctx; + const sharedState = ctx.mcsSharedState; + + // Read conversationId from ctx — may be updated by a previous tool call in this turn + let mcsConversationId = ctx.mcsConversationId; + + logger.info( + `MCS tool invoked. conversationId: ${mcsConversationId ?? "new"}` + ); + mcsStreamingResponse.queueInformativeUpdate( + "Contacting Copilot Studio agent..." + ); + + // Build a fresh client per invocation (token baked in at construction). + // Do NOT set mcsStreamed flag until client is created and streaming starts, + // so LLM fallback messages are not suppressed if client creation fails. + let client; + try { + logger.debug("Creating MCS client..."); + client = await createMcsClient(mcsAuthorization, mcsTurnContext); + logger.debug("MCS client created successfully"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error(`Failed to create MCS client: ${msg}`); + if (err instanceof Error && err.stack) logger.error(err.stack); + throw err; + } + + // If no conversation yet, start one with the greeting event (true) + // to ensure we get a conversationId back. We consume the welcome silently. + if (!mcsConversationId) { + logger.debug("Starting new MCS conversation"); + const startStream = client.startConversationStreaming( + true + ) as AsyncGenerator; + + // Drain silently — just capture the conversationId + let startConversationId: string | undefined; + for await (const activity of startStream) { + if (!startConversationId && activity.conversation?.id) { + startConversationId = activity.conversation.id; + } + } + + if (!startConversationId) { + throw new Error( + "MCS did not return a conversationId from startConversationStreaming" + ); + } + mcsConversationId = startConversationId; + logger.debug(`MCS conversationId: ${mcsConversationId}`); + } + + // Now that we have a client and conversationId, signal that MCS streaming + // is about to begin. This suppresses LLM echo in agent.ts. + sharedState.mcsStreamed = true; + + // Send the user's message with streaming + const preview = + userMessage.length > 50 + ? `${userMessage.substring(0, 50)}...` + : userMessage; + logger.debug( + `Sending message to MCS conversation ${mcsConversationId}: "${preview}"` + ); + + const sendStream = client.sendActivityStreaming( + { + type: "message", + text: userMessage, + conversation: { id: mcsConversationId }, + } as any, + mcsConversationId + ) as AsyncGenerator; + + const result = await processMcsStream( + sendStream, + mcsStreamingResponse, + mcsConversationId + ); + + // Store conversationId in shared state for agent.ts to persist, + // AND write back to ctx so subsequent tool calls in this turn see it. + const finalConversationId = result.conversationId ?? mcsConversationId; + sharedState.conversationId = finalConversationId; + ctx.mcsConversationId = finalConversationId; + + return result.finalText || "(no response from Copilot Studio agent)"; + }, + { + name: "ask_copilot_studio_agent", + description: + "Send a message to the Copilot Studio agent and get a response. " + + "Use this tool when the user's question should be handled by the Copilot Studio agent.", + schema: inputSchema, + } +); diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/orchestrator.ts b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/orchestrator.ts new file mode 100644 index 00000000..4e3cebe1 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/src/mcs/orchestrator.ts @@ -0,0 +1,71 @@ +import { AzureChatOpenAI } from "@langchain/openai"; +import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import type { CompiledStateGraph } from "@langchain/langgraph"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { mcsTool } from "./mcsTool"; +import config from "../config"; +import { logger } from "../logger"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OrchestratorGraph = CompiledStateGraph; + +/** + * Simple demo tool that returns the current date/time. + * Demonstrates multi-tool orchestration alongside the MCS tool. + */ +const getCurrentTimeTool = tool( + async (): Promise => { + const now = new Date(); + return `Current date and time: ${now.toISOString()} (${now.toLocaleString("en-US", { timeZone: "UTC" })} UTC)`; + }, + { + name: "get_current_time", + description: + "Get the current date and time. Use this when the user asks about the current time or date.", + schema: z.object({}), + } +); + +/** + * Creates the LangGraph ReAct agent with Azure OpenAI and the tool set. + */ +export function createOrchestrator(): OrchestratorGraph { + if (!config.azureOpenAiEndpoint) { + throw new Error("AZURE_OPENAI_ENDPOINT is not configured."); + } + + const model = new AzureChatOpenAI({ + azureOpenAIEndpoint: config.azureOpenAiEndpoint, + azureOpenAIApiDeploymentName: config.azureOpenAiDeployment, + azureOpenAIApiKey: config.azureOpenAiApiKey, + azureOpenAIApiVersion: config.azureOpenAiApiVersion, + temperature: 0, + streaming: true, + }); + + const tools = [mcsTool, getCurrentTimeTool]; + + const agent = createReactAgent({ + llm: model, + tools, + prompt: + "You are a helpful assistant that orchestrates between tools to answer user questions.\n\n" + + "AVAILABLE TOOLS:\n" + + "- ask_copilot_studio_agent: A Copilot Studio agent that specializes in finding hotels and answering hotel-related questions. " + + "Use this for ANY question about hotels, accommodations, bookings, or travel.\n" + + "- get_current_time: Returns the current date and time.\n\n" + + "RULES:\n" + + "1. For hotel/accommodation/travel questions, ALWAYS use ask_copilot_studio_agent.\n" + + "2. If the user mentions 'Copilot Studio', 'agent', or asks you to forward a message, use ask_copilot_studio_agent.\n" + + "3. For time/date questions, use get_current_time.\n" + + "4. For simple greetings, respond directly.\n" + + "5. After calling ask_copilot_studio_agent, do NOT repeat the response — the user already saw it via streaming. " + + "Just say something brief or nothing at all.\n", + }); + + logger.info( + `Orchestrator created with Azure OpenAI (${config.azureOpenAiDeployment}) and ${tools.length} tools` + ); + return agent; +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/tsconfig.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/tsconfig.json new file mode 100644 index 00000000..7d0e91da --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2022", + "module": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node16", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/web.config b/extensibility/agents-sdk/m365-langgraph-mcs-tool/web.config new file mode 100644 index 00000000..d43d5ef6 --- /dev/null +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/web.config @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 0aaa5569e91ffec40721840287ebd175865ee4d2 Mon Sep 17 00:00:00 2001 From: adilei Date: Mon, 20 Jul 2026 20:28:22 +0300 Subject: [PATCH 2/2] Make deploy scripts run atk via npx when it isn't installed globally The sample uses m365agents.yml, which the newer M365 Agents Toolkit CLI (`atk`, from @microsoft/m365agentstoolkit-cli) reads. The older `teamsapp` CLI reads teamsapp.yml and can't drive this sample, so relying on a specific global install was a rough edge for the one-command deploy. deploy.sh / deploy.ps1 now prefer a global `atk` and otherwise invoke `npx -y -p @microsoft/m365agentstoolkit-cli atk`, so provision/deploy work with no manual global install. Docs updated to mark the global CLI install as optional. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c64fb4df-84d5-4146-84c2-65ade1d34a7c --- .../m365-langgraph-mcs-tool/README.md | 3 ++- .../docs/AZURE_DEPLOYMENT.md | 6 ++++-- .../scripts/deploy.ps1 | 21 ++++++++++++++----- .../m365-langgraph-mcs-tool/scripts/deploy.sh | 20 +++++++++++++----- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md b/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md index 54068ce7..6df7b505 100644 --- a/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md @@ -63,7 +63,8 @@ flowchart LR - **Node.js 22 or 24** and npm. - **Azure subscription** with permission to create resources and assign roles. -- **Microsoft 365 Agents Toolkit CLI** (`atk`): +- The **Microsoft 365 Agents Toolkit CLI** (`atk`). Optional — the deploy script runs it via + `npx` if it isn't installed. To install it globally anyway: `npm install -g @microsoft/m365agentstoolkit-cli` - A **published Copilot Studio agent** — you need its **environment ID** and **schema name** (Copilot Studio → your agent → *Settings → Advanced → Metadata*). diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/AZURE_DEPLOYMENT.md b/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/AZURE_DEPLOYMENT.md index c09670ba..7cea6c7e 100644 --- a/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/AZURE_DEPLOYMENT.md +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/AZURE_DEPLOYMENT.md @@ -12,7 +12,8 @@ using the **Microsoft 365 Agents Toolkit** (`atk`). For local debugging instead, ## Prerequisites - **Node.js 22 or 24** and npm. -- **Microsoft 365 Agents Toolkit CLI**: `npm install -g @microsoft/m365agentstoolkit-cli` +- **Microsoft 365 Agents Toolkit CLI** (`atk`) — optional; the deploy script runs it via `npx` + if it isn't installed. To install globally: `npm install -g @microsoft/m365agentstoolkit-cli` - **Azure subscription** with rights to create resources and assign roles. - A **published Copilot Studio agent** — its **environment ID** and **schema name**. - An **Azure OpenAI** resource with a chat **deployment** and its **API key**. @@ -29,7 +30,8 @@ From the sample root: The script: -1. Checks that `node`, `npm`, and `atk` are installed. +1. Checks that `node` and `npm` are installed, and resolves the `atk` CLI (using `npx` if it + isn't installed globally). 2. Prompts for anything not already set: `MCS_ENVIRONMENT_ID`, `MCS_SCHEMA_NAME`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT`, the Azure OpenAI API key, and (optionally) the target subscription/resource group. diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 index f74c95ee..c5488eb4 100644 --- a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1 @@ -40,10 +40,21 @@ function Need($cmd, $hint) { } Need node "Install Node.js 22 or 24: https://nodejs.org" Need npm "npm ships with Node.js: https://nodejs.org" -Need atk "Install the M365 Agents Toolkit CLI: npm install -g @microsoft/m365agentstoolkit-cli" $nodeMajor = [int](node -p "process.versions.node.split('.')[0]") if ($nodeMajor -lt 22) { Fail "Node.js $nodeMajor detected; this sample requires Node 22 or 24." } -Write-Info "node $(node -v), npm $(npm -v), atk $((atk --version 2>$null | Select-Object -First 1))" + +# Resolve the Microsoft 365 Agents Toolkit CLI (`atk`). Prefer a global install; +# otherwise run it on demand with npx so no global install is required. (Note: the +# older `teamsapp` CLI reads teamsapp.yml, not m365agents.yml, so it is not used.) +if (Get-Command atk -ErrorAction SilentlyContinue) { + $AtkExe = 'atk'; $AtkBase = @() + Write-Info "node $(node -v), npm $(npm -v), atk $((atk --version 2>$null | Select-Object -First 1))" +} else { + Need npx "npx ships with Node.js: https://nodejs.org" + $AtkExe = 'npx'; $AtkBase = @('-y', '-p', '@microsoft/m365agentstoolkit-cli', 'atk') + Write-Info "node $(node -v), npm $(npm -v); atk via npx (@microsoft/m365agentstoolkit-cli)" +} +$AtkDisplay = (@($AtkExe) + $AtkBase) -join ' ' # --- helpers to read/write .env files -------------------------------------- function Read-Env($key, $file) { @@ -127,16 +138,16 @@ npm run build # --- 5. Provision + deploy ------------------------------------------------- Write-Head "5. Provisioning Azure resources (atk provision)" Write-Info "You may be prompted to sign in to Azure and Microsoft 365." -atk provision --env $EnvName +& $AtkExe @AtkBase provision --env $EnvName Write-Head "6. Deploying the bot (atk deploy)" -atk deploy --env $EnvName +& $AtkExe @AtkBase deploy --env $EnvName # --- Done ------------------------------------------------------------------ $Pkg = "appPackage/build/appPackage.$EnvName.zip" Write-Head "Done. Next steps" Write-Info "1. Install the app package: $Pkg" Write-Info " - Teams: Apps -> Manage your apps -> Upload an app -> Upload a custom app" -Write-Info " - Or run: atk install --file-path $Pkg --env $EnvName" +Write-Info " - Or run: $AtkDisplay install --file-path $Pkg --env $EnvName" Write-Info "2. Open the agent in Teams / Microsoft 365 Copilot and say hello." Write-Info "3. First message triggers a one-time sign-in (delegated Copilot Studio access)." diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh index 25fc2b07..456678ae 100755 --- a/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh +++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh @@ -34,12 +34,22 @@ bold "1. Checking prerequisites" need() { command -v "$1" >/dev/null 2>&1 || fail "$1 not found. $2"; } need node "Install Node.js 22 or 24: https://nodejs.org" need npm "npm ships with Node.js: https://nodejs.org" -need atk "Install the M365 Agents Toolkit CLI: npm install -g @microsoft/m365agentstoolkit-cli" NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" if [ "${NODE_MAJOR}" -lt 22 ]; then fail "Node.js ${NODE_MAJOR} detected; this sample requires Node 22 or 24." fi -info "node $(node -v), npm $(npm -v), atk $(atk --version 2>/dev/null | head -n1)" + +# Resolve the Microsoft 365 Agents Toolkit CLI (`atk`). Prefer a global install; +# otherwise run it on demand with npx so no global install is required. (Note: the +# older `teamsapp` CLI reads teamsapp.yml, not m365agents.yml, so it is not used.) +if command -v atk >/dev/null 2>&1; then + ATK=(atk) + info "node $(node -v), npm $(npm -v), atk $(atk --version 2>/dev/null | head -n1)" +else + need npx "npx ships with Node.js: https://nodejs.org" + ATK=(npx -y -p @microsoft/m365agentstoolkit-cli atk) + info "node $(node -v), npm $(npm -v); atk via npx (@microsoft/m365agentstoolkit-cli)" +fi # --- helpers to read/write .env files -------------------------------------- read_env() { # read_env KEY FILE -> prints value (may be empty) @@ -128,16 +138,16 @@ npm run build # --- 5. Provision + deploy ------------------------------------------------- bold "5. Provisioning Azure resources (atk provision)" info "You may be prompted to sign in to Azure and Microsoft 365." -atk provision --env "${ENV_NAME}" +"${ATK[@]}" provision --env "${ENV_NAME}" bold "6. Deploying the bot (atk deploy)" -atk deploy --env "${ENV_NAME}" +"${ATK[@]}" deploy --env "${ENV_NAME}" # --- Done ------------------------------------------------------------------ PKG="appPackage/build/appPackage.${ENV_NAME}.zip" bold "Done. Next steps" info "1. Install the app package: ${PKG}" info " • Teams: Apps → Manage your apps → Upload an app → Upload a custom app" -info " • Or run: atk install --file-path ${PKG} --env ${ENV_NAME}" +info " • Or run: ${ATK[*]} install --file-path ${PKG} --env ${ENV_NAME}" info "2. Open the agent in Teams / Microsoft 365 Copilot and say hello." info "3. First message triggers a one-time sign-in (delegated Copilot Studio access)."