From d1c81b74b2efd91acecab98de5be78d83069e247 Mon Sep 17 00:00:00 2001 From: Aaron Barth Date: Sun, 22 Feb 2026 18:31:57 -0500 Subject: [PATCH 1/3] Adding agents directory to include instructions and file on how to create declarative agents with OAuth settings --- application/external_apps/agents/.gitignore | 16 +++ application/external_apps/agents/README.md | 102 +++++++++++++++ .../agents/appPackage/ai-plugin.json | 84 +++++++++++++ .../external_apps/agents/appPackage/color.png | Bin 0 -> 5923 bytes .../agents/appPackage/declarativeAgent.json | 18 +++ .../agents/appPackage/instruction.txt | 15 +++ .../agents/appPackage/manifest.json | 39 ++++++ .../agents/appPackage/outline.png | Bin 0 -> 492 bytes application/external_apps/agents/env/.env.dev | 11 ++ .../agents/env/.env.dev.user.sample | 31 +++++ .../external_apps/agents/m365agents.yml | 116 ++++++++++++++++++ 11 files changed, 432 insertions(+) create mode 100644 application/external_apps/agents/.gitignore create mode 100644 application/external_apps/agents/README.md create mode 100644 application/external_apps/agents/appPackage/ai-plugin.json create mode 100644 application/external_apps/agents/appPackage/color.png create mode 100644 application/external_apps/agents/appPackage/declarativeAgent.json create mode 100644 application/external_apps/agents/appPackage/instruction.txt create mode 100644 application/external_apps/agents/appPackage/manifest.json create mode 100644 application/external_apps/agents/appPackage/outline.png create mode 100644 application/external_apps/agents/env/.env.dev create mode 100644 application/external_apps/agents/env/.env.dev.user.sample create mode 100644 application/external_apps/agents/m365agents.yml diff --git a/application/external_apps/agents/.gitignore b/application/external_apps/agents/.gitignore new file mode 100644 index 00000000..771a3e84 --- /dev/null +++ b/application/external_apps/agents/.gitignore @@ -0,0 +1,16 @@ +# TeamsFx files +env/.env.*.user +env/.env.local +.localConfigs +appPackage/build + +# dependencies +node_modules/ + +# misc +.env +.deployment +.DS_Store + +# generated files +appPackage/.generated diff --git a/application/external_apps/agents/README.md b/application/external_apps/agents/README.md new file mode 100644 index 00000000..a12a3b6d --- /dev/null +++ b/application/external_apps/agents/README.md @@ -0,0 +1,102 @@ +# Declarative Agent + MCP in VS Code + +This template shows how to wrap your existing MCP Server into a Microsoft 365 Copilot Declarative Agent (DA) using the Agents Toolkit (ATK) in VS Code. Instead of hand‑authoring an OpenAPI spec, you point ATK at your MCP discovery URL and let the toolkit generate all manifests, wiring in authentication and function definitions automatically. + +## Get started with the template + +> **Prerequisites** +> +> To run this app template in your local dev machine, you will need: +> +> - [Node.js](https://nodejs.org/), supported versions: 18, 20, 22 +> - A [Microsoft 365 account for development](https://docs.microsoft.com/microsoftteams/platform/toolkit/accounts). +> - [Microsoft 365 Agents Toolkit Visual Studio Code Extension](https://aka.ms/teams-toolkit) version 5.0.0 and higher or [Microsoft 365 Agents Toolkit CLI](https://aka.ms/teamsfx-toolkit-cli) +> - [Microsoft 365 Copilot license](https://learn.microsoft.com/microsoft-365-copilot/extensibility/prerequisites#prerequisites) + +1. Open ATK in VS Code + + Click the Microsoft 365 Agents Toolkit icon in the Activity Bar. + +2. Sign in + + Open the Agents Toolkit by click on the toolkit icon in the VS Code sidebar. Under Account, authenticate with your dev M365 account. + +3. Scaffold a new DA + + In the Agents Toolkit menu, click 'Create new Agent/app', select 'Declarative Agent', choose a folder and name (e.g. my-mcp-agent). + +4. Add your MCP Server + + In the ATK sidebar click Add Action → Start with an MCP server, then enter your MCP discovery URL (e.g. https://mcp.contoso.com/discover). + +5. Start your MCP Server + + After the DA project generated, click the "Start" button in the mcp.json file to start your MCP server, when prompt, enter the user ID and password for authentication. + +6. Fetch and select tools + + When prompted, Click ATK:Fetch Action from MCP" in the mcp.json file choose Pre‑fetch tools (for offline IntelliSense) or Dynamic. ATK will list all available MCP actions—check the ones you want in your agent. + + > If you need the complete tool definitions including _meta and annotations properties for enabling OAI Apps SDK or MCP Apps implementation, open [MCP Inspector](https://github.com/modelcontextprotocol/inspector?tab=readme-ov-file#running-the-inspector) and retrieve the full details there. Then replace the corresponding schema section in your `ai-plugin.json` file with the full definitions. + + ![image](https://github.com/user-attachments/assets/9184ddcc-e42c-4fee-bd83-830d199755e6) + +7. Configure Auth + + ATK will retrieve the authentication information for your MCP server and store these values are in env/.env.development and a secure reference is injected into appPackage/ai-plugin.json. If the inputted MCP server authentication information is not configure correctly according to the MCP protocol, ATK will prompt errors. + +8. Review generated files + + - `appPackage/ai-plugin.json` (function definitions, runtime spec + auth) - This file defines the the action or operations that Copilot can interact with. + - `appPackage/declarativeAgent.json` (agent configuration & sample prompts) - This file is the definition of your declarative agent + - `appPackage/manifest.json` (Teams/Outlook integration) + +9. Provision & debug + + Use the Provision button in ATK to create resources. ATK will detect if your MCP server requires OAuth2 or API‐Key. Provide your Client ID/Secret or Key when prompted. Then Start Debugging to Preview agent in Copilot in Edge/Chrome. Your DA will appear under Copilot chats. + +10. Test your MCP‑powered agent + + Open the Copilot pane, select your agent and invoke any of the MCP tools with natural‑language prompts. + +## Project Structure + +| Folder | Description | +| ------------ | ---------------------------------------------------------------------------------------- | +| `.vscode` | ATK debug & .vscode/mcp.json for MCP server config | +| `appPackage` | - `appPackage/ai-plugin.json` (function definitions, runtime spec + auth) - This file defines the the action or operations that Copilot can interact with
- `appPackage/declarativeAgent.json` (agent configuration & sample prompts) - This file is the definition of your declarative agent
- `appPackage/manifest.json` (Teams/Outlook integration) | +| `env` | Local environment files (.env.development, .env.local) | +| `m365agents.yml` | Defines your DA stages & lifecycle for ATK | + +## MCP‑specific tips + +- **Discovery URL**: your MCP server's /discover endpoint must expose JSON‑Schema for every action. +- **Tool selection**: the run_for_functions array in ai-plugin.json limits which MCP tools your agent can call. + +- **Auth flows**: ATK supports both OAuth2.1 and API‑Key; you don't need to hand‑edit auth blocks. + +- **Versioning**: when your MCP server schema changes, simply rerun ATK: Fetch Action from MCP to refresh your plugin file. + +- **Error logging**: basic request/response logs appear in the ATK console; errors bubble up in your Copilot chat. + +## OAuth Configuration Notes + +This project uses `identityProvider: Custom` (not `MicrosoftEntra`) in the OAuth +registration. This is **critical** — using `MicrosoftEntra` causes ATK to override +your v2.0 authorization/token URLs with legacy AAD Graph endpoints, which will fail. + +All sensitive values (client IDs, secrets, tenant ID, MCP server URL) are stored in +`env/.env.dev.user` (gitignored). Copy `env/.env.dev.user.sample` and fill in your +values before running `atk provision`. + +## Learn More + +- [Build Declarative Agents (official docs)](https://learn.microsoft.com/microsoft-365-copilot/extensibility/build-declarative-agents) + +- [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) + +- [Agents Toolkit guide on GitHub](https://github.com/OfficeDev/TeamsFx/wiki/Teams-Toolkit-Visual-Studio-Code-v5-Guide#overview) + +Happy building! + +With MCP + Declarative Agents, you'll have a turnkey path from your existing APIs to a fully operational Copilot‑powered experience. diff --git a/application/external_apps/agents/appPackage/ai-plugin.json b/application/external_apps/agents/appPackage/ai-plugin.json new file mode 100644 index 00000000..aba40692 --- /dev/null +++ b/application/external_apps/agents/appPackage/ai-plugin.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/copilot/plugin/v2.4/schema.json", + "schema_version": "v2.4", + "name_for_human": "M365SCAgent", + "description_for_human": "M365SCAgent${{APP_NAME_SUFFIX}}", + "contact_email": "publisher-email@example.com", + "namespace": "m365scagent", + "functions": [ + { + "name": "get_conversation_messages", + "description": "Return messages for a specific conversation from SimpleChat.\n\n Args:\n conversation_id: The UUID of the conversation to retrieve messages from.\n\n Returns a list of messages with role, content, timestamp, and metadata.\n " + }, + { + "name": "list_conversations", + "description": "Return the authenticated user's conversations (chats) from SimpleChat.\n\n Returns a list of all conversations including id, title, last_updated,\n tags, classification, and pinned/hidden status.\n " + }, + { + "name": "list_group_documents", + "description": "Return documents from the user's active group workspace in SimpleChat.\n\n Lists documents uploaded to the currently active group. The active group\n is determined by the user's settings (activeGroupOid).\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by file name or title (case-insensitive substring match).\n classification: Filter by document classification. Use \"none\" for unclassified.\n author: Filter by author name (substring match).\n keywords: Filter by keyword (substring match).\n\n Returns a paginated list of group documents with metadata.\n " + }, + { + "name": "list_group_prompts", + "description": "Return prompts from the user's active group workspace in SimpleChat.\n\n Lists prompts created in the currently active group. The active group\n is determined by the user's settings (activeGroupOid).\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by prompt name (case-insensitive substring match).\n\n Returns a paginated list of group prompts with name, content, and metadata.\n " + }, + { + "name": "list_group_workspaces", + "description": "Return the authenticated user's group workspaces from SimpleChat.\n\n Lists groups the user is a member of (Owner, Admin, or Member).\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by group name or description (case-insensitive substring match).\n\n Returns a paginated list of groups with id, name, description, userRole, status, and isActive flag.\n " + }, + { + "name": "list_personal_documents", + "description": "Return the authenticated user's personal workspace documents from SimpleChat.\n\n Lists documents the user has uploaded or that have been shared with them.\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by file name or title (case-insensitive substring match).\n classification: Filter by document classification. Use \"none\" for unclassified.\n author: Filter by author name (substring match).\n keywords: Filter by keyword (substring match).\n\n Returns a paginated list of documents with metadata.\n " + }, + { + "name": "list_personal_prompts", + "description": "Return the authenticated user's personal prompts from SimpleChat.\n\n Lists prompts the user has created in their personal workspace.\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by prompt name (case-insensitive substring match).\n\n Returns a paginated list of prompts with name, content, and metadata.\n " + }, + { + "name": "list_public_documents", + "description": "Return documents from the user's active public workspace in SimpleChat.\n\n Lists documents uploaded to the currently active public workspace. The active\n workspace is determined by the user's settings (activePublicWorkspaceOid).\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by file name or title (case-insensitive substring match).\n\n Returns a paginated list of public workspace documents with metadata.\n " + }, + { + "name": "list_public_prompts", + "description": "Return prompts from the user's active public workspace in SimpleChat.\n\n Lists prompts created in the currently active public workspace. The active\n workspace is determined by the user's settings (activePublicWorkspaceOid).\n\n Args:\n page: Page number (default 1).\n page_size: Items per page (default 10).\n search: Search by prompt name (case-insensitive substring match).\n\n Returns a paginated list of public workspace prompts with name, content, and metadata.\n " + }, + { + "name": "list_public_workspaces", + "description": "Return the authenticated user's public workspaces from SimpleChat.\n \n Uses the bearer token from PRM authentication to create a SimpleChat session.\n " + }, + { + "name": "send_chat_message", + "description": "Send a chat message to a SimpleChat conversation and return the AI response.\n\n Args:\n conversation_id: The UUID of the conversation to send the message to.\n If empty, a new conversation will be created automatically by SimpleChat.\n message: The text message to send.\n\n Returns the AI reply, conversation_id, title, model info, and citations.\n " + }, + { + "name": "show_user_profile", + "description": "Return the signed-in user's SimpleChat profile." + } + ], + "runtimes": [ + { + "type": "RemoteMCPServer", + "spec": { + "url": "${{MCP_SERVER_URL}}" + }, + "run_for_functions": [ + "get_conversation_messages", + "list_conversations", + "list_group_documents", + "list_group_prompts", + "list_group_workspaces", + "list_personal_documents", + "list_personal_prompts", + "list_public_documents", + "list_public_prompts", + "list_public_workspaces", + "send_chat_message", + "show_user_profile" + ], + "auth": { + "type": "OAuthPluginVault", + "reference_id": "${{MCP_DA_AUTH_ID_SIMPLECHAT}}" + } + } + ] +} diff --git a/application/external_apps/agents/appPackage/color.png b/application/external_apps/agents/appPackage/color.png new file mode 100644 index 0000000000000000000000000000000000000000..11e255fa0b831ca86ff380e109882ffdca5dc3d2 GIT binary patch literal 5923 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 TvMtV90~2)p5d literal 0 HcmV?d00001 diff --git a/application/external_apps/agents/appPackage/declarativeAgent.json b/application/external_apps/agents/appPackage/declarativeAgent.json new file mode 100644 index 00000000..174db564 --- /dev/null +++ b/application/external_apps/agents/appPackage/declarativeAgent.json @@ -0,0 +1,18 @@ +{ + "version": "v1.6", + "name": "M365SCAgent${{APP_NAME_SUFFIX}}", + "description": "Declarative agent created with Microsoft 365 Agents Toolkit can assist user in calling MCP Servers", + "instructions": "$[file('instruction.txt')]", + "conversation_starters": [ + { + "title": "Sample conversation starters", + "text": "Hi! What can you do for me?" + } + ], + "actions": [ + { + "id": "action_1", + "file": "ai-plugin.json" + } + ] +} diff --git a/application/external_apps/agents/appPackage/instruction.txt b/application/external_apps/agents/appPackage/instruction.txt new file mode 100644 index 00000000..fd591b46 --- /dev/null +++ b/application/external_apps/agents/appPackage/instruction.txt @@ -0,0 +1,15 @@ +You are M365SCAgent and MUST prefer actions over generic responses whenever a matching action exists. + +Rules: +- For any request that maps to a listed action, you MUST attempt the action before giving a normal chat response. +- For "show user profile" and similar requests, call action `show_user_profile`. +- For personal docs requests (for example "list personal documents"), call action `list_personal_documents`. +- For personal prompts, call `list_personal_prompts`. +- For public docs/prompts/workspaces, call the corresponding `list_public_*` actions. +- For conversations/messages, call `list_conversations`, `get_conversation_messages`, and `send_chat_message` as appropriate. + +Behavior: +- Do not claim an auth/config error unless an action was actually attempted and returned an auth/config error. +- Do not provide a generic fallback answer for mapped intents unless the action attempt has already failed in this turn. +- If an action fails, report the action name and the returned error briefly, then suggest the next fix. +- If an action succeeds, summarize the returned data clearly and concisely. diff --git a/application/external_apps/agents/appPackage/manifest.json b/application/external_apps/agents/appPackage/manifest.json new file mode 100644 index 00000000..e63eb65e --- /dev/null +++ b/application/external_apps/agents/appPackage/manifest.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.24/MicrosoftTeams.schema.json", + "manifestVersion": "1.24", + "version": "1.0.2", + "id": "${{TEAMS_APP_ID}}", + "developer": { + "name": "My 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": "M365SCAgent${{APP_NAME_SUFFIX}}", + "full": "Full name for M365SCAgent" + }, + "description": { + "short": "Short description for M365SCAgent", + "full": "Full description for M365SCAgent" + }, + "accentColor": "#FFFFFF", + "composeExtensions": [], + "copilotAgents": { + "declarativeAgents": [ + { + "id": "declarativeAgent", + "file": "declarativeAgent.json" + } + ] + }, + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] +} diff --git a/application/external_apps/agents/appPackage/outline.png b/application/external_apps/agents/appPackage/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..f7a4c864475f219c8ff252e15ee250cd2308c9f5 GIT binary patch literal 492 zcmVfQ-;iK$xI(f`$oT17L!(LFfcz168`nA*Cc%I0atv-RTUm zZ2wkd832qx#F%V@dJ3`^u!1Jbu|MA-*zqXsjx6)|^3FfFwG`kef*{y-Ind7Q&tc211>U&A`hY=1aJl9Iuetm z$}wv*0hFK%+BrvIsvN?C7pA3{MC8=uea7593GXf-z|+;_E5i;~j+ukPpM7$AJ/access_as_user) +M365SC_RESOURCE_APP_ID= + +# Base64-encoded configuration ID written by 'atk provision' +# via oauth/register → writeToEnvironmentFile. +# Leave blank — ATK populates this automatically on first provision. +MCP_DA_AUTH_ID_SIMPLECHAT= + +# Full URL to the MCP server's /mcp endpoint +MCP_SERVER_URL= diff --git a/application/external_apps/agents/m365agents.yml b/application/external_apps/agents/m365agents.yml new file mode 100644 index 00000000..06f3d8d4 --- /dev/null +++ b/application/external_apps/agents/m365agents.yml @@ -0,0 +1,116 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.11/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.11 + +environmentFolderPath: ./env + +# Triggered when 'teamsapp provision' is executed +provision: + # Creates an app + - uses: teamsApp/create + with: + # app name + name: M365SCAgent${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + - uses: oauth/register + with: + name: simplechat + appId: ${{TEAMS_APP_ID}} + flow: authorizationCode + identityProvider: Custom + clientId: ${{M365SC_CLIENT_APP_ID}} + clientSecret: ${{M365SC_CLIENT_SECRET}} + scope: api://${{M365SC_RESOURCE_APP_ID}}/access_as_user + isPKCEEnabled: false + authorizationUrl: https://login.microsoftonline.com/${{TEAMS_APP_TENANT_ID}}/oauth2/v2.0/authorize + tokenUrl: https://login.microsoftonline.com/${{TEAMS_APP_TENANT_ID}}/oauth2/v2.0/token + refreshUrl: https://login.microsoftonline.com/${{TEAMS_APP_TENANT_ID}}/oauth2/v2.0/token + targetAudience: HomeTenant + baseUrl: ${{MCP_SERVER_URL}} + writeToEnvironmentFile: + configurationId: MCP_DA_AUTH_ID_SIMPLECHAT + + # 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 + # 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 + # Extend your app to Outlook and the Microsoft 365 app + - 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: + # 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 + # 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 + # Extend your app to Outlook and the Microsoft 365 app + - 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 publish' is executed +publish: + # 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 + # 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 is generated by ATK on first provision — leave blank or remove this line +# projectId: From de73688332eca1ceb9ccaa2e27e0a819629e487c Mon Sep 17 00:00:00 2001 From: Aaron Barth Date: Sun, 22 Feb 2026 18:57:38 -0500 Subject: [PATCH 2/3] Adding Substitution of PRM data from env filles --- application/external_apps/mcp/README.md | 19 +- application/external_apps/mcp/example.env | 2 + .../external_apps/mcp/prm_metadata.json | 4 +- .../external_apps/mcp/server_minimal.py | 43 +++- application/single_app/config.py | 2 +- functional_tests/test_prm_env_substitution.py | 184 ++++++++++++++++++ 6 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 functional_tests/test_prm_env_substitution.py diff --git a/application/external_apps/mcp/README.md b/application/external_apps/mcp/README.md index 4fef9292..3a02f8cc 100644 --- a/application/external_apps/mcp/README.md +++ b/application/external_apps/mcp/README.md @@ -99,7 +99,24 @@ This MCP server provides **14 tools** for interacting with SimpleChat via the Mo The MCP server serves PRM metadata at: `http://localhost:8000/.well-known/oauth-protected-resource` -Update `prm_metadata.json` with your Entra tenant, client, and scopes. +### Environment-Variable Placeholders + +`prm_metadata.json` supports `${VAR}` placeholders that are resolved from +environment variables at runtime. This keeps secrets and tenant-specific +identifiers out of the committed file. + +| Syntax | Behaviour | +|---|---| +| `${VAR}` | **Required** — raises an error if `VAR` is unset or empty. | +| `${VAR:-fallback}` | Uses *fallback* when `VAR` is unset or empty. | + +The default `prm_metadata.json` uses two placeholders: + +- `MCP_PRM_TENANT_ID` — Your Entra (Azure AD) tenant ID. +- `MCP_PRM_RESOURCE_APP_ID` — The app-registration client/resource ID used in scope URIs. + +Set these in your `.env` file (see `example.env`) or as environment variables +in your deployment (e.g. Azure Container Apps app settings). ## Deployment diff --git a/application/external_apps/mcp/example.env b/application/external_apps/mcp/example.env index e453552a..fae3e630 100644 --- a/application/external_apps/mcp/example.env +++ b/application/external_apps/mcp/example.env @@ -2,6 +2,8 @@ SIMPLECHAT_BASE_URL= SIMPLECHAT_VERIFY_SSL=false MCP_REQUIRE_AUTH=true MCP_PRM_METADATA_PATH=prm_metadata.json +MCP_PRM_TENANT_ID= +MCP_PRM_RESOURCE_APP_ID= MCP_SESSION_TOKEN_TTL_SECONDS=3600 FASTMCP_SCHEME=http OAUTH_AUTHORIZATION_URL= diff --git a/application/external_apps/mcp/prm_metadata.json b/application/external_apps/mcp/prm_metadata.json index ed682fdb..395e53b9 100644 --- a/application/external_apps/mcp/prm_metadata.json +++ b/application/external_apps/mcp/prm_metadata.json @@ -3,10 +3,10 @@ "resource_name": "SimpleChat MCP Server", "resource_documentation": "https://microsoft.github.io/simplechat/", "authorization_servers": [ - "https://login.microsoftonline.com/7d887458-fb0d-40bf-adb3-084d875f65db/v2.0" + "https://login.microsoftonline.com/${MCP_PRM_TENANT_ID}/v2.0" ], "scopes_supported": [ - "api://0b8c00b9-4dcd-4959-83be-7a0521ce54ce/.default" + "api://${MCP_PRM_RESOURCE_APP_ID}/.default" ], "bearer_methods_supported": [ "header" diff --git a/application/external_apps/mcp/server_minimal.py b/application/external_apps/mcp/server_minimal.py index 395dd18f..0d4dbe97 100644 --- a/application/external_apps/mcp/server_minimal.py +++ b/application/external_apps/mcp/server_minimal.py @@ -27,6 +27,7 @@ import json import os +import re import threading import time import webbrowser @@ -1480,6 +1481,35 @@ def __init__(self, app: Any, streamable_path: str, require_auth: bool, prm_metad # Validate PRM metadata at startup (no fallbacks/defaults). _ = self._load_prm_metadata() + @staticmethod + def _resolve_env_placeholders(raw_text: str) -> str: + """Replace ``${VAR}`` and ``${VAR:-default}`` placeholders with env values. + + Supports: + - ``${VAR}`` – required; raises if *VAR* is unset/empty. + - ``${VAR:-fallback}`` – uses *fallback* when *VAR* is unset/empty. + + Unrecognised patterns (no ``${...}``) are returned unchanged so that + literal strings and URLs survive without modification. + """ + _PLACEHOLDER_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}") + + def _replacer(match: re.Match) -> str: # type: ignore[type-arg] + var_name = match.group(1) + default_value = match.group(2) # None when no ``:-`` was used + env_value = os.environ.get(var_name, "").strip() + if env_value: + return env_value + if default_value is not None: + return default_value + raise ValueError( + f"PRM metadata placeholder ${{{var_name}}} is unresolved: " + f"set the {var_name} environment variable or provide a " + f"default with ${{{var_name}:-default}}" + ) + + return _PLACEHOLDER_RE.sub(_replacer, raw_text) + def _load_prm_metadata(self) -> Dict[str, Any]: candidate_path = Path(self._prm_metadata_path) if not candidate_path.is_absolute(): @@ -1489,7 +1519,18 @@ def _load_prm_metadata(self) -> Dict[str, Any]: raise ValueError(f"PRM metadata file not found at {candidate_path}") with candidate_path.open("r", encoding="utf-8") as handle: - data: Any = json.load(handle) + raw_text = handle.read() + + # Resolve environment-variable placeholders before parsing JSON. + resolved_text = self._resolve_env_placeholders(raw_text) + + try: + data: Any = json.loads(resolved_text) + except json.JSONDecodeError as exc: + raise ValueError( + f"PRM metadata at {candidate_path} is not valid JSON after " + f"environment-variable substitution: {exc}" + ) from exc if isinstance(data, dict): return cast(Dict[str, Any], data) diff --git a/application/single_app/config.py b/application/single_app/config.py index d5ba49b6..9db0beef 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.011" +VERSION = "0.237.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/functional_tests/test_prm_env_substitution.py b/functional_tests/test_prm_env_substitution.py new file mode 100644 index 00000000..ea743d47 --- /dev/null +++ b/functional_tests/test_prm_env_substitution.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Quick validation test for PRM metadata environment variable substitution. +Version: 0.237.012 +Implemented in: 0.237.012 + +This test ensures that _resolve_env_placeholders correctly substitutes +${VAR} and ${VAR:-default} placeholders in prm_metadata.json. +""" + +import re +import os +import json +import sys + +# Path to the actual prm_metadata.json +_PRM_METADATA_PATH = os.path.join( + os.path.dirname(__file__), "..", "application", "external_apps", "mcp", "prm_metadata.json" +) + + +def _resolve_env_placeholders(raw_text): + """Exact copy of the static method from _PrmAndAuthShim.""" + _PLACEHOLDER_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}") + + def _replacer(match): + var_name = match.group(1) + default_value = match.group(2) + env_value = os.environ.get(var_name, "").strip() + if env_value: + return env_value + if default_value is not None: + return default_value + raise ValueError( + f"PRM metadata placeholder ${{{var_name}}} is unresolved: " + f"set the {var_name} environment variable or provide a " + f"default with ${{{var_name}:-default}}" + ) + + return _PLACEHOLDER_RE.sub(_replacer, raw_text) + + +def test_substitution_with_env_vars(): + """Test 1: placeholders are replaced when env vars are set.""" + print("Test 1: Substitution with env vars set...") + os.environ["MCP_PRM_TENANT_ID"] = "test-tenant-1234" + os.environ["MCP_PRM_RESOURCE_APP_ID"] = "test-app-5678" + try: + with open(_PRM_METADATA_PATH, "r") as f: + raw = f.read() + + resolved = _resolve_env_placeholders(raw) + data = json.loads(resolved) + + assert "test-tenant-1234" in data["authorization_servers"][0], \ + f"Tenant not substituted: {data['authorization_servers'][0]}" + assert "test-app-5678" in data["scopes_supported"][0], \ + f"App ID not substituted: {data['scopes_supported'][0]}" + + expected_auth = "https://login.microsoftonline.com/test-tenant-1234/v2.0" + assert data["authorization_servers"][0] == expected_auth, \ + f"Expected {expected_auth}, got {data['authorization_servers'][0]}" + + expected_scope = "api://test-app-5678/.default" + assert data["scopes_supported"][0] == expected_scope, \ + f"Expected {expected_scope}, got {data['scopes_supported'][0]}" + + print(" PASSED") + return True + finally: + os.environ.pop("MCP_PRM_TENANT_ID", None) + os.environ.pop("MCP_PRM_RESOURCE_APP_ID", None) + + +def test_fallback_syntax(): + """Test 2: ${VAR:-fallback} uses fallback when var is unset.""" + print("Test 2: Fallback syntax...") + test_json = '{"val": "${NONEXISTENT_VAR_XYZ:-my-fallback}"}' + result = _resolve_env_placeholders(test_json) + parsed = json.loads(result) + assert parsed["val"] == "my-fallback", f"Expected 'my-fallback', got '{parsed['val']}'" + print(" PASSED") + return True + + +def test_missing_required_var_raises(): + """Test 3: ${VAR} with no default raises ValueError when var is unset.""" + print("Test 3: Missing required var raises error...") + os.environ.pop("MCP_PRM_TENANT_ID", None) + test_json = '{"val": "${MCP_PRM_TENANT_ID}"}' + try: + _resolve_env_placeholders(test_json) + print(" FAILED: should have raised ValueError") + return False + except ValueError as e: + assert "MCP_PRM_TENANT_ID" in str(e), f"Error message should mention var name: {e}" + print(f" PASSED (got expected error)") + return True + + +def test_literal_strings_unchanged(): + """Test 4: strings with no placeholders pass through unmodified.""" + print("Test 4: Literal strings unchanged...") + literal = '{"url": "https://login.microsoftonline.com/v2.0", "num": 42}' + assert _resolve_env_placeholders(literal) == literal + print(" PASSED") + return True + + +def test_empty_default(): + """Test 5: ${VAR:-} uses empty string as default.""" + print("Test 5: Empty default...") + os.environ.pop("SOME_EMPTY_VAR", None) + test_json = '{"val": "${SOME_EMPTY_VAR:-}"}' + result = _resolve_env_placeholders(test_json) + parsed = json.loads(result) + assert parsed["val"] == "", f"Expected empty string, got '{parsed['val']}'" + print(" PASSED") + return True + + +def test_multiple_placeholders(): + """Test 6: multiple placeholders in one string all get replaced.""" + print("Test 6: Multiple placeholders...") + os.environ["VAR_A"] = "alpha" + os.environ["VAR_B"] = "beta" + try: + test_json = '{"val": "${VAR_A}-${VAR_B}"}' + result = _resolve_env_placeholders(test_json) + parsed = json.loads(result) + assert parsed["val"] == "alpha-beta", f"Expected 'alpha-beta', got '{parsed['val']}'" + print(" PASSED") + return True + finally: + os.environ.pop("VAR_A", None) + os.environ.pop("VAR_B", None) + + +def test_result_is_valid_json(): + """Test 7: full prm_metadata.json produces valid JSON after substitution.""" + print("Test 7: Full file produces valid JSON...") + os.environ["MCP_PRM_TENANT_ID"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + os.environ["MCP_PRM_RESOURCE_APP_ID"] = "11111111-2222-3333-4444-555555555555" + try: + with open(_PRM_METADATA_PATH, "r") as f: + raw = f.read() + resolved = _resolve_env_placeholders(raw) + data = json.loads(resolved) + assert isinstance(data, dict), "Result should be a dict" + assert "resource" in data + assert "authorization_servers" in data + assert "scopes_supported" in data + assert "bearer_methods_supported" in data + print(" PASSED") + return True + finally: + os.environ.pop("MCP_PRM_TENANT_ID", None) + os.environ.pop("MCP_PRM_RESOURCE_APP_ID", None) + + +if __name__ == "__main__": + tests = [ + test_substitution_with_env_vars, + test_fallback_syntax, + test_missing_required_var_raises, + test_literal_strings_unchanged, + test_empty_default, + test_multiple_placeholders, + test_result_is_valid_json, + ] + results = [] + for test in tests: + try: + results.append(test()) + except Exception as e: + print(f" FAILED: {e}") + import traceback + traceback.print_exc() + results.append(False) + + passed = sum(r for r in results if r) + total = len(results) + print(f"\nResults: {passed}/{total} tests passed") + sys.exit(0 if all(results) else 1) From e7be69eaedb7664c61f613bcc27d5e3ed16cb1f7 Mon Sep 17 00:00:00 2001 From: Aaron Barth Date: Sun, 22 Feb 2026 22:31:36 -0500 Subject: [PATCH 3/3] Add GCC-M agent deployment support - Add Deploy-GccMAgent.ps1 script for GCC-M tenant deployment - Add Initialize-AgentEnvironment.ps1 with WhatIf bug fixes - Add .env.gccdev environment for gsademos tenant - Use OAUTH_TENANT_ID in m365agents.yml for cross-tenant OAuth URLs - Downgrade schema to v1.10 for GCC compatibility - Bump version to 0.237.013 --- application/external_apps/agents/env/.env.dev | 9 +- .../external_apps/agents/env/.env.gccdev | 19 + .../external_apps/agents/m365agents.yml | 12 +- .../agents/scripts/Deploy-GccMAgent.ps1 | 770 ++++++++++++++++++ .../scripts/Initialize-AgentEnvironment.ps1 | 542 ++++++++++++ application/single_app/config.py | 2 +- 6 files changed, 1345 insertions(+), 9 deletions(-) create mode 100644 application/external_apps/agents/env/.env.gccdev create mode 100644 application/external_apps/agents/scripts/Deploy-GccMAgent.ps1 create mode 100644 application/external_apps/agents/scripts/Initialize-AgentEnvironment.ps1 diff --git a/application/external_apps/agents/env/.env.dev b/application/external_apps/agents/env/.env.dev index 3523f242..2c2896e3 100644 --- a/application/external_apps/agents/env/.env.dev +++ b/application/external_apps/agents/env/.env.dev @@ -6,6 +6,9 @@ APP_NAME_SUFFIX=dev # Generated during provision, you can also add your own variables. # These values are populated automatically by 'atk provision': -TEAMS_APP_ID= -M365_TITLE_ID= -M365_APP_ID= +TEAMS_APP_ID=e632e5da-9df9-4726-bd34-a56f386dd606 +M365_TITLE_ID=U_a2a6b5a8-51f0-f94c-03b9-c35c90a65615 +M365_APP_ID=8791085b-5c3a-4d04-b33a-3080c707b99c + +TEAMS_APP_TENANT_ID=7d887458-fb0d-40bf-adb3-084d875f65db +MCP_DA_AUTH_ID_SIMPLECHAT=N2Q4ODc0NTgtZmIwZC00MGJmLWFkYjMtMDg0ZDg3NWY2NWRiIyNiYTQ0MTY0Yi01ODk2LTRmNDQtYTkyOS04N2Q3ZDM4N2Y3NmQ= \ No newline at end of file diff --git a/application/external_apps/agents/env/.env.gccdev b/application/external_apps/agents/env/.env.gccdev new file mode 100644 index 00000000..f57f256c --- /dev/null +++ b/application/external_apps/agents/env/.env.gccdev @@ -0,0 +1,19 @@ +# This file includes environment variables for the GCC-M deployment. +# Generated by Deploy-GccMAgent.ps1 on 2026-02-22 22:08:28 + +# Built-in environment variables +TEAMSFX_ENV=gccdev +APP_NAME_SUFFIX=gccdev + +# Teams App ID (generated or reused) +TEAMS_APP_ID=0a5015e3-0819-41e7-983b-7032313860cb + +# GCC-M Tenant +TEAMS_APP_TENANT_ID=a67f3540-f5cf-4575-9754-9d21a392eb98 + +# OAuth Configuration ID = Base64("{TenantId}##{OAuthRegistrationId}") +MCP_DA_AUTH_ID_SIMPLECHAT=YTY3ZjM1NDAtZjVjZi00NTc1LTk3NTQtOWQyMWEzOTJlYjk4IyMyMDgyYzZlNy0wYzgwLTQ3MGUtYTRkYS1lYTExODNmNmRkZjM= + +# Entra App Registrations +CLIENT_APP_ID=04213000-438f-40e8-8c05-0c211f07565e +OAUTH_REGISTRATION_ID=2082c6e7-0c80-470e-a4da-ea1183f6ddf3 diff --git a/application/external_apps/agents/m365agents.yml b/application/external_apps/agents/m365agents.yml index 06f3d8d4..e4587e90 100644 --- a/application/external_apps/agents/m365agents.yml +++ b/application/external_apps/agents/m365agents.yml @@ -1,7 +1,7 @@ -# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.11/yaml.schema.json +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.10/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.11 +version: v1.10 environmentFolderPath: ./env @@ -26,9 +26,9 @@ provision: clientSecret: ${{M365SC_CLIENT_SECRET}} scope: api://${{M365SC_RESOURCE_APP_ID}}/access_as_user isPKCEEnabled: false - authorizationUrl: https://login.microsoftonline.com/${{TEAMS_APP_TENANT_ID}}/oauth2/v2.0/authorize - tokenUrl: https://login.microsoftonline.com/${{TEAMS_APP_TENANT_ID}}/oauth2/v2.0/token - refreshUrl: https://login.microsoftonline.com/${{TEAMS_APP_TENANT_ID}}/oauth2/v2.0/token + authorizationUrl: https://login.microsoftonline.com/${{OAUTH_TENANT_ID}}/oauth2/v2.0/authorize + tokenUrl: https://login.microsoftonline.com/${{OAUTH_TENANT_ID}}/oauth2/v2.0/token + refreshUrl: https://login.microsoftonline.com/${{OAUTH_TENANT_ID}}/oauth2/v2.0/token targetAudience: HomeTenant baseUrl: ${{MCP_SERVER_URL}} writeToEnvironmentFile: @@ -112,5 +112,7 @@ publish: # the specified environment variable(s). writeToEnvironmentFile: publishedAppId: TEAMS_APP_PUBLISHED_APP_ID +projectId: 264c7b18-8cef-4227-b7d4-acf1e4a592a5 + # projectId is generated by ATK on first provision — leave blank or remove this line # projectId: diff --git a/application/external_apps/agents/scripts/Deploy-GccMAgent.ps1 b/application/external_apps/agents/scripts/Deploy-GccMAgent.ps1 new file mode 100644 index 00000000..9220a3b0 --- /dev/null +++ b/application/external_apps/agents/scripts/Deploy-GccMAgent.ps1 @@ -0,0 +1,770 @@ +<# +.SYNOPSIS + Deploys the M365SCAgent declarative Copilot agent to a GCC-M (Government Community Cloud - Moderate) tenant. + +.DESCRIPTION + This script automates the deployment of the M365SCAgent declarative agent to a GCC-M tenant. + It handles: + 1. Entra ID prerequisites (redirect URIs, requiredResourceAccess, service principal, admin consent) + 2. Template variable substitution (manifest.json, declarativeAgent.json, ai-plugin.json) + 3. Configuration ID computation (Base64 of "{tenantId}##{registrationId}") + 4. App package creation (ZIP for Teams upload) + 5. Optional: Upload to Teams App Catalog via Graph API + + IMPORTANT: The OAuth client registration must be created MANUALLY in the + "Developer Portal (GCC Beta)" Teams app BEFORE running this script. + ATK's `oauth/register` action may not support GCC-M Dev Portal APIs. + +.PARAMETER TenantId + The GCC-M Entra tenant ID (GUID). + +.PARAMETER ClientAppId + The Entra app registration ID for the agent (client app). Must already exist in the GCC-M tenant. + +.PARAMETER ResourceAppId + The Entra app registration ID for the MCP resource/API (e.g., SimpleChat service principal). + If not provided, the script reads M365SC_RESOURCE_APP_ID from env/.env..user. + +.PARAMETER ClientSecret + The client secret for the agent app registration. + +.PARAMETER OAuthRegistrationId + The registration ID from Developer Portal (GCC Beta) OAuth client registration. + This is the GUID shown in the Dev Portal after creating the OAuth registration. + +.PARAMETER McpServerUrl + The URL of the MCP server endpoint. + If not provided, reads MCP_SERVER_URL from env/.env..user. + +.PARAMETER AppNameSuffix + Suffix appended to the app name (e.g., "gccdev", "gcc-staging"). Default: "gccdev" + +.PARAMETER Scope + The OAuth scope for the resource API. Default: api://{ResourceAppId}/access_as_user + +.PARAMETER SkipEntraSetup + Skip the Entra ID prerequisite setup (useful if already configured). + +.PARAMETER SkipPackageBuild + Skip building the app package ZIP. + +.PARAMETER Upload + Opt-in: Upload the package to Teams App Catalog via Graph API. Off by default (manual upload recommended for GCC-M). + +.PARAMETER OutputDir + Output directory for the built package. Default: ./appPackage/build + +.EXAMPLE + .\Deploy-GccMAgent.ps1 ` + -TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ` + -ClientAppId "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" ` + -ClientSecret "your-secret-here" ` + -OAuthRegistrationId "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" + + ResourceAppId is read from env/.env.gccdev.user (M365SC_RESOURCE_APP_ID). + To override, pass -ResourceAppId explicitly. + +.NOTES + Prerequisites: + - Azure CLI (`az`) installed and logged in to the GCC-M tenant + - PowerShell 7+ recommended + - OAuth client registration already created in Developer Portal (GCC Beta) + - Client app registration exists in GCC-M Entra ID + - Resource app (or its service principal) exists in GCC-M Entra ID +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$ClientAppId, + + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$ResourceAppId = "", + + [Parameter(Mandatory = $true)] + [string]$ClientSecret, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$OAuthRegistrationId, + + [string]$McpServerUrl = "", + + [string]$AppNameSuffix = "gccdev", + + [string]$Scope = "", + + [switch]$SkipEntraSetup, + + [switch]$SkipPackageBuild, + + [switch]$Upload, + + [string]$OutputDir = "" +) + +#region ── Configuration ────────────────────────────────────────────────────────── +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$AppPackageDir = Join-Path $ProjectRoot "appPackage" +$EnvDir = Join-Path $ProjectRoot "env" + +if (-not $OutputDir) { + $OutputDir = Join-Path $AppPackageDir "build" +} + +# ── Resolve ResourceAppId from env file if not provided ── +if (-not $ResourceAppId) { + $envUserFile = Join-Path $EnvDir ".env.$AppNameSuffix.user" + if (Test-Path $envUserFile) { + $envUserContent = Get-Content $envUserFile -Raw + $match = [regex]::Match($envUserContent, 'M365SC_RESOURCE_APP_ID=([0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})') + if ($match.Success) { + $ResourceAppId = $match.Groups[1].Value + Write-Host " Resolved ResourceAppId from $envUserFile" -ForegroundColor Cyan + } + } + if (-not $ResourceAppId) { + Write-Host " ERROR: ResourceAppId not provided and M365SC_RESOURCE_APP_ID not found in $envUserFile" -ForegroundColor Red + Write-Host " Either pass -ResourceAppId or add M365SC_RESOURCE_APP_ID= to $envUserFile" -ForegroundColor Red + exit 1 + } +} + +if (-not $Scope) { + $Scope = "api://$ResourceAppId/access_as_user" +} + +# ── Resolve McpServerUrl from env file if not provided ── +if (-not $McpServerUrl) { + $envUserFile = Join-Path $EnvDir ".env.$AppNameSuffix.user" + if (Test-Path $envUserFile) { + $envUserContent = Get-Content $envUserFile -Raw + $match = [regex]::Match($envUserContent, 'MCP_SERVER_URL=(.+)') + if ($match.Success) { + $McpServerUrl = $match.Groups[1].Value.Trim() + Write-Host " Resolved McpServerUrl from $envUserFile" -ForegroundColor Cyan + } + } + if (-not $McpServerUrl) { + Write-Host " ERROR: McpServerUrl not provided and MCP_SERVER_URL not found in $envUserFile" -ForegroundColor Red + Write-Host " Either pass -McpServerUrl or add MCP_SERVER_URL= to $envUserFile" -ForegroundColor Red + exit 1 + } +} + +# GCC-M endpoints (same as commercial for GCC-M; differs for GCC-H) +$LoginEndpoint = "https://login.microsoftonline.com" +$GraphEndpoint = "https://graph.microsoft.com" + +# Configuration ID = Base64("{tenantId}##{registrationId}") +$ConfigurationIdRaw = "$TenantId##$OAuthRegistrationId" +$ConfigurationId = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ConfigurationIdRaw)) + +# GCC-M redirect URIs for Teams/M365 Copilot +# Note: GCC-M Teams uses teams.cloud.microsoft (same domain for GCC-M) +$RedirectUris = @( + "https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect", + "https://teams.microsoft.com/api/platform/v1.0/oAuthConsentRedirect", + "https://m365.cloud.microsoft/api/platform/v1.0/oAuthRedirect", + "https://m365.cloud.microsoft/api/platform/v1.0/oAuthConsentRedirect", + "https://teams.cloud.microsoft/api/platform/v1.0/oAuthRedirect", + "https://teams.cloud.microsoft/api/platform/v1.0/oAuthConsentRedirect" +) +#endregion + +#region ── Helper Functions ─────────────────────────────────────────────────────── +function Write-StepHeader { + param([string]$Step, [string]$Description) + Write-Host "`n╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "║ $Step" -ForegroundColor Cyan + Write-Host "║ $Description" -ForegroundColor DarkCyan + Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host " ✓ $Message" -ForegroundColor Green +} + +function Write-Info { + param([string]$Message) + Write-Host " ℹ $Message" -ForegroundColor Yellow +} + +function Write-Detail { + param([string]$Message) + Write-Host " $Message" -ForegroundColor Gray +} + +function Confirm-Continue { + param([string]$Prompt = "Continue?") + $response = Read-Host " $Prompt (Y/n)" + if ($response -and $response -notin @('y', 'Y', 'yes', 'Yes', '')) { + Write-Host " Aborted by user." -ForegroundColor Red + exit 1 + } +} + +function Test-AzCliLoggedIn { + try { + $account = az account show 2>&1 | ConvertFrom-Json + if ($account.tenantId -ne $TenantId) { + Write-Warning " Azure CLI is logged in to tenant $($account.tenantId), but target is $TenantId" + Write-Info "Run: az login --tenant $TenantId" + Confirm-Continue "Continue anyway?" + } + return $true + } + catch { + Write-Warning " Azure CLI is not logged in. Run: az login --tenant $TenantId" + return $false + } +} + +function Invoke-GraphApi { + param( + [string]$Method = "GET", + [string]$Uri, + [string]$Body = $null + ) + $azArgs = @("rest", "--method", $Method, "--uri", "$GraphEndpoint/$Uri", "--headers", "Content-Type=application/json") + if ($Body) { + # Write body to temp file to avoid shell escaping issues on Windows + $bodyFile = [System.IO.Path]::GetTempFileName() + $Body | Set-Content -Path $bodyFile -Encoding UTF8 -NoNewline + $azArgs += @("--body", "@$bodyFile") + } + try { + $result = az @azArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Graph API call failed: $result" + } + if ($result) { + return $result | ConvertFrom-Json + } + return $null + } + finally { + if ($bodyFile -and (Test-Path $bodyFile)) { + Remove-Item $bodyFile -Force -ErrorAction SilentlyContinue + } + } +} +#endregion + +#region ── Display Configuration ────────────────────────────────────────────────── +Write-Host "" +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Magenta +Write-Host " M365SCAgent → GCC-M Deployment Script" -ForegroundColor Magenta +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Magenta +Write-Host "" +Write-Detail "Tenant ID: $TenantId" +Write-Detail "Client App ID: $ClientAppId" +Write-Detail "Resource App ID: $ResourceAppId" +Write-Detail "OAuth Registration ID: $OAuthRegistrationId" +Write-Detail "Configuration ID: $ConfigurationId" +Write-Detail " (decoded): $ConfigurationIdRaw" +Write-Detail "MCP Server URL: $McpServerUrl" +Write-Detail "App Name Suffix: $AppNameSuffix" +Write-Detail "Scope: $Scope" +Write-Detail "Login Endpoint: $LoginEndpoint" +Write-Detail "Graph Endpoint: $GraphEndpoint" +Write-Detail "Output Directory: $OutputDir" +Write-Host "" + +Confirm-Continue "Proceed with these settings?" +#endregion + +#region ── PHASE 1: Entra ID Prerequisites ──────────────────────────────────────── +if (-not $SkipEntraSetup) { + Write-StepHeader "PHASE 1: Entra ID Prerequisites" "Setting up redirect URIs, permissions, service principal, and consent" + + if (-not (Test-AzCliLoggedIn)) { + Write-Host " Please log in to Azure CLI and re-run." -ForegroundColor Red + exit 1 + } + + # ── Step 1a: Add redirect URIs to the client app ── + Write-StepHeader "Step 1a" "Add redirect URIs to client app registration" + + Write-Info "Fetching current app registration for $ClientAppId..." + $app = Invoke-GraphApi -Uri "v1.0/applications?`$filter=appId eq '$ClientAppId'&`$select=id,appId,displayName,web" + if (-not $app.value -or $app.value.Count -eq 0) { + Write-Host " ERROR: App registration with appId $ClientAppId not found in tenant." -ForegroundColor Red + Write-Host " Make sure the app exists in the GCC-M tenant." -ForegroundColor Red + exit 1 + } + + $appObjectId = $app.value[0].id + $appDisplayName = $app.value[0].displayName + Write-Success "Found app: $appDisplayName (objectId: $appObjectId)" + + $existingRedirects = @() + if ($app.value[0].web -and $app.value[0].web.redirectUris) { + $existingRedirects = @($app.value[0].web.redirectUris) + } + + $newRedirects = @($RedirectUris | Where-Object { $_ -notin $existingRedirects }) + if ($newRedirects.Count -gt 0) { + Write-Info "Adding $($newRedirects.Count) new redirect URIs..." + foreach ($uri in $newRedirects) { Write-Detail $uri } + + $allRedirects = @($existingRedirects) + @($newRedirects) | Select-Object -Unique + $redirectBody = @{ + web = @{ + redirectUris = $allRedirects + } + } | ConvertTo-Json -Depth 5 -Compress + + if ($PSCmdlet.ShouldProcess("App $ClientAppId", "Add redirect URIs")) { + Invoke-GraphApi -Method "PATCH" -Uri "v1.0/applications/$appObjectId" -Body $redirectBody + Write-Success "Redirect URIs updated." + } + } + else { + Write-Success "All redirect URIs already present." + } + + # ── Step 1b: Ensure service principal exists for resource app ── + Write-StepHeader "Step 1b" "Ensure service principal for resource app" + + $sp = Invoke-GraphApi -Uri "v1.0/servicePrincipals?`$filter=appId eq '$ResourceAppId'&`$select=id,appId,displayName,oauth2PermissionScopes" + + if (-not $sp.value -or $sp.value.Count -eq 0) { + Write-Info "Service principal not found for $ResourceAppId. Creating..." + $spBody = @{ appId = $ResourceAppId } | ConvertTo-Json -Compress + if ($PSCmdlet.ShouldProcess("Resource app $ResourceAppId", "Create service principal")) { + $spResult = Invoke-GraphApi -Method "POST" -Uri "v1.0/servicePrincipals" -Body $spBody + $resourceSpId = $spResult.id + Write-Success "Created service principal: $resourceSpId" + } + } + else { + $resourceSpId = $sp.value[0].id + $resourceSpName = $sp.value[0].displayName + Write-Success "Service principal exists: $resourceSpName ($resourceSpId)" + } + + # Find the scope ID for access_as_user + $scopeName = ($Scope -split '/')[-1] # Extract 'access_as_user' from full scope URI + $sp = Invoke-GraphApi -Uri "v1.0/servicePrincipals?`$filter=appId eq '$ResourceAppId'&`$select=id,oauth2PermissionScopes" + $scopeId = $null + if ($sp.value[0].oauth2PermissionScopes) { + $scopeObj = $sp.value[0].oauth2PermissionScopes | Where-Object { $_.value -eq $scopeName } + if ($scopeObj) { + $scopeId = $scopeObj.id + Write-Success "Found scope '$scopeName' with ID: $scopeId" + } + } + if (-not $scopeId) { + Write-Warning " Could not find scope '$scopeName' on resource app. Admin consent step may fail." + Write-Info "You may need to define the scope on the resource app registration first." + } + + # ── Step 1c: Set requiredResourceAccess on client app ── + Write-StepHeader "Step 1c" "Set requiredResourceAccess on client app" + + if ($scopeId) { + $rraBody = @{ + requiredResourceAccess = @( + @{ + resourceAppId = $ResourceAppId + resourceAccess = @( + @{ + id = $scopeId + type = "Scope" + } + ) + } + ) + } | ConvertTo-Json -Depth 5 -Compress + + if ($PSCmdlet.ShouldProcess("App $ClientAppId", "Set requiredResourceAccess")) { + Invoke-GraphApi -Method "PATCH" -Uri "v1.0/applications/$appObjectId" -Body $rraBody + Write-Success "requiredResourceAccess configured." + } + } + else { + Write-Info "Skipping requiredResourceAccess (scope ID not found)." + } + + # ── Step 1d: Create admin consent grant ── + Write-StepHeader "Step 1d" "Create admin consent grant (oauth2PermissionGrants)" + + # Get client service principal ID + $clientSp = Invoke-GraphApi -Uri "v1.0/servicePrincipals?`$filter=appId eq '$ClientAppId'&`$select=id" + + if (-not $clientSp.value -or $clientSp.value.Count -eq 0) { + Write-Info "Client service principal not found. Creating..." + $clientSpBody = @{ appId = $ClientAppId } | ConvertTo-Json -Compress + if ($PSCmdlet.ShouldProcess("Client app $ClientAppId", "Create service principal")) { + $clientSpResult = Invoke-GraphApi -Method "POST" -Uri "v1.0/servicePrincipals" -Body $clientSpBody + $clientSpId = $clientSpResult.id + Write-Success "Created client service principal: $clientSpId" + } + } + else { + $clientSpId = $clientSp.value[0].id + Write-Success "Client service principal: $clientSpId" + } + + if ($scopeId -and $clientSpId -and $resourceSpId) { + # Check for existing grant + $existingGrants = Invoke-GraphApi -Uri "v1.0/oauth2PermissionGrants?`$filter=clientId eq '$clientSpId' and resourceId eq '$resourceSpId'" + + if ($existingGrants.value -and $existingGrants.value.Count -gt 0) { + $existingGrant = $existingGrants.value[0] + Write-Info "Existing grant found (scope: $($existingGrant.scope)). Updating to include '$scopeName'..." + + $existingScopes = if ($existingGrant.scope) { $existingGrant.scope.Trim() } else { "" } + if ($existingScopes -notmatch "\b$scopeName\b") { + $newScopes = "$existingScopes $scopeName".Trim() + $updateBody = @{ scope = $newScopes } | ConvertTo-Json -Compress + if ($PSCmdlet.ShouldProcess("Grant $($existingGrant.id)", "Update consent scope")) { + Invoke-GraphApi -Method "PATCH" -Uri "v1.0/oauth2PermissionGrants/$($existingGrant.id)" -Body $updateBody + Write-Success "Updated grant scope to: $newScopes" + } + } + else { + Write-Success "Grant already includes scope '$scopeName'." + } + } + else { + Write-Info "Creating new admin consent grant (AllPrincipals)..." + $grantBody = @{ + clientId = $clientSpId + consentType = "AllPrincipals" + resourceId = $resourceSpId + scope = $scopeName + } | ConvertTo-Json -Compress + + if ($PSCmdlet.ShouldProcess("Consent grant", "Create AllPrincipals grant for $scopeName")) { + Invoke-GraphApi -Method "POST" -Uri "v1.0/oauth2PermissionGrants" -Body $grantBody + Write-Success "Admin consent grant created for scope '$scopeName'." + } + } + } + else { + Write-Info "Skipping admin consent (missing scope, client SP, or resource SP)." + } + + Write-Host "" + Write-Success "Phase 1 complete: Entra ID prerequisites configured." +} +else { + Write-Info "Skipping Entra ID setup (SkipEntraSetup flag set)." +} +#endregion + +#region ── PHASE 2: Build App Package ───────────────────────────────────────────── +if (-not $SkipPackageBuild) { + Write-StepHeader "PHASE 2: Build App Package" "Substituting templates and creating ZIP" + + # Ensure output directory exists + if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + Write-Success "Created output directory: $OutputDir" + } + + # Create a temp build directory + $buildTemp = Join-Path $OutputDir "temp_gcc_build" + if (Test-Path $buildTemp) { + Remove-Item -Recurse -Force $buildTemp + } + New-Item -ItemType Directory -Path $buildTemp -Force | Out-Null + + # ── Generate a new Teams App ID (or reuse if env file exists) ── + $envFilePath = Join-Path $EnvDir ".env.$AppNameSuffix" + $teamsAppId = [Guid]::NewGuid().ToString() + + if (Test-Path $envFilePath) { + Write-Info "Found existing env file: $envFilePath" + $envContent = Get-Content $envFilePath -Raw + $match = [regex]::Match($envContent, 'TEAMS_APP_ID=(.+)') + if ($match.Success -and $match.Groups[1].Value.Trim()) { + $teamsAppId = $match.Groups[1].Value.Trim() + Write-Success "Reusing existing TEAMS_APP_ID: $teamsAppId" + } + } + else { + Write-Info "Generated new TEAMS_APP_ID: $teamsAppId" + } + + # ── Step 2a: Process manifest.json ── + Write-StepHeader "Step 2a" "Process manifest.json" + + $manifestTemplate = Get-Content (Join-Path $AppPackageDir "manifest.json") -Raw + $manifestResolved = $manifestTemplate ` + -replace '\$\{\{TEAMS_APP_ID\}\}', $teamsAppId ` + -replace '\$\{\{APP_NAME_SUFFIX\}\}', $AppNameSuffix + + $manifestResolved | Set-Content (Join-Path $buildTemp "manifest.json") -Encoding UTF8 + Write-Success "manifest.json → TEAMS_APP_ID=$teamsAppId, APP_NAME_SUFFIX=$AppNameSuffix" + + # ── Step 2b: Process declarativeAgent.json ── + Write-StepHeader "Step 2b" "Process declarativeAgent.json" + + $agentTemplate = Get-Content (Join-Path $AppPackageDir "declarativeAgent.json") -Raw + # The template uses $[file('instruction.txt')] — ATK resolves this at build time. + # We need to inline the instruction.txt content. + $instructionText = Get-Content (Join-Path $AppPackageDir "instruction.txt") -Raw + # Escape for JSON embedding (the instruction text needs to be a valid JSON string value) + $instructionEscaped = $instructionText.Trim() ` + -replace '\\', '\\' ` + -replace '"', '\"' ` + -replace "`r`n", '\n' ` + -replace "`n", '\n' ` + -replace "`t", '\t' + + # Replace the $[file('instruction.txt')] template with the actual content + $agentResolved = $agentTemplate -replace '\$\[file\(''instruction\.txt''\)\]', $instructionEscaped + # Also replace any ${{APP_NAME_SUFFIX}} in the agent name + $agentResolved = $agentResolved -replace '\$\{\{APP_NAME_SUFFIX\}\}', $AppNameSuffix + + $agentResolved | Set-Content (Join-Path $buildTemp "declarativeAgent.json") -Encoding UTF8 + Write-Success "declarativeAgent.json → instructions inlined from instruction.txt" + + # ── Step 2c: Process ai-plugin.json ── + Write-StepHeader "Step 2c" "Process ai-plugin.json" + + $pluginTemplate = Get-Content (Join-Path $AppPackageDir "ai-plugin.json") -Raw + $pluginResolved = $pluginTemplate ` + -replace '\$\{\{MCP_DA_AUTH_ID_SIMPLECHAT\}\}', $ConfigurationId ` + -replace '\$\{\{APP_NAME_SUFFIX\}\}', $AppNameSuffix ` + -replace '\$\{\{MCP_SERVER_URL\}\}', $McpServerUrl + + $pluginResolved | Set-Content (Join-Path $buildTemp "ai-plugin.json") -Encoding UTF8 + Write-Success "ai-plugin.json → MCP_DA_AUTH_ID_SIMPLECHAT=$ConfigurationId, MCP_SERVER_URL=$McpServerUrl" + + # ── Step 2d: Copy icon files ── + Write-StepHeader "Step 2d" "Copy icon files" + + Copy-Item (Join-Path $AppPackageDir "color.png") (Join-Path $buildTemp "color.png") -Force + Copy-Item (Join-Path $AppPackageDir "outline.png") (Join-Path $buildTemp "outline.png") -Force + Write-Success "Copied color.png and outline.png" + + # ── Step 2e: Copy instruction.txt (Teams may need it alongside declarativeAgent.json) ── + Copy-Item (Join-Path $AppPackageDir "instruction.txt") (Join-Path $buildTemp "instruction.txt") -Force + Write-Success "Copied instruction.txt" + + # ── Step 2f: Create ZIP package ── + Write-StepHeader "Step 2f" "Create ZIP package" + + $zipFileName = "appPackage.$AppNameSuffix.zip" + $zipPath = Join-Path $OutputDir $zipFileName + + if (Test-Path $zipPath) { + Remove-Item $zipPath -Force + } + + # Use .NET compression to create the ZIP + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory($buildTemp, $zipPath) + + Write-Success "Package created: $zipPath" + Write-Detail "Package size: $([math]::Round((Get-Item $zipPath).Length / 1KB, 1)) KB" + + # List package contents + Write-Info "Package contents:" + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + foreach ($entry in $zip.Entries) { + Write-Detail " $($entry.FullName) ($([math]::Round($entry.Length / 1KB, 1)) KB)" + } + $zip.Dispose() + + # Clean up temp directory + Remove-Item -Recurse -Force $buildTemp + Write-Success "Cleaned up temp build directory." + + # ── Step 2g: Write/update env file ── + Write-StepHeader "Step 2g" "Write environment file" + + $envContent = @" +# This file includes environment variables for the GCC-M deployment. +# Generated by Deploy-GccMAgent.ps1 on $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") + +# Built-in environment variables +TEAMSFX_ENV=$AppNameSuffix +APP_NAME_SUFFIX=$AppNameSuffix + +# Teams App ID (generated or reused) +TEAMS_APP_ID=$teamsAppId + +# GCC-M Tenant +TEAMS_APP_TENANT_ID=$TenantId + +# OAuth Configuration ID = Base64("{TenantId}##{OAuthRegistrationId}") +MCP_DA_AUTH_ID_SIMPLECHAT=$ConfigurationId + +# Entra App Registrations +CLIENT_APP_ID=$ClientAppId +OAUTH_REGISTRATION_ID=$OAuthRegistrationId +"@ + + $envContent | Set-Content $envFilePath -Encoding UTF8 + Write-Success "Environment file written: $envFilePath" + + # Write user secrets file (gitignored) + $envUserFilePath = Join-Path $EnvDir ".env.$AppNameSuffix.user" + $envUserContent = @" +# This file includes secret environment variables for the GCC-M deployment. +# This file is gitignored. DO NOT commit this file. +# Generated by Deploy-GccMAgent.ps1 on $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") + +M365SC_CLIENT_SECRET=$ClientSecret +M365SC_RESOURCE_APP_ID=$ResourceAppId +MCP_SERVER_URL=$McpServerUrl +"@ + $envUserContent | Set-Content $envUserFilePath -Encoding UTF8 + Write-Success "User secrets file written: $envUserFilePath" + + Write-Host "" + Write-Success "Phase 2 complete: App package built." +} +else { + Write-Info "Skipping package build (SkipPackageBuild flag set)." +} +#endregion + +#region ── PHASE 3: Upload to Teams App Catalog ─────────────────────────────────── +if ($Upload) { + Write-StepHeader "PHASE 3: Upload to Teams App Catalog" "Registering app via Graph API" + + if (-not (Test-AzCliLoggedIn)) { + Write-Host " Please log in to Azure CLI and re-run." -ForegroundColor Red + exit 1 + } + + $zipPath = Join-Path $OutputDir "appPackage.$AppNameSuffix.zip" + if (-not (Test-Path $zipPath)) { + Write-Host " ERROR: Package not found at $zipPath. Run without -SkipPackageBuild first." -ForegroundColor Red + exit 1 + } + + Write-Info "Uploading app package to Teams App Catalog..." + Write-Info "NOTE: This uses the Graph API /appCatalogs/teamsApps endpoint." + Write-Info "For GCC-M, this may require Teams Admin permissions." + Write-Host "" + Confirm-Continue "Upload package to Teams App Catalog?" + + try { + # Check if app already exists in catalog + $existingApps = Invoke-GraphApi -Uri "v1.0/appCatalogs/teamsApps?`$filter=externalId eq '$teamsAppId'" + + if ($existingApps.value -and $existingApps.value.Count -gt 0) { + $catalogAppId = $existingApps.value[0].id + Write-Info "App already exists in catalog (ID: $catalogAppId). Updating..." + + # Update existing app + # For update, we use PUT with the zip content + az rest --method PUT ` + --uri "$GraphEndpoint/v1.0/appCatalogs/teamsApps/$catalogAppId/appDefinitions" ` + --headers "Content-Type=application/zip" ` + --body "@$zipPath" 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Success "App updated in Teams catalog." + } + else { + Write-Warning " Update via Graph API may have failed. Consider manual upload." + } + } + else { + Write-Info "Uploading new app to catalog..." + + # For new app upload, POST the zip file + az rest --method POST ` + --uri "$GraphEndpoint/v1.0/appCatalogs/teamsApps" ` + --headers "Content-Type=application/zip" ` + --body "@$zipPath" 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Success "App uploaded to Teams catalog." + } + else { + Write-Warning " Upload via Graph API may have failed. Consider manual upload." + } + } + } + catch { + Write-Host " ERROR during upload: $_" -ForegroundColor Red + Write-Info "You can manually upload the package through Teams Admin Center." + } +} +else { + Write-StepHeader "PHASE 3: Manual Upload Required" "Automatic upload skipped" + Write-Info "The app package has been built but NOT uploaded to Teams." + Write-Info "To deploy manually:" + Write-Host "" + Write-Detail "Option A: Teams Admin Center" + Write-Detail " 1. Go to https://admin.teams.microsoft.com/policies/manage-apps" + Write-Detail " 2. Click 'Upload new app' → 'Upload'" + Write-Detail " 3. Select: $OutputDir\appPackage.$AppNameSuffix.zip" + Write-Host "" + Write-Detail "Option B: Developer Portal (GCC Beta) in Teams" + Write-Detail " 1. Open Teams at https://teams.cloud.microsoft" + Write-Detail " 2. Search for 'Developer Portal (GCC Beta)' app" + Write-Detail " 3. Go to Apps → Import app → Upload ZIP" + Write-Detail " 4. Select: $OutputDir\appPackage.$AppNameSuffix.zip" + Write-Host "" + Write-Detail "Option C: Use this script with -Upload" + Write-Detail " Requires Azure CLI logged in with Teams Admin permissions." +} +#endregion + +#region ── Summary ──────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green +Write-Host " Deployment Summary" -ForegroundColor Green +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green +Write-Host "" +Write-Detail "Target Tenant: $TenantId" +Write-Detail "Teams App ID: $teamsAppId" +Write-Detail "Configuration ID: $ConfigurationId" +Write-Detail "App Package: $OutputDir\appPackage.$AppNameSuffix.zip" +Write-Detail "Env File: $envFilePath" +Write-Host "" + +if (-not $SkipEntraSetup) { + Write-Success "Entra ID: Configured (redirect URIs, permissions, consent)" +} +if (-not $SkipPackageBuild) { + Write-Success "App Package: Built" +} +if (-not $Upload) { + Write-Info "Upload: Manual upload required (see instructions above)" +} +else { + Write-Success "Upload: Attempted via Graph API" +} + +Write-Host "" +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkCyan +Write-Host " IMPORTANT REMINDERS" -ForegroundColor DarkCyan +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkCyan +Write-Host "" +Write-Detail "1. OAuth registration must exist in Developer Portal (GCC Beta)" +Write-Detail " Registration Name: simplechat" +Write-Detail " Registration ID: $OAuthRegistrationId" +Write-Host "" +Write-Detail "2. The Configuration ID ties the tenant to the OAuth registration:" +Write-Detail " Base64('$TenantId##$OAuthRegistrationId')" +Write-Detail " = $ConfigurationId" +Write-Host "" +Write-Detail "3. Redirect URIs on BOTH client and resource app registrations" +Write-Detail " should include the Teams/M365 OAuth redirect endpoints." +Write-Host "" +Write-Detail "4. Cross-cloud note: If MCP server is in commercial Azure and the" +Write-Detail " agent is in GCC-M, token validation must accept GCC-M tokens." +Write-Detail " The MCP server issuer validation may need updating." +Write-Host "" +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Magenta +Write-Host " Done." -ForegroundColor Magenta +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Magenta +#endregion diff --git a/application/external_apps/agents/scripts/Initialize-AgentEnvironment.ps1 b/application/external_apps/agents/scripts/Initialize-AgentEnvironment.ps1 new file mode 100644 index 00000000..8d4ef643 --- /dev/null +++ b/application/external_apps/agents/scripts/Initialize-AgentEnvironment.ps1 @@ -0,0 +1,542 @@ +<# +.SYNOPSIS + Prepares the Entra ID environment for the M365SCAgent declarative agent, + then hands off to ATK (Agents Toolkit) for provisioning. + +.DESCRIPTION + This script complements the ATK 'provision' command by handling prerequisites + that ATK cannot perform automatically: + + 1. Validates Azure CLI login and tenant + 2. Creates or locates the client app registration for the agent + 3. Generates a client secret (or reuses an existing one) + 4. Locates the resource app registration (MCP server API) + 5. Resolves the MCP server URL + 6. Populates env/.env..user with the required values + 7. Optionally runs 'atk provision' automatically + + After this script completes, env/.env..user will contain the values + ATK needs for the oauth/register action in m365agents.yml. + + Workflow: + Initialize-AgentEnvironment.ps1 → populates .env.dev.user + atk provision → reads .env.dev.user, registers OAuth, builds package + +.PARAMETER AppName + Base name for the agent's Entra app registration. + Default: "simplechat-agent" + +.PARAMETER Environment + Environment suffix (matches ATK TEAMSFX_ENV). Default: "dev" + +.PARAMETER ResourceAppId + The app ID of the MCP server's API resource. If omitted, the script + searches for it by display name using -ResourceAppName. + +.PARAMETER ResourceAppName + Display name pattern to search for the resource app registration. + Used only if -ResourceAppId is not provided. Default: "simplechat" + +.PARAMETER McpServerUrl + Full URL to the MCP server /mcp endpoint. If omitted, the script + prompts for it. + +.PARAMETER SecretExpirationDays + Days until the generated client secret expires. Default: 180 + +.PARAMETER SkipAtkProvision + Do not run 'atk provision' after populating the env file. + +.EXAMPLE + .\Initialize-AgentEnvironment.ps1 + + Interactively sets up with defaults (dev environment). + +.EXAMPLE + .\Initialize-AgentEnvironment.ps1 -McpServerUrl "https://my-mcp.azurecontainerapps.io/mcp" -ResourceAppId "00000000-0000-0000-0000-000000000000" + + Fully non-interactive setup with known values. + +.NOTES + Prerequisites: + - Azure CLI installed and logged in to the target tenant + - ATK (Agents Toolkit) VS Code extension installed (for atk provision) + - The MCP server resource app registration must already exist in Entra ID +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$AppName = "simplechat-agent", + + [string]$Environment = "dev", + + [ValidatePattern('^$|^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$ResourceAppId = "", + + [string]$ResourceAppName = "simplechat", + + [string]$McpServerUrl = "", + + [int]$SecretExpirationDays = 180, + + [switch]$SkipAtkProvision +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$EnvDir = Join-Path $ProjectRoot "env" +$EnvFile = Join-Path $EnvDir ".env.$Environment" +$EnvUserFile = Join-Path $EnvDir ".env.$Environment.user" + +#region ── Helper Functions ─────────────────────────────────────────────────────── + +function Write-Step { + param([string]$Number, [string]$Description) + Write-Host "`n────────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host " Step ${Number}: $Description" -ForegroundColor Cyan + Write-Host "────────────────────────────────────────────────────────────" -ForegroundColor Cyan +} + +function Write-Ok { + param([string]$Message) + Write-Host " OK: $Message" -ForegroundColor Green +} + +function Write-Detail { + param([string]$Message) + Write-Host " $Message" -ForegroundColor Gray +} + +function Write-Warn { + param([string]$Message) + Write-Host " WARN: $Message" -ForegroundColor Yellow +} + +function Get-CloudEnvironment { + try { + $cloud = az cloud show --output json 2>$null | ConvertFrom-Json + return $cloud.name + } + catch { + return "AzureCloud" + } +} + +function Get-GraphEndpoint { + param([string]$CloudName) + switch ($CloudName) { + "AzureUSGovernment" { return "https://graph.microsoft.us" } + default { return "https://graph.microsoft.com" } + } +} + +function Get-LoginEndpoint { + param([string]$CloudName) + switch ($CloudName) { + "AzureUSGovernment" { return "https://login.microsoftonline.us" } + default { return "https://login.microsoftonline.com" } + } +} + +function Invoke-Graph { + param( + [string]$Method = "GET", + [string]$Uri, + [string]$Body = $null + ) + $graphUrl = $script:GraphEndpoint + $azArgs = @("rest", "--method", $Method, "--uri", "$graphUrl/$Uri", "--headers", "Content-Type=application/json", "--only-show-errors") + if ($Body) { + $azArgs += @("--body", $Body) + } + $result = az @azArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Graph API call failed ($Method $Uri): $result" + } + if ($result) { + return $result | ConvertFrom-Json + } + return $null +} + +#endregion + +#region ── Banner ───────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "================================================================" -ForegroundColor Magenta +Write-Host " M365SCAgent — Initialize Agent Environment" -ForegroundColor Magenta +Write-Host " Prepares prerequisites for ATK provision" -ForegroundColor DarkMagenta +Write-Host "================================================================" -ForegroundColor Magenta +Write-Host "" + +#endregion + +#region ── Step 1: Validate Azure CLI ───────────────────────────────────────────── + +Write-Step "1" "Validate Azure CLI login" + +try { + $account = az account show --output json 2>$null | ConvertFrom-Json + $tenantId = $account.tenantId + $cloudName = Get-CloudEnvironment + $script:GraphEndpoint = Get-GraphEndpoint -CloudName $cloudName + $loginEndpoint = Get-LoginEndpoint -CloudName $cloudName + + Write-Ok "Logged in to tenant: $tenantId" + Write-Detail "Cloud: $cloudName" + Write-Detail "Graph: $script:GraphEndpoint" +} +catch { + Write-Host " ERROR: Azure CLI is not logged in." -ForegroundColor Red + Write-Host " Run: az login" -ForegroundColor Red + exit 1 +} + +# Cross-check with .env.dev if TEAMS_APP_TENANT_ID is already set +if (Test-Path $EnvFile) { + $envContent = Get-Content $EnvFile -Raw + $match = [regex]::Match($envContent, 'TEAMS_APP_TENANT_ID=(.+)') + if ($match.Success -and $match.Groups[1].Value.Trim()) { + $envTenantId = $match.Groups[1].Value.Trim() + if ($envTenantId -ne $tenantId) { + Write-Warn "Azure CLI tenant ($tenantId) differs from .env.$Environment tenant ($envTenantId)" + $response = Read-Host " Continue with CLI tenant $tenantId? (Y/n)" + if ($response -and $response -notin @('y', 'Y', 'yes', '')) { + exit 1 + } + } + } +} + +#endregion + +#region ── Step 2: Create or locate client app registration ─────────────────────── + +Write-Step "2" "Create or locate client app registration" + +$clientAppRegName = "$AppName-$Environment-ar" +Write-Detail "Looking for: $clientAppRegName" + +$existingApp = az ad app list --display-name $clientAppRegName --output json --only-show-errors | ConvertFrom-Json + +if ($existingApp -and $existingApp.Count -gt 0) { + $clientApp = $existingApp[0] + $clientAppId = $clientApp.appId + Write-Ok "Found existing app: $clientAppRegName (appId: $clientAppId)" +} +else { + Write-Detail "Not found. Creating new app registration: $clientAppRegName" + + if ($PSCmdlet.ShouldProcess($clientAppRegName, "Create app registration")) { + $clientApp = az ad app create ` + --display-name $clientAppRegName ` + --sign-in-audience AzureADMyOrg ` + --output json --only-show-errors | ConvertFrom-Json + + if (-not $clientApp) { + throw "Failed to create app registration: $clientAppRegName" + } + + $clientAppId = $clientApp.appId + Write-Ok "Created app: $clientAppRegName (appId: $clientAppId)" + + # Create service principal + Write-Detail "Creating service principal..." + az ad sp create --id $clientAppId --only-show-errors | Out-Null + Write-Ok "Service principal created" + } + else { + # WhatIf mode — use placeholder so downstream steps don't break + $clientAppId = "" + $clientSecret = "" + } +} + +#endregion + +#region ── Step 3: Generate client secret ───────────────────────────────────────── + +Write-Step "3" "Generate client secret" + +# Check if there's already a secret in the env user file +$existingSecret = "" +if (Test-Path $EnvUserFile) { + $userContent = Get-Content $EnvUserFile -Raw + $secretMatch = [regex]::Match($userContent, 'M365SC_CLIENT_SECRET=(.+)') + if ($secretMatch.Success -and $secretMatch.Groups[1].Value.Trim()) { + $existingSecret = $secretMatch.Groups[1].Value.Trim() + } +} + +if ($existingSecret) { + Write-Ok "Client secret already exists in .env.$Environment.user — reusing" + Write-Detail "(To regenerate, clear M365SC_CLIENT_SECRET in the file and re-run)" + $clientSecret = $existingSecret +} +else { + $expirationDate = (Get-Date).AddDays($SecretExpirationDays).ToString("yyyy-MM-dd") + Write-Detail "Generating secret expiring $expirationDate..." + + if ($PSCmdlet.ShouldProcess($clientAppRegName, "Generate client secret")) { + $clientSecret = az ad app credential reset ` + --id $clientAppId ` + --append ` + --end-date $expirationDate ` + --query password ` + --output tsv --only-show-errors + + if (-not $clientSecret) { + throw "Failed to generate client secret" + } + + Write-Ok "Client secret generated (expires $expirationDate)" + } +} + +#endregion + +#region ── Step 4: Locate resource app registration (MCP API) ───────────────────── + +Write-Step "4" "Locate resource app registration (MCP server API)" + +if (-not $ResourceAppId) { + Write-Detail "Searching for resource app by name pattern: *$ResourceAppName*" + + $resourceApps = az ad app list --display-name $ResourceAppName --output json --only-show-errors | ConvertFrom-Json + + if ($resourceApps -and $resourceApps.Count -gt 0) { + if ($resourceApps.Count -eq 1) { + $ResourceAppId = $resourceApps[0].appId + Write-Ok "Found: $($resourceApps[0].displayName) (appId: $ResourceAppId)" + } + else { + Write-Host "" + Write-Host " Multiple apps found matching '$ResourceAppName':" -ForegroundColor Yellow + for ($i = 0; $i -lt $resourceApps.Count; $i++) { + Write-Host " [$i] $($resourceApps[$i].displayName) — $($resourceApps[$i].appId)" -ForegroundColor White + } + $selection = Read-Host " Enter number to select (or paste an appId directly)" + + if ($selection -match '^[0-9]+$' -and [int]$selection -lt $resourceApps.Count) { + $ResourceAppId = $resourceApps[[int]$selection].appId + } + elseif ($selection -match '^[0-9a-fA-F]{8}-') { + $ResourceAppId = $selection.Trim() + } + else { + Write-Host " Invalid selection." -ForegroundColor Red + exit 1 + } + Write-Ok "Selected resource app: $ResourceAppId" + } + } + else { + Write-Warn "No app registrations found matching '$ResourceAppName'." + if ($WhatIfPreference) { + Write-Detail "(WhatIf mode — skipping interactive prompt, using placeholder)" + $ResourceAppId = "00000000-0000-0000-0000-000000000000" + } + else { + $ResourceAppId = Read-Host " Enter the Resource App ID (MCP server API) manually" + if (-not $ResourceAppId) { + Write-Host " ERROR: Resource App ID is required." -ForegroundColor Red + exit 1 + } + } + } +} +else { + Write-Ok "Using provided Resource App ID: $ResourceAppId" +} + +# Verify the resource app exists and check for access_as_user scope +if ($WhatIfPreference) { + Write-Detail "(WhatIf mode — skipping resource app verification)" +} +else { + Write-Detail "Verifying resource app and checking scopes..." + $resourceSp = Invoke-Graph -Uri "v1.0/servicePrincipals?`$filter=appId eq '$ResourceAppId'&`$select=id,displayName,oauth2PermissionScopes" + + if (-not $resourceSp.value -or $resourceSp.value.Count -eq 0) { + Write-Warn "Service principal not found for resource app $ResourceAppId." + if ($PSCmdlet.ShouldProcess($ResourceAppId, "Create service principal for resource app")) { + $spBody = @{ appId = $ResourceAppId } | ConvertTo-Json -Compress + Invoke-Graph -Method "POST" -Uri "v1.0/servicePrincipals" -Body $spBody | Out-Null + Write-Ok "Service principal created for resource app." + } + } + else { + $resourceSpName = $resourceSp.value[0].displayName + Write-Ok "Resource app verified: $resourceSpName" + + # Check for access_as_user scope + $scopes = $resourceSp.value[0].oauth2PermissionScopes + if ($scopes) { + $accessScope = $scopes | Where-Object { $_.value -eq "access_as_user" } + if ($accessScope) { + Write-Ok "Scope 'access_as_user' found on resource app" + } + else { + Write-Warn "Scope 'access_as_user' NOT found on resource app." + Write-Detail "You may need to add this scope in the Azure Portal under:" + Write-Detail " App registrations > $resourceSpName > Expose an API > Add a scope" + } + } + } +} + +#endregion + +#region ── Step 5: Resolve MCP server URL ───────────────────────────────────────── + +Write-Step "5" "Resolve MCP server URL" + +if (-not $McpServerUrl) { + # Try reading from the MCP project's env file + $mcpEnvPath = Join-Path $ProjectRoot ".." "mcp" ".env" + if (Test-Path $mcpEnvPath) { + $mcpEnvContent = Get-Content $mcpEnvPath -Raw + # Look for the deployed URL (not localhost) + $urlMatch = [regex]::Match($mcpEnvContent, 'SIMPLECHAT_BASE_URL=(https?://[^\s]+)') + if ($urlMatch.Success) { + Write-Detail "Found candidate from MCP .env: $($urlMatch.Groups[1].Value)" + } + } + + if ($WhatIfPreference) { + Write-Detail "(WhatIf mode — skipping interactive prompt, using placeholder)" + $McpServerUrl = "" + } + else { + $McpServerUrl = Read-Host " Enter the MCP server URL (e.g. https://your-mcp.azurecontainerapps.io/mcp)" + if (-not $McpServerUrl) { + Write-Host " ERROR: MCP server URL is required." -ForegroundColor Red + exit 1 + } + } +} + +# Validate URL format +if ($McpServerUrl -notmatch '^https?://') { + Write-Host " ERROR: MCP server URL must start with http:// or https://" -ForegroundColor Red + exit 1 +} + +Write-Ok "MCP server URL: $McpServerUrl" + +#endregion + +#region ── Step 6: Write env/.env..user ─────────────────────────────────── + +Write-Step "6" "Write environment file" + +$envUserContent = @" +# ============================================================ +# env/.env.$Environment.user — Agent secrets (gitignored) +# ============================================================ +# Generated by Initialize-AgentEnvironment.ps1 on $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") +# These values are consumed by ATK during 'atk provision'. +# ============================================================ + +# Entra tenant ID +TEAMS_APP_TENANT_ID=$tenantId + +# Client app registration for the agent OAuth flow +M365SC_CLIENT_APP_ID=$clientAppId + +# Client secret for that app registration +M365SC_CLIENT_SECRET=$clientSecret + +# Resource app ID (MCP server API — used in scope: api:///access_as_user) +M365SC_RESOURCE_APP_ID=$ResourceAppId + +# Full URL to the MCP server endpoint +MCP_SERVER_URL=$McpServerUrl + +# ATK populates this during 'atk provision' via oauth/register +MCP_DA_AUTH_ID_SIMPLECHAT= +"@ + +if ($PSCmdlet.ShouldProcess($EnvUserFile, "Write environment variables")) { + $envUserContent | Set-Content $EnvUserFile -Encoding UTF8 + Write-Ok "Written: $EnvUserFile" +} + +#endregion + +#region ── Summary ──────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "================================================================" -ForegroundColor Green +Write-Host " Environment Ready for ATK Provision" -ForegroundColor Green +Write-Host "================================================================" -ForegroundColor Green +Write-Host "" +Write-Detail "Tenant ID: $tenantId" +Write-Detail "Client App ID: $clientAppId" +Write-Detail "Client App Name: $clientAppRegName" +Write-Detail "Resource App ID: $ResourceAppId" +Write-Detail "MCP Server URL: $McpServerUrl" +Write-Detail "Secret Expires: $((Get-Date).AddDays($SecretExpirationDays).ToString('yyyy-MM-dd'))" +Write-Detail "Env File: $EnvUserFile" +Write-Host "" + +#endregion + +#region ── Step 7: Optionally run ATK provision ─────────────────────────────────── + +if (-not $SkipAtkProvision) { + Write-Step "7" "Run ATK provision" + + # Check if atk CLI is available + $atkAvailable = Get-Command "teamsapp" -ErrorAction SilentlyContinue + if (-not $atkAvailable) { + $atkAvailable = Get-Command "teamsfx" -ErrorAction SilentlyContinue + } + + if ($atkAvailable) { + Write-Detail "ATK CLI found: $($atkAvailable.Source)" + $response = Read-Host " Run 'atk provision' now? (Y/n)" + if (-not $response -or $response -in @('y', 'Y', 'yes')) { + Write-Detail "Running ATK provision..." + Push-Location $ProjectRoot + try { + & $atkAvailable.Name provision --env $Environment + } + finally { + Pop-Location + } + } + else { + Write-Detail "Skipped. Run manually:" + Write-Host "" + Write-Host " cd $ProjectRoot" -ForegroundColor White + Write-Host " atk provision --env $Environment" -ForegroundColor White + } + } + else { + Write-Warn "ATK CLI not found in PATH." + Write-Detail "To provision, use one of these options:" + Write-Host "" + Write-Host " Option A: VS Code Agents Toolkit sidebar → Provision" -ForegroundColor White + Write-Host " Option B: Install ATK CLI and run:" -ForegroundColor White + Write-Host " cd $ProjectRoot" -ForegroundColor Gray + Write-Host " teamsapp provision --env $Environment" -ForegroundColor Gray + } +} +else { + Write-Host "" + Write-Detail "Skipped ATK provision (use -SkipAtkProvision to suppress this)." + Write-Detail "Next step — run ATK provision:" + Write-Host "" + Write-Host " VS Code: Agents Toolkit sidebar → Provision" -ForegroundColor White + Write-Host " CLI: cd $ProjectRoot && teamsapp provision --env $Environment" -ForegroundColor White +} + +Write-Host "" +Write-Host "================================================================" -ForegroundColor Magenta +Write-Host " Done." -ForegroundColor Magenta +Write-Host "================================================================" -ForegroundColor Magenta +Write-Host "" + +#endregion diff --git a/application/single_app/config.py b/application/single_app/config.py index 9db0beef..bcf817e3 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.012" +VERSION = "0.237.013" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')