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..6df7b505
--- /dev/null
+++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/README.md
@@ -0,0 +1,214 @@
+---
+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.
+- 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*).
+- 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 00000000..01aa37e3
Binary files /dev/null and b/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/color.png differ
diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/manifest.json b/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/manifest.json
new file mode 100644
index 00000000..fc1efb33
--- /dev/null
+++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/manifest.json
@@ -0,0 +1,78 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.24/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.24",
+ "version": "1.0.0",
+ "id": "${{TEAMS_APP_ID}}",
+ "developer": {
+ "name": "Teams App, Inc.",
+ "websiteUrl": "https://www.example.com",
+ "privacyUrl": "https://www.example.com/privacy",
+ "termsOfUseUrl": "https://www.example.com/termofuse"
+ },
+ "icons": {
+ "color": "color.png",
+ "outline": "outline.png"
+ },
+ "name": {
+ "short": "AzureAgentToM365ATK${{APP_NAME_SUFFIX}}",
+ "full": "Azure Agent for Microsoft 365 Copilot"
+ },
+ "description": {
+ "short": "Azure-powered agent for Microsoft 365 Copilot",
+ "full": "Custom engine agent that integrates Azure services with Microsoft 365 Copilot for enhanced productivity"
+ },
+ "accentColor": "#FFFFFF",
+ "copilotAgents": {
+ "customEngineAgents": [
+ {
+ "type": "bot",
+ "id": "${{BOT_ID}}"
+ }
+ ]
+ },
+ "bots": [
+ {
+ "botId": "${{BOT_ID}}",
+ "scopes": [
+ "copilot",
+ "personal"
+ ],
+ "supportsFiles": false,
+ "isNotificationOnly": false,
+ "commandLists": [
+ {
+ "scopes": [
+ "copilot",
+ "personal"
+ ],
+ "commands": [
+ {
+ "title": "How can you help me?",
+ "description": "How can you help me?"
+ },
+ {
+ "title": "Hotels in the policy",
+ "description": "What are the available hotels?"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "webApplicationInfo": {
+ "id": "${{SSO_APP_ID}}",
+ "resource": "${{SSO_APP_ID_URI}}"
+ },
+ "composeExtensions": [
+ ],
+ "configurableTabs": [],
+ "staticTabs": [],
+ "permissions": [
+ "identity",
+ "messageTeamMembers"
+ ],
+ "validDomains": [
+ "${{BOT_DOMAIN}}",
+ "token.botframework.com"
+ ]
+}
\ No newline at end of file
diff --git a/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/outline.png b/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/outline.png
new file mode 100644
index 00000000..f7a4c864
Binary files /dev/null and b/extensibility/agents-sdk/m365-langgraph-mcs-tool/appPackage/outline.png differ
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
new file mode 100644
index 00000000..7cea6c7e
--- /dev/null
+++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/docs/AZURE_DEPLOYMENT.md
@@ -0,0 +1,151 @@
+---
+nav_exclude: true
+search_exclude: false
+---
+
+# Azure deployment guide
+
+How to provision Azure resources and deploy the LangGraph + Copilot Studio proxy agent
+using the **Microsoft 365 Agents Toolkit** (`atk`). For local debugging instead, see the
+[local development guide](LOCAL_DEPLOYMENT).
+
+## Prerequisites
+
+- **Node.js 22 or 24** and npm.
+- **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**.
+- A **Microsoft 365 tenant** where you can upload a custom app.
+
+## Option A — one command (recommended)
+
+From the sample root:
+
+```bash
+./scripts/deploy.sh # macOS/Linux
+./scripts/deploy.ps1 # Windows (PowerShell)
+```
+
+The script:
+
+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.
+3. Writes non-secret values to `env/.env.dev` and the API key to `env/.env.dev.user`
+ (git-ignored), generating a unique `RESOURCE_SUFFIX` if needed.
+4. Runs `npm install` and `npm run build`.
+5. Runs `atk provision --env dev` then `atk deploy --env dev`.
+
+Values already present in your shell environment or in `env/.env.dev` are reused, so the
+script is idempotent and safe to re-run.
+
+## Option B — run the toolkit steps yourself
+
+1. Fill in `env/.env.dev` (see [Configuration](#configuration)) and put the API key in
+ `env/.env.dev.user`:
+
+ ```ini
+ # env/.env.dev.user (git-ignored)
+ SECRET_AZURE_OPENAI_API_KEY=
+ ```
+
+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..c5488eb4
--- /dev/null
+++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.ps1
@@ -0,0 +1,153 @@
+<#
+.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"
+$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." }
+
+# 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) {
+ 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."
+& $AtkExe @AtkBase provision --env $EnvName
+
+Write-Head "6. Deploying the bot (atk deploy)"
+& $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: $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
new file mode 100755
index 00000000..456678ae
--- /dev/null
+++ b/extensibility/agents-sdk/m365-langgraph-mcs-tool/scripts/deploy.sh
@@ -0,0 +1,153 @@
+#!/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"
+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
+
+# 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)
+ [ -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