GitHub Copilot ChatGPT Claude Codex CLI Cursor opencode Skill Text

ui-widget-developer

Build MCP servers for Copilot Chat using the OpenAI Apps SDK or MCP Apps SDK widget rendering support (any language). Use this skill when: - Creating MCP servers that integrate with M365 Copilot declarative agents - Building rich interactive widgets (React + Fluent UI) that rende

Ciza · 0 points · 27 views 3 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download microsoft-skills-.github_plugins_microsoft-365-agents-toolkit_skills_ui-widget-developer-e58528d.zip · 31 KB
Part of microsoft/skills — 195 skills

Install

skills CLI npx skills add https://github.com/microsoft/skills/tree/main/.github/plugins/microsoft-365-agents-toolkit/skills/ui-widget-developer
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install microsoft-skills@llmmart
Git git clone https://github.com/microsoft/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole microsoft/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Copilot MCP Server Development

Telemetry Tagging

Before running any atk CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated:

export ATK_CLI_SKILL=true

Run this once at the start of the session. All subsequent atk commands in the same terminal will inherit it.

🔀 PATH SELECTION

Before proceeding, ask the user which path they want to take using AskUserQuestion. Both are supported for M365 Copilot widget rendering. Present the tradeoffs and let them choose:

OAI Apps (this skill) MCP Apps Extension (modelcontextprotocol/ext-apps)
Standard OpenAI-specific Official MCP standard
Works in ChatGPT + M365 Copilot M365 Copilot, ChatGPT, VSCode, and more
Maturity Battle-tested, production-ready New official standard, growing ecosystem
Design OpenAI Apps SDK MCP Apps protocol (cross-platform)
When to choose Existing OAI app investment Prefer the open standard, want broadest client support

Ask: "Would you like to build an OAI app (OpenAI Apps SDK — battle-tested, works in ChatGPT and M365 Copilot) or an MCP app (new official standard — works in M365 Copilot, ChatGPT, VSCode, and more)?"

  • OAI apps → Continue below. This skill covers everything you need.
  • MCP apps → Install the modelcontextprotocol/ext-apps plugin (see below), then use the appropriate skill from that plugin.

MCP Apps: Install ext-apps Plugin

If the user chooses MCP Apps, do this automatically (do not stop at explanation-only):

  1. Run /plugin marketplace add modelcontextprotocol/ext-apps
  2. Run /plugin install mcp-apps@mcp-apps
  3. Confirm the plugin is available, then invoke the correct ext-apps skill based on user intent

If plugin commands are unavailable in the current environment, provide the exact commands below and ask the user to run them once, then continue by invoking the selected ext-apps skill.

Reference commands:

To build an MCP App, install the ext-apps plugin from the marketplace:

1. /plugin marketplace add modelcontextprotocol/ext-apps
2. /plugin install mcp-apps@mcp-apps

Then use one of these skills from that plugin:
- create-mcp-app      — Scaffold a new MCP App with interactive UI from scratch
- add-app-to-server   — Add interactive UI to an existing MCP server's tools
- migrate-oai-app     — Convert an existing OAI app to use MCP Apps
- convert-web-app     — Turn a web app into a hybrid web + MCP App

After installing, invoke the relevant skill to continue.

Note: The ext-apps plugin lives in the external modelcontextprotocol/ext-apps marketplace — it is not part of this plugin collection.

Handoff mapping after install:

  • New MCP app from scratch → create-mcp-app
  • Add app UI to existing MCP server → add-app-to-server
  • Migrate existing OAI app → migrate-oai-app
  • Convert an existing web app → convert-web-app

📛 PROJECT DETECTION 📛

This skill triggers when building MCP servers with OAI app or widget rendering for Microsoft 365 Copilot Chat. The MCP server can be written in any language that supports the MCP protocol (TypeScript, Python, C#, etc.). The agent project and MCP server may live in the same repo, separate folders, or entirely different projects.

Scenario Routing

Starting Point What You Need Path
Prefer MCP Apps standard Cross-platform widget support (M365 Copilot, ChatGPT, VSCode, and more) Install modelcontextprotocol/ext-apps, then use create-mcp-app or add-app-to-server — see Path Selection above
From scratch (no agent, no MCP server) Full OAI app setup Delegate agent scaffolding to declarative-agent-developer first, then return here for MCP server + widgets
Existing M365 agent, new MCP server MCP server + widgets + mcpPlugin.json Start at Implementation
Existing MCP server, add Copilot widgets Widget support added to existing server Start at Copilot Widget Protocol
Language choice (non-TypeScript) Protocol requirements See Copilot Widget Protocol for what to implement, MCP Server Pattern (TypeScript) as a reference

🚨 CRITICAL EXECUTION RULES 🚨

FLUENT UI ENFORCEMENT (REQUIRED): Widget implementations MUST use React + Fluent UI components. Before writing any widget code, the agent MUST read and follow:

  • references/widget-patterns.md
  • references/best-practices.md FLUENT UI PACKAGE REQUIREMENT (REQUIRED): The widget project MUST include Fluent UI dependencies before implementation. At minimum, install and keep these in the widget package dependencies:
  • @fluentui/react-components
  • react
  • react-dom

If any of these packages are missing, install them automatically before continuing with widget code generation.

If the generated widget does not include React entry files (for example widgets/src/<widget-name>/main.tsx and a React component file) and Fluent imports from @fluentui/react-components, the task is incomplete and MUST be corrected before returning results.

NO RAW HTML-ONLY WIDGETS (DEFAULT): Do not implement app content directly with static HTML templates and inline JS as the final widget solution. A minimal shell HTML file is allowed only as a loader for built React assets. Raw/self-contained HTML-only widgets are allowed only when the user explicitly requests a non-React prototype.

BACKGROUND PROCESSES: MCP server and devtunnel MUST be spawned as independent OS processes — NOT run inside the agent's shell session. isBackground: true, mode: "async", and Start-Job all run inside the agent's shell session and will be killed between messages. The only reliable approach is to spawn a detached OS process.

Windows — use Start-Process -WindowStyle Hidden:

# Start devtunnel
$t = Start-Process -FilePath "devtunnel" `
    -ArgumentList "host","<tunnel-name>","-a" `
    -WindowStyle Hidden -PassThru `
    -RedirectStandardOutput "tunnel.log" -RedirectStandardError "tunnel-err.log"

# Start MCP server — use cmd.exe /c to set the working directory and inherit PATH
$s = Start-Process -FilePath "cmd.exe" `
    -ArgumentList "/c","cd /d <abs-path-to-mcp-server> && <start-command>" `
    -WindowStyle Hidden -PassThru `
    -RedirectStandardOutput "server.log" -RedirectStandardError "server-err.log"

# Save PIDs so they can be stopped later
"$($t.Id),$($s.Id)" | Out-File pids.txt
Write-Host "Started tunnel PID $($t.Id), server PID $($s.Id)"

To stop: Stop-Process -Id (Get-Content pids.txt).Split(',') or Stop-Process -Id <pid>.

Linux/Mac — use nohup with &:

nohup devtunnel host <tunnel-name> > tunnel.log 2>tunnel-err.log &
echo "tunnel:$!" >> pids.txt
nohup <start-command> > server.log 2>server-err.log &
echo "server:$!" >> pids.txt

To stop: kill $(grep -oP '\d+' pids.txt).

After starting, tail the logs to confirm both processes are up before proceeding:

# Windows
Start-Sleep 3; Get-Content tunnel.log, server.log
# Linux/Mac
sleep 3 && tail tunnel.log server.log

FULL AUTOMATION: Never tell the user to run commands manually. Install tools, authenticate, start services — do everything automatically. Only ask the user for interactive input that truly requires them (like device code confirmation during devtunnel user login -g -d). If a tool isn't installed, install it. If a service needs starting, start it. The user expects full automation.

PATH SELECTION (REQUIRED — STOP BEFORE ANY CODE): You MUST use AskUserQuestion to ask the user whether they want OAI Apps or MCP Apps Extension before writing any code, running any commands, or making any architectural decisions.

There is no exception to this rule. The most common failure mode is reasoning "the user's request makes it obvious, so asking is redundant." This reasoning is always wrong — invoke AskUserQuestion regardless. A user saying "build an MCP server with widgets" is NOT an answer to this question. A user invoking this skill by name is NOT an answer. Only an explicit answer to the question counts. See PATH SELECTION above for the exact question to ask.

AGENT PROVISIONING: Re-provisioning is only required when the agent manifest changes (e.g., mcpPlugin.json tool definitions, MCP server URL, declarativeAgent.json, instruction.txt). MCP server code changes (tool implementations, React widget code, server logic) do NOT require re-provisioning the agent — running or deploying the server picks up changes automatically.

When provisioning is needed:

  1. Bump the version in manifest.json (increment the patch version, e.g., 1.0.0 → 1.0.1)
  2. Deploy the agent:
    npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local
    

WIDGET TESTING LINKS: Every time you return to the user with a result while the MCP server is running, you MUST include links to ALL widgets so they can test them locally. Format:

🧪 Test widgets locally:
- http://localhost:3001/widgets/widget-name.html
- http://localhost:3001/widgets/another-widget.html

List every .html file in the mcp-server/widgets/ directory (or equivalent widget folder). This helps users verify widget rendering before testing in Copilot.

AUTO-DEPLOY ON COMPLETION (REQUIRED — DO NOT SKIP): When coding is complete, proceed automatically without waiting for the user:

  1. Start MCP server + devtunnel in the background (per BACKGROUND PROCESSES above)
  2. Run E2E verification with MCP Inspector (per MCP TOOL CONFIGURATION RULE below) — fix any failures before continuing
  3. Provision the agent if needed (per AGENT PROVISIONING above)
  4. Print a project summary in this format:
## ✅ <Project Name> — Ready

### Widgets
- [widget-name.html](http://localhost:<PORT>/widgets/widget-name.html)
- [widget-name2.html](http://localhost:<PORT>/widgets/widget-name2.html)

### Endpoints
- MCP server: http://localhost:<PORT>/mcp
- MCP via tunnel: https://<tunnel-url>/mcp

### Test in Copilot
Local:      https://m365.cloud.microsoft/chat/?titleId={M365_TITLE_ID from env/.env.local}
Other envs: {SHARE_LINK from env/.env.{environment}}

AGENT PROJECT DELEGATION: This skill builds MCP servers and widgets, NOT declarative agent projects. If the user's request involves creating or configuring the declarative agent itself (scaffolding, m365agents.yml, m365agents.local.yml, declarativeAgent.json, manifest lifecycle), delegate to the declarative-agent-developer skill.

MCP RESOURCE REGISTRATION: Every widget MUST have a matching MCP resource. Without resources, Copilot cannot fetch widget shells through the MCP protocol and widgets will not render.

For each new widget, complete this checklist:

  1. ☐ Create a widget shell HTML file in widgets/ and a React widget entry under widgets/src/<widget-name>/ (see widget-patterns.md)
  2. ☐ Define a ui://widget/<name>.html URI constant
  3. ☐ Add a Resource entry to the resources array with:
    • uri: the ui://widget/<name>.html URI
    • mimeType: "text/html+skybridge"
    • _meta: CSP config with openai/widgetDomain and openai/widgetCSP (from environment)
  4. ☐ Add a handler for resources/read that returns the widget shell HTML for this URI
  5. ☐ Add the tool with _meta.openai/outputTemplate pointing to the same ui://widget/<name>.html URI
  6. ☐ Verify the server capabilities include resources: {} in the initialize response

Widget shell + asset considerations:

  • Preferred (React + Fluent UI): Resource HTML should be a minimal shell that links to built JS/CSS assets served from the MCP server's /assets/ route.
  • Exception only: Self-contained HTML via resources/read is for explicit user-requested prototypes only. Default and production path is React + Fluent UI.

Example shell for React build output:

<!doctype html><html><head>
  <script type="module" src="${serverUrl}/assets/my-widget.js"></script>
  <link rel="stylesheet" href="${serverUrl}/assets/my-widget.css">
</head><body>
  <div id="widget-root"></div>
</body></html>

Use the WIDGET_BASE_URL or MCP_SERVER_URL environment variable for the asset URL base (see mcp-server-pattern.md "Configurable Widget Base URL" section).

See mcp-server-pattern.md for the complete resource and asset serving patterns.


⚠️ MCP TOOL CONFIGURATION RULE ⚠️

NEVER manually write tool definitions in mcpPlugin.json. Always use MCP Inspector to get the complete tool definitions from the running MCP server.

TOOL NAMING CONVENTION: Tool names MUST match the pattern ^[A-Za-z0-9_]+$ (letters, numbers, and underscores only). NEVER use hyphens (-) in tool names. Use underscores instead (e.g., render_profile not render-profile).

MANDATORY WORKFLOW:

  1. Start the MCP server (in background)
  2. Use MCP Inspector to get the latest tool definitions:
    npx @modelcontextprotocol/inspector@0.20.0 --cli https://my-mcp-server.example.com --transport http --method tools/list
    
  3. Copy the COMPLETE tool definition from the inspector (including name, description, inputSchema, _meta, annotations, title)
  4. Paste into mcpPlugin.json under runtimes[].spec.mcp_tool_description.tools (inside the RemoteMCPServer runtime's spec object)
  5. Run E2E verification through the devtunnel — call each tool and confirm the response contains structuredContent and _meta.openai/widgetAccessible: true:
    npx @modelcontextprotocol/inspector@0.20.0 --cli https://<tunnel-url>/mcp --transport http --method tools/call --tool-name <tool_name>
    
    Also verify GET https://<tunnel-url>/health returns {"status":"ok"}. Fix any failures before provisioning.

The MCP Inspector shows the exact tool schema from your server. Copy it completely — do not manually write or modify these definitions. This ensures mcpPlugin.json stays in sync with the MCP server.


Build MCP servers that integrate with Microsoft 365 Copilot Chat and render rich interactive widgets.

Architecture

M365 Copilot ──▶ mcpPlugin.json ──▶ MCP Server ──▶ structuredContent ──▶ React + Fluent UI Widget
     │              (RemoteMCPServer)    (Streamable HTTP)                  (window.openai.toolOutput)
     │
     └── Capabilities (People, etc.) provide data to pass to MCP tools

Project Structure

Example project structure, not a hard requirement but a common pattern for organizing MCP server + widget development:

project/
├── appPackage/
│   ├── manifest.json           # Teams manifest (bump version on deploy)
│   ├── declarativeAgent.json   # Agent config + capabilities
│   ├── mcpPlugin.json          # Tool definitions with _meta
│   └── instruction.txt         # Agent behavior instructions
├── mcp-server/
│   ├── src/index.ts            # Server with Streamable HTTP
│   ├── widgets/                # Widget shells + React source
│   │   ├── my-widget.html      # Minimal shell returned by resources/read
│   │   └── src/my-widget/      # React + Fluent UI source
│   ├── assets/                 # Built widget bundles served at /assets
│   └── package.json
├── scripts/
│   ├── setup-devtunnel.sh      # Linux/Mac devtunnel setup
│   └── setup-devtunnel.ps1     # Windows devtunnel setup
└── env/.env.local              # MCP_SERVER_URL, MCP_SERVER_DOMAIN

Language note: This shows a TypeScript project layout. For Python, replace mcp-server/src/index.ts with your Python entry point (e.g., server.py). For C#, use a standard .NET project structure. The appPackage/, widgets/, scripts/, and env/ directories are language-agnostic.

Copilot Widget Protocol

Your MCP server must implement these protocol requirements to render widgets in Copilot Chat. This applies regardless of language:

  1. Streamable HTTP transport — /mcp endpoint handling POST, GET, DELETE with session management
  2. CORS headers — Origin-checking on /mcp allowing m365.cloud.microsoft and *.m365.cloud.microsoft, with required MCP headers
  3. Server capabilities — initialize response must declare resources: {} and tools: {}
  4. MCP resources — Register widgets with ui://widget/<name>.html URIs, text/html+skybridge mime type, and CSP _meta
  5. Tool response format — Return content (text) + structuredContent (widget data) + _meta with openai/outputTemplate
  6. Widget serving — HTTP route at /widgets/*.html for shell files and /assets/* for built bundles, both with origin-checking CORS

For full protocol details, JSON shapes, and an adaptation checklist for existing MCP servers, see references/copilot-widget-protocol.md.

Implementation

MCP Server Pattern (TypeScript Reference)

See references/mcp-server-pattern.md for complete implementation.

For other languages, implement the requirements described in Copilot Widget Protocol using your language's MCP SDK. See the Language SDK References table for SDK packages.

Core requirements:

  • Expose Streamable HTTP transport on /mcp
  • Return structuredContent + _meta with openai/outputTemplate
  • Serve widgets via HTTP endpoint
  • Handle CORS for cross-origin requests
  • Handle partial data gracefully (fill in "Unknown" for missing fields)

Tool response format:

return {
  content: [{ type: "text", text: "Summary" }],
  structuredContent: { /* widget data */ },
  _meta: { "openai/outputTemplate": "ui://widget/name.html", "openai/widgetAccessible": true }
};

Handling Partial Data

Always normalize input data to handle missing fields:

server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
  const args = request.params.arguments as { title?: string; items?: Partial<Item>[] };

  // Normalize data - fill in "Unknown" for missing fields
  const title = args.title || "Default Title";
  const items = (args.items || []).map(item => ({
    name: item.name || "Unknown",
    value: item.value || "Unknown",
  }));

  // Build structuredContent for widget
  const structuredContent = { title, items };
  // ...
});

Widget Pattern

See references/widget-patterns.md for complete examples.

Core requirements:

  • Use React + Fluent UI components (@fluentui/react-components)
  • Ensure widget package dependencies include @fluentui/react-components, react, and react-dom
  • Theme with FluentProvider (webLightTheme/webDarkTheme) and Fluent tokens
  • Access data through shared hooks (e.g., useOpenAiGlobal("toolOutput"))
  • Debug fallback: embedded mock data when window.openai unavailable
  • Handle "Unknown" values gracefully (e.g., hide action buttons)

Plugin Schema

See references/plugin-schema.md for mcpPlugin.json format.

Core requirements:

  • Schema v2.4 with RemoteMCPServer runtime
  • run_for_functions array matching tool names
  • _meta in tool definitions for widget binding
  • inputSchema - make properties optional for flexibility, describe defaults in descriptions

DevTunnels Setup

Local testing only. DevTunnels are for development and testing on your machine. Before sharing the agent more broadly, deploy both the MCP server and widget assets to a hosted environment (e.g., Azure App Service, Azure Static Web Apps, or another hosting provider) and update the agent manifest URLs accordingly.

DevTunnels expose your localhost MCP server to M365 Copilot using named tunnels for stable URLs. See references/devtunnels.md for setup scripts, command reference, and troubleshooting.

The setup script (npm run tunnel / npm run tunnel:win):

  1. Creates a named tunnel on first run (or reuses the existing one)
  2. Starts hosting the tunnel on the configured port
  3. Updates env/.env.local with MCP_SERVER_URL and MCP_SERVER_DOMAIN (first run only)
  4. Continues hosting the tunnel

Quick Start

Terminal 1 - Start MCP Server:

cd mcp-server
npm install
npm run dev

Terminal 2 - Start DevTunnel:

npm run tunnel
# Or on Windows:
npm run tunnel:win

On first run, provision the agent once the tunnel is up (see AGENT PROVISIONING rule). On subsequent runs the tunnel URL is stable — no re-provisioning needed unless the agent manifest changes.

Development Workflow

  1. Start the MCP server (dev mode with hot reload):

    • TypeScript: cd mcp-server && npm install && npm run dev
    • Python: cd mcp-server && pip install -r requirements.txt && python server.py
    • C#: cd mcp-server && dotnet run
  2. Start the devtunnel (creates named tunnel on first run, reuses on subsequent runs):

    npm run tunnel
    
  3. Provision + test — see AGENT PROVISIONING rule for when this is needed; bump version in manifest.json if Copilot doesn't reflect changes

Best Practices

See references/best-practices.md for detailed guidance.

Key points:

  1. Rendering tools: Accept data as input, don't fetch internally
  2. Instructions: Tell agent to use capabilities FIRST, then pass data to MCP tools
  3. Themes: Use FluentProvider + Fluent tokens for dark/light support
  4. Debug mode: Include fallback data for local widget testing
  5. Partial data: Handle missing fields with "Unknown" defaults
  6. Action buttons: Hide email/chat buttons when data is "Unknown"
  7. Version bumping: Bump manifest version when changes aren't reflected in Copilot
Files (skills)
  • references
    • best-practices.md 12 KB
      # Best Practices
      
      > **Language note**: These best practices apply to any MCP server implementation. Code examples
      > use TypeScript for illustration, but the patterns (rendering tools, partial data handling,
      > tool response format, widget-resource-tool triplet) are language-agnostic concepts.
      
      ## Table of Contents
      - [1. ALWAYS Use Fluent UI for Widgets](#1-always-use-fluent-ui-for-widgets)
      - [2. Rendering Tools Pattern](#2-rendering-tools-pattern)
      - [3. Handle Partial Data](#3-handle-partial-data)
      - [4. Agent Instructions](#4-agent-instructions)
      - [5. Theme Support with FluentProvider](#5-theme-support-with-fluentprovider)
      - [6. Use Shared Hooks](#6-use-shared-hooks)
      - [7. Build Before Serve](#7-build-before-serve)
      - [8. Debug Mode](#8-debug-mode)
      - [9. Version Management](#9-version-management)
      - [10. Input Schema Descriptions](#10-input-schema-descriptions)
      - [11. Consistent Tool Definitions](#11-consistent-tool-definitions)
      - [12. CORS Configuration](#12-cors-configuration)
      - [13. Widget Security](#13-widget-security)
      - [14. DevTunnels](#14-devtunnels)
      - [15. MCP Server Working Directory](#15-mcp-server-working-directory)
      - [16. Environment Variable Initialization](#16-environment-variable-initialization)
      - [17. Declarative Agent Capabilities](#17-declarative-agent-capabilities)
      - [18. Tool Response Format](#18-tool-response-format)
      - [19. Conversation Starters](#19-conversation-starters)
      - [20. Widget-Resource-Tool Triplet](#20-widget-resource-tool-triplet)
      
      ## 1. ALWAYS Use Fluent UI for Widgets
      
      MANDATORY: All widget UI must be built with React and `@fluentui/react-components`.
      Do not use raw HTML/CSS templates or other UI frameworks for widget rendering.
      
      Required Fluent UI components:
      - `Card` for containers
      - `Badge` for labels/status
      - `Table` or `DataGrid` for tabular data
      - `Button` for actions
      - `Avatar` for entity visuals
      - `Tooltip` for hints
      - `Spinner` for loading states
      - `tokens` and `makeStyles` for styling
      
      ```tsx
      import { Card, Badge, makeStyles, tokens } from "@fluentui/react-components";
      
      const useStyles = makeStyles({
        root: {
          padding: tokens.spacingVerticalM,
          backgroundColor: tokens.colorNeutralBackground1,
        },
      });
      
      export function MyWidget() {
        const styles = useStyles();
        return (
          <div className={styles.root}>
            <Card>
              <Badge appearance="filled">Title</Badge>
            </Card>
          </div>
        );
      }
      ```
      
      ## 2. Rendering Tools Pattern
      
      Design MCP tools as **rendering tools** that accept data from the caller rather than fetching data internally.
      
      **Why**: Copilot can use its capabilities (People, Graph, etc.) to fetch data, then pass it to your MCP tool for rendering. This separation:
      - Leverages Copilot's built-in data access
      - Makes tools reusable across different data sources
      - Simplifies MCP server implementation
      
      **Pattern**:
      ```typescript
      // Good: accept and validate caller-provided data
      const parser = z.object({
        items: z.array(z.object({ name: z.string(), value: z.string() })),
      });
      
      // Avoid: Fetching data internally
      // const data = await fetchFromAPI(); // Don't do this
      ```
      
      ## 3. Handle Partial Data
      
      Always normalize input data to handle missing fields gracefully. Fill in "Unknown" for any missing properties.
      
      **Server Pattern** - use Zod defaults:
      ```typescript
      const parser = z.object({
        title: z.string().default("Untitled"),
        items: z.array(z.object({
          name: z.string().default("Unknown"),
          email: z.string().default("Unknown"),
          location: z.string().default("Unknown"),
        })).default([]),
      });
      
      server.setRequestHandler(CallToolRequestSchema, async (request) => {
        const parsed = parser.parse(request.params.arguments ?? {});
        return {
          content: [{ type: "text", text: `Rendered ${parsed.items.length} items` }],
          structuredContent: parsed,
          _meta: invocationMeta(MY_WIDGET),
        };
      });
      ```
      
      **Widget Pattern** - hide action buttons when data is "Unknown":
      ```tsx
      import { Button } from "@fluentui/react-components";
      import { MailRegular, ChatRegular } from "@fluentui/react-icons";
      
      function ContactActions({ email }: { email: string }) {
        if (!email || email === "Unknown") return null;
        return (
          <>
            <Button icon={<MailRegular />} as="a" href={`mailto:${email}`} appearance="subtle" size="small">
              Email
            </Button>
            <Button
              icon={<ChatRegular />}
              as="a"
              href={`https://teams.microsoft.com/l/chat/0/0?users=${encodeURIComponent(email)}`}
              appearance="subtle"
              size="small"
            >
              Chat
            </Button>
          </>
        );
      }
      ```
      
      **Schema Pattern** - Make properties optional and document defaults:
      ```json
      {
        "properties": {
          "name": {
            "type": "string",
            "description": "Full name. Defaults to 'Unknown' if not provided."
          }
        }
      }
      ```
      
      ## 4. Agent Instructions
      
      Tell the agent to use capabilities FIRST, then pass data to MCP tools.
      
      **Pattern** (instruction.txt):
      ```
      IMPORTANT: You MUST ALWAYS use the [Capability] capability FIRST to retrieve data
      before calling MCP tools. The MCP tools are RENDERING tools only - they do NOT
      fetch data. You must:
      1. Query the capability to get the data
      2. Pass that retrieved data to the appropriate MCP tool to render it
      
      CRITICAL: Never call the MCP tools without first retrieving data from the capability.
      ```
      
      ## 5. Theme Support with FluentProvider
      
      Theme should be handled by `FluentProvider` in widget entry points.
      
      ```tsx
      import { FluentProvider, webLightTheme, webDarkTheme } from "@fluentui/react-components";
      
      function getTheme() {
        const openaiTheme = (window as any).openai?.theme;
        if (openaiTheme === "dark") return webDarkTheme;
        if (openaiTheme === "light") return webLightTheme;
        return window.matchMedia?.("(prefers-color-scheme: dark)").matches
          ? webDarkTheme
          : webLightTheme;
      }
      ```
      
      Never hand-code color systems for widget UI. Use Fluent `tokens` + `makeStyles`.
      
      ## 6. Use Shared Hooks
      
      Reuse shared hooks from [widget-patterns.md](widget-patterns.md):
      - `useOpenAiGlobal(key)` for polling `window.openai[key]`
      - `useThemeColors()` for semantic theme palette
      - `useWidgetState(initial)` for state persistence through Apps SDK host
      
      ```tsx
      function MyWidget() {
        const toolOutput = useOpenAiGlobal("toolOutput");
        const [state, setState] = useWidgetState({ expanded: false });
      
        if (!toolOutput) return <Spinner label="Loading..." />;
      
        return <Card>{/* render with toolOutput */}</Card>;
      }
      ```
      
      ## 7. Build Before Serve
      
      Always build widgets before starting the server. The server serves pre-built assets.
      
      ```bash
      npm run install:all
      npm run build:widgets
      npm run dev:server
      ```
      
      After every widget code change:
      
      ```bash
      npm run build:widgets
      ```
      
      Server reads assets on each request; restart is typically not required after rebuild.
      
      ## 8. Debug Mode
      
      Include fallback data for local widget testing without BizChat.
      
      ```tsx
      const DEBUG_DATA = { title: "Test", items: [{ name: "Alice", value: "123" }] };
      
      function useToolData() {
        const toolOutput = useOpenAiGlobal("toolOutput");
        if (toolOutput) return toolOutput;
        console.log("Debug mode - using DEBUG_DATA");
        return DEBUG_DATA;
      }
      ```
      
      ## 9. Version Management
      
      Bump manifest version for each deployment when changes aren't reflected.
      
      ```json
      // manifest.json
      { "version": "1.0.5" }  // Increment on each change
      ```
      
      ## 10. Input Schema Descriptions
      
      Provide detailed descriptions with examples and default values in inputSchema.
      
      ```json
      {
        "properties": {
          "email": {
            "type": "string",
            "description": "Email address (e.g., 'john.doe@microsoft.com'). Defaults to 'Unknown' if not provided."
          },
          "location": {
            "type": "string",
            "description": "Work location (e.g., 'Redmond, WA'). Defaults to 'Unknown' if not provided."
          }
        }
      }
      ```
      
      ## 11. Consistent Tool Definitions
      
      Keep inputSchema identical in:
      1. MCP server tool definitions
      2. mcpPlugin.json tool definitions
      
      Mismatches cause runtime errors.
      
      ## 12. CORS Configuration
      
      Always configure CORS with an allowlist origin check.
      
      ```typescript
      import cors from "cors";
      
      const corsOptions: cors.CorsOptions = {
        origin: (origin, callback) => {
          if (isOriginAllowed(origin)) {
            callback(null, origin ?? true);
          } else {
            callback(null, false);
          }
        },
        methods: ["GET", "POST", "DELETE", "OPTIONS"],
        allowedHeaders: [
          "Content-Type", "Accept",
          "Mcp-Session-Id", "mcp-session-id",
          "Last-Event-ID",
          "Mcp-Protocol-Version", "mcp-protocol-version",
        ],
        exposedHeaders: ["Mcp-Session-Id"],
        credentials: false,
      };
      
      app.use(cors(corsOptions));
      app.options("*", cors(corsOptions));
      ```
      
      See [mcp-server-pattern.md](mcp-server-pattern.md) for full allowlist and `isOriginAllowed()` implementation.
      
      ## 13. Widget Security
      
      React escapes JSX by default. Do not use `dangerouslySetInnerHTML`.
      
      ```tsx
      // Safe
      <Text>{userData.name}</Text>
      
      // Unsafe in widgets
      // <div dangerouslySetInnerHTML={{ __html: userData.name }} />
      ```
      
      Validate dynamic URLs before rendering links.
      
      ## 14. DevTunnels
      
      Use random tunnels for simple local loops:
      
      ```bash
      devtunnel host -p 3001 --allow-anonymous
      ```
      
      Pre-flight:
      1. Kill old tunnels: `pkill -f "devtunnel host" 2>/dev/null`
      2. Verify auth: `devtunnel user show`
      3. Verify server: `curl -s http://localhost:3001/health`
      
      Because URL changes each run, update `SERVER_BASE_URL` and provision again.
      
      ## 15. MCP Server Working Directory
      
      Monorepo pattern (`server/` and `widgets/` each with separate package files):
      
      ```bash
      # From mcp-server root
      npm run install:all
      npm run build:widgets
      npm run dev:server
      npm run start
      
      # Directly
      cd server && npm run dev
      cd widgets && npm run build
      ```
      
      Common failure: running `npm run dev` at root when only `dev:server` is defined.
      
      ## 16. Environment Variable Initialization
      
      Before first provision, populate all `${{VAR_NAME}}` placeholders used by `appPackage/`
      in `env/.env.local`.
      
      Set at least:
      
      ```env
      SERVER_BASE_URL=http://localhost:3001
      ```
      
      This placeholder allows initial provision before a tunnel URL is available.
      
      ## 17. Declarative Agent Capabilities
      
      Only enable capabilities you need.
      
      ```json
      {
        "capabilities": [
          { "name": "People" }
        ]
      }
      ```
      
      Available:
      - `People` - Organizational data, manager/reports
      - `GraphConnectors` - Custom Graph connectors
      - `OneDriveAndSharePoint` - File access
      - `WebSearch` - Web search
      
      ## 18. Tool Response Format
      
      Always include both text content and structuredContent.
      
      ```typescript
      return {
        content: [{ type: "text", text: "Human-readable summary" }],
        structuredContent: { /* data for widget */ },
        _meta: invocationMeta(MY_WIDGET),
      };
      ```
      
      The text content serves as fallback and accessibility.
      
      ## 19. Conversation Starters
      
      Add relevant conversation starters to help users discover your agent's capabilities.
      
      ```json
      {
        "capabilities": {
          "conversation_starters": [
            {
              "title": "Short button label",
              "text": "Full prompt that will be sent when clicked"
            }
          ]
        }
      }
      ```
      
      ## 20. Widget-Resource-Tool Triplet
      
      Every widget in a Copilot MCP server requires three coordinated parts:
      
      | Part | What it does | Where it lives |
      |------|-------------|----------------|
      | **Widget shell + assets** | Shell HTML loads rendered React + Fluent UI bundle | `widgets/<name>.html` + `assets/<name>.js` |
      | **MCP Resource** | Serves widget shell to Copilot via `ui://widget/<name>.html` | `resources` array + `ReadResourceRequestSchema` handler |
      | **MCP Tool** | Triggers widget rendering via `_meta.openai/outputTemplate` | `tools` array + `CallToolRequestSchema` handler |
      
      If any part is missing:
      - No Resource → Copilot can't fetch widget shell, widget won't render
      - No Tool → Widget exists but nothing triggers it
      - No shell/assets → Resource returns 404 or shell loads without scripts, tool invocation shows empty widget
      
      **Simple vs Complex widgets:**
      - Simple: Self-contained shell/widget for quick validation → `ReadResource` returns full file
      - Complex (React + Fluent UI, preferred): Minimal HTML shell linking to JS/CSS assets served via `/assets/` route → `ReadResource` returns shell HTML, assets load from `MCP_SERVER_URL/assets/`
      
      Always create all three parts together. When adding a new tool+widget, start from the resource pattern in [mcp-server-pattern.md](mcp-server-pattern.md).
      
    • copilot-widget-protocol.md 11.5 KB
      # Copilot Widget Protocol
      
      Language-agnostic protocol requirements for MCP servers that render widgets in Microsoft 365 Copilot Chat. This document describes **what** your server must implement, regardless of programming language. For a complete TypeScript reference implementation, see [mcp-server-pattern.md](mcp-server-pattern.md).
      
      ## Table of Contents
      - [Transport: Streamable HTTP](#transport-streamable-http)
      - [CORS Configuration](#cors-configuration)
      - [Server Capabilities](#server-capabilities)
      - [MCP Resources for Widgets](#mcp-resources-for-widgets)
      - [MCP Tool Response Format](#mcp-tool-response-format)
      - [Widget Shell and Asset Serving](#widget-shell-and-asset-serving)
      - [Widget-Resource-Tool Triplet](#widget-resource-tool-triplet)
      - [Environment Configuration](#environment-configuration)
      - [Adaptation Checklist: Existing MCP Server](#adaptation-checklist-existing-mcp-server)
      - [Language SDK References](#language-sdk-references)
      
      ## Transport: Streamable HTTP
      
      Your server must expose a single `/mcp` endpoint that handles three HTTP methods:
      
      | Method | Purpose | Key Headers |
      |--------|---------|-------------|
      | `POST /mcp` | Send JSON-RPC messages (initialize, tool calls, resource reads) | `Content-Type: application/json`, `mcp-session-id` (after init) |
      | `GET /mcp` | Open SSE stream for server-to-client notifications | `mcp-session-id` (required) |
      | `DELETE /mcp` | Terminate a session | `mcp-session-id` (required) |
      
      **Session lifecycle:**
      1. Client sends `POST /mcp` with an `initialize` request (no `mcp-session-id` header)
      2. Server generates a session ID and returns it in the `mcp-session-id` response header
      3. All subsequent requests include the `mcp-session-id` header
      
      **Initialize request:**
      ```json
      {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
          "protocolVersion": "2025-03-26",
          "capabilities": {},
          "clientInfo": { "name": "copilot", "version": "1.0.0" }
        }
      }
      ```
      
      **Initialize response:**
      ```json
      {
        "jsonrpc": "2.0",
        "id": 1,
        "result": {
          "protocolVersion": "2025-03-26",
          "capabilities": { "resources": {}, "tools": {} },
          "serverInfo": { "name": "my-server", "version": "1.0.0" }
        }
      }
      ```
      
      ## CORS Configuration
      
      The `/mcp` endpoint must handle CORS for cross-origin requests from Copilot. Use **origin-checking** rather than a blanket wildcard — validate the request's `Origin` header against an allowlist and reflect the origin back if it matches.
      
      **Required allowed origins** (at minimum):
      - `m365.cloud.microsoft` — the base Copilot Chat domain
      - `*.m365.cloud.microsoft` — any subdomain (e.g., `copilot.m365.cloud.microsoft`)
      
      **Preflight (OPTIONS /mcp):**
      ```
      Access-Control-Allow-Origin: <reflected-origin>
      Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
      Access-Control-Allow-Headers: Content-Type, mcp-session-id, Last-Event-ID, mcp-protocol-version
      Access-Control-Expose-Headers: mcp-session-id, mcp-protocol-version
      ```
      
      **All /mcp responses:**
      ```
      Access-Control-Allow-Origin: <reflected-origin>
      Access-Control-Expose-Headers: mcp-session-id, mcp-protocol-version
      ```
      
      Where `<reflected-origin>` is the value of the request's `Origin` header, set only when it matches the allowlist. If the origin does not match, omit the `Access-Control-Allow-Origin` header entirely.
      
      **Origin-checking pseudocode:**
      ```
      function isAllowedOrigin(origin):
          hostname = parseURL(origin).hostname
          return hostname == "m365.cloud.microsoft"
              OR hostname ends with ".m365.cloud.microsoft"
      ```
      
      > **Note**: Do not use a blanket `Access-Control-Allow-Origin: *`. Origin-checking prevents your MCP server from being called by arbitrary web pages.
      
      ## Server Capabilities
      
      The `initialize` response **must** declare both `resources` and `tools` capabilities:
      
      ```json
      {
        "capabilities": {
          "resources": {},
          "tools": {}
        }
      }
      ```
      
      Without `resources: {}`, Copilot will not call `resources/list` or `resources/read`, and widgets will not render.
      
      ## MCP Resources for Widgets
      
      Each widget requires an MCP resource registration. Resources tell Copilot how to fetch the widget shell via the MCP protocol.
      
      **`resources/list` response:**
      ```json
      {
        "resources": [
          {
            "name": "My Widget",
            "uri": "ui://widget/my-widget.html",
            "description": "Widget for displaying data",
            "mimeType": "text/html+skybridge",
            "_meta": {
              "openai/widgetDomain": "https://your-server.example.com",
              "openai/widgetCSP": {
                "connect_domains": ["https://your-server.example.com"],
                "resource_domains": ["https://your-server.example.com"]
              }
            }
          }
        ]
      }
      ```
      
      **`resources/read` response** (when Copilot requests `uri: "ui://widget/my-widget.html"`):
      ```json
      {
        "contents": [
          {
            "uri": "ui://widget/my-widget.html",
            "mimeType": "text/html+skybridge",
            "text": "<!DOCTYPE html><html>...widget shell HTML...</html>",
            "_meta": {
              "openai/widgetDomain": "https://your-server.example.com",
              "openai/widgetCSP": {
                "connect_domains": ["https://your-server.example.com"],
                "resource_domains": ["https://your-server.example.com"]
              }
            }
          }
        ]
      }
      ```
      
      Key fields:
      - **`uri`**: Must use the `ui://widget/<name>.html` scheme
      - **`mimeType`**: Must be `"text/html+skybridge"` — this signals Copilot to render as a widget
      - **`_meta.openai/widgetDomain`**: The server URL for CSP allowlisting
      - **`_meta.openai/widgetCSP`**: Content Security Policy domains the widget may contact
      
      ## MCP Tool Response Format
      
      Tool responses must include three parts: a text summary, structured data for the widget, and metadata linking to the widget template.
      
      ```json
      {
        "content": [
          { "type": "text", "text": "Human-readable summary (fallback and accessibility)" }
        ],
        "structuredContent": {
          "title": "Widget Title",
          "items": [{ "name": "Item 1", "value": "Value 1" }]
        },
        "_meta": {
          "openai/outputTemplate": "ui://widget/my-widget.html",
          "openai/widgetAccessible": true,
          "openai/toolInvocation/invoking": "Processing...",
          "openai/toolInvocation/invoked": "Complete"
        }
      }
      ```
      
      | Field | Purpose |
      |-------|---------|
      | `content` | Text fallback — displayed if widget can't render |
      | `structuredContent` | JSON data passed to the widget as `window.openai.toolOutput` |
      | `_meta.openai/outputTemplate` | URI linking this tool to its widget (must match a registered resource URI) |
      | `_meta.openai/widgetAccessible` | Set `true` to enable widget rendering |
      | `_meta.openai/toolInvocation/invoking` | Status text shown while tool executes |
      | `_meta.openai/toolInvocation/invoked` | Status text shown when tool completes |
      
      ## Widget Shell and Asset Serving
      
      Your server must serve widget shell HTML files over HTTP at a `/widgets/` route:
      
      ```
      GET /widgets/my-widget.html → 200 OK (Content-Type: text/html, Access-Control-Allow-Origin: <reflected-origin>)
      ```
      
      Requirements:
      - Serve shell files from a `widgets/` directory (or equivalent)
      - Serve built JS/CSS bundles from an `/assets/` route
      - Apply the same origin-checking CORS as the `/mcp` endpoint (see [CORS Configuration](#cors-configuration))
      - Guard against path traversal for both widgets and assets directories
      
      Widgets should be built with React + Fluent UI and loaded by the shell. See [widget-patterns.md](widget-patterns.md) for templates and patterns. Key points:
      - Wrap app UI with `FluentProvider` (`webLightTheme`/`webDarkTheme`)
      - Build UI with `@fluentui/react-components` and `@fluentui/react-icons`
      - Read data from `window.openai` through shared hooks (for example, `useOpenAiGlobal("toolOutput")`)
      - Include debug fallback data for local testing
      
      ## Widget-Resource-Tool Triplet
      
      Every widget requires three coordinated parts. If any part is missing, the widget will not work correctly.
      
      | Part | What it does | Key identifiers |
      |------|-------------|-----------------|
      | **Widget shell + assets** | Shell returned by resource loads built React bundle | `GET /widgets/<name>.html`, `GET /assets/<name>.js` |
      | **MCP Resource** | Serves shell HTML to Copilot via MCP protocol | `uri: "ui://widget/<name>.html"`, `mimeType: "text/html+skybridge"` |
      | **MCP Tool** | Triggers widget rendering, returns data | `_meta.openai/outputTemplate: "ui://widget/<name>.html"` |
      
      **When a part is missing:**
      - **No Resource** → Copilot can't fetch the shell, widget won't render
      - **No Tool** → Widget exists but nothing triggers it
      - **No shell/assets** → Resource loads empty/404 or missing scripts, tool invocation shows empty widget
      
      Always create all three parts together for each new widget. See [best-practices.md](best-practices.md#20-widget-resource-tool-triplet) for additional detail.
      
      ## Environment Configuration
      
      Three environment variables connect your server to devtunnels and CSP configuration:
      
      | Variable | Example | Purpose |
      |----------|---------|---------|
      | `MCP_SERVER_URL` | `https://xxxxx-3001.usw2.devtunnels.ms` | Full server URL — used in resource `_meta` for CSP |
      | `MCP_SERVER_DOMAIN` | `xxxxx-3001.usw2.devtunnels.ms` | Domain only — used in CSP `connect_domains` and `resource_domains` |
      | `DEVTUNNEL_PORT` | `3001` | Local port the server listens on |
      
      These are auto-populated by the devtunnel setup script. See [devtunnels.md](devtunnels.md) for the automated setup.
      
      The resource `_meta` CSP fields must reference these values so that Copilot's Content Security Policy allows the widget to contact your server:
      
      ```json
      {
        "openai/widgetDomain": "${MCP_SERVER_URL}",
        "openai/widgetCSP": {
          "connect_domains": ["${MCP_SERVER_URL}", "https://${MCP_SERVER_DOMAIN}"],
          "resource_domains": ["${MCP_SERVER_URL}", "https://${MCP_SERVER_DOMAIN}"]
        }
      }
      ```
      
      ## Adaptation Checklist: Existing MCP Server
      
      If you have an existing MCP server and want to add Copilot widget support, complete this checklist:
      
      - [ ] Add `resources: {}` capability to your server's `initialize` response (see [Server Capabilities](#server-capabilities))
      - [ ] Create widget shell HTML files in `widgets/` and build React + Fluent UI bundles into `assets/` (see [widget-patterns.md](widget-patterns.md))
      - [ ] Register MCP resources with `ui://widget/<name>.html` URIs and `text/html+skybridge` mime type (see [MCP Resources for Widgets](#mcp-resources-for-widgets))
      - [ ] Add `_meta` to resources with CSP configuration (`openai/widgetDomain`, `openai/widgetCSP`)
      - [ ] Implement `resources/read` handler that returns shell HTML for each registered URI
      - [ ] Update tool responses to return `structuredContent` + `_meta` with `openai/outputTemplate` (see [MCP Tool Response Format](#mcp-tool-response-format))
      - [ ] Add `/widgets/*.html` and `/assets/*` HTTP serving routes with origin-checking CORS (see [CORS Configuration](#cors-configuration))
      - [ ] Configure CORS on `/mcp` endpoint (see [CORS Configuration](#cors-configuration))
      - [ ] Create `mcpPlugin.json` manifest (see [plugin-schema.md](plugin-schema.md))
      - [ ] Set up devtunnel for local testing (see [devtunnels.md](devtunnels.md))
      
      ## Language SDK References
      
      | Language | SDK Package | Transport Support | Notes |
      |----------|-------------|-------------------|-------|
      | TypeScript / Node.js | `@modelcontextprotocol/sdk` | `StreamableHTTPServerTransport` | Most mature; see [mcp-server-pattern.md](mcp-server-pattern.md) for complete reference |
      | Python | `mcp` (PyPI) | Built-in Streamable HTTP | Use FastMCP or low-level server |
      | C# / .NET | `ModelContextProtocol` (NuGet) | ASP.NET integration | Community SDK with Streamable HTTP support |
      
      The TypeScript implementation in [mcp-server-pattern.md](mcp-server-pattern.md) demonstrates every protocol requirement listed in this document. Use it as a reference when implementing in other languages.
      
    • devtunnels.md 11.6 KB
      # DevTunnels Setup for MCP Servers
      
      ## Table of Contents
      - [Prerequisites](#prerequisites)
      - [Environment Configuration](#environment-configuration)
        - [`.env.local` File Structure](#envlocal-file-structure)
      - [Automated Setup Script (Named Tunnel)](#automated-setup-script-named-tunnel)
        - [`scripts/setup-devtunnel.sh`](#scriptssetup-devtunnelsh)
        - [`scripts/setup-devtunnel.ps1` (Windows)](#scriptssetup-devtunnelps1-windows)
      - [package.json Scripts](#packagejson-scripts)
        - [Root package.json](#root-packagejson)
        - [mcp-server/package.json](#mcp-serverpackagejson)
      - [MCP Server Environment Integration](#mcp-server-environment-integration)
        - [Load Environment Variables](#load-environment-variables)
        - [Use Environment for CSP](#use-environment-for-csp)
        - [Add dotenv Dependency](#add-dotenv-dependency)
      - [Development Workflow](#development-workflow)
        - [Terminal 1: Start MCP Server](#terminal-1-start-mcp-server)
        - [Terminal 2: Start DevTunnel](#terminal-2-start-devtunnel)
        - [After First-Time Setup](#after-first-time-setup)
      - [Why Named Tunnels?](#why-named-tunnels)
      - [Troubleshooting](#troubleshooting)
      
      Automated setup for exposing localhost MCP servers via Azure DevTunnels using **named tunnels** for stable URLs.
      
      ## Prerequisites
      
      - [Azure DevTunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started) installed
      - Node.js installed
      - MCP server running on localhost (default port: 3001)
      - **First-time setup**: `devtunnel user login -g -d` (GitHub auth with device code). Azure AD device code auth (`devtunnel user login -d`) is blocked by tenant Conditional Access policy on managed devices — use GitHub auth as the default.
      
      ## Environment Configuration
      
      ### `.env.local` File Structure
      
      Add these variables to `env/.env.local`:
      
      ```bash
      # DevTunnel configuration
      DEVTUNNEL_PORT=3001
      DEVTUNNEL_NAME=my-mcp-agent
      
      # Auto-populated by devtunnel setup script (run npm run tunnel):
      MCP_SERVER_URL=
      MCP_SERVER_DOMAIN=
      ```
      
      ## Automated Setup Script (Named Tunnel)
      
      Uses **named tunnels** so the URL stays the same across restarts. The script creates the tunnel on first run and reuses it on subsequent runs — no need to update `.env.local` or re-provision the agent after a tunnel restart.
      
      ### `scripts/setup-devtunnel.sh`
      
      ```bash
      #!/bin/bash
      set -e
      
      ENV_FILE="env/.env.local"
      PORT="${DEVTUNNEL_PORT:-3001}"
      TUNNEL_NAME="${DEVTUNNEL_NAME:-mcp-agent}"
      
      # Auto-login check: ensure devtunnel is authenticated
      if ! devtunnel user show &>/dev/null; then
        echo "DevTunnel not logged in. Authenticating via GitHub..."
        devtunnel user login -g -d
      fi
      
      # Create named tunnel if it doesn't exist
      if ! devtunnel show "$TUNNEL_NAME" &>/dev/null; then
        echo "Creating named tunnel '$TUNNEL_NAME'..."
        devtunnel create -a "$TUNNEL_NAME"
        devtunnel port create "$TUNNEL_NAME" -p "$PORT"
        echo "Named tunnel '$TUNNEL_NAME' created on port $PORT."
      else
        echo "Reusing existing tunnel '$TUNNEL_NAME'."
      fi
      
      echo "Starting DevTunnel '$TUNNEL_NAME'..."
      echo ""
      
      # Host the named tunnel and capture output
      devtunnel host "$TUNNEL_NAME" 2>&1 | while IFS= read -r line; do
        echo "$line"
      
        # Extract URL when it appears (only updates .env.local on first run or URL change)
        if [[ "$line" =~ (https://[a-zA-Z0-9.-]+\.devtunnels\.ms[^ ]*) ]]; then
          TUNNEL_URL="${BASH_REMATCH[1]}"
          TUNNEL_DOMAIN=$(echo "$TUNNEL_URL" | sed -E 's|https?://||' | sed 's|/.*||')
      
          # Check if .env.local already has the correct URL
          CURRENT_URL=$(grep "^MCP_SERVER_URL=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
          if [ "$CURRENT_URL" = "$TUNNEL_URL" ]; then
            echo ""
            echo "Environment already configured (URL unchanged):"
            echo "   MCP_SERVER_URL=$TUNNEL_URL"
            echo "   MCP_SERVER_DOMAIN=$TUNNEL_DOMAIN"
            echo ""
          else
            echo ""
            echo "Updating $ENV_FILE..."
      
            # Update MCP_SERVER_URL
            if grep -q "^MCP_SERVER_URL=" "$ENV_FILE"; then
              sed -i "s|^MCP_SERVER_URL=.*|MCP_SERVER_URL=$TUNNEL_URL|" "$ENV_FILE"
            else
              echo "MCP_SERVER_URL=$TUNNEL_URL" >> "$ENV_FILE"
            fi
      
            # Update MCP_SERVER_DOMAIN
            if grep -q "^MCP_SERVER_DOMAIN=" "$ENV_FILE"; then
              sed -i "s|^MCP_SERVER_DOMAIN=.*|MCP_SERVER_DOMAIN=$TUNNEL_DOMAIN|" "$ENV_FILE"
            else
              echo "MCP_SERVER_DOMAIN=$TUNNEL_DOMAIN" >> "$ENV_FILE"
            fi
      
            echo ""
            echo "Environment configured:"
            echo "   MCP_SERVER_URL=$TUNNEL_URL"
            echo "   MCP_SERVER_DOMAIN=$TUNNEL_DOMAIN"
            echo ""
            echo "NOTE: First-time setup — run 'npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local' to deploy the agent."
            echo ""
          fi
        fi
      done
      ```
      
      ### `scripts/setup-devtunnel.ps1` (Windows)
      
      ```powershell
      $envFile = "env\.env.local"
      $port = if ($env:DEVTUNNEL_PORT) { $env:DEVTUNNEL_PORT } else { "3001" }
      $tunnelName = if ($env:DEVTUNNEL_NAME) { $env:DEVTUNNEL_NAME } else { "mcp-agent" }
      
      # Auto-login check: ensure devtunnel is authenticated
      try {
          devtunnel user show 2>&1 | Out-Null
      } catch {
          Write-Host "DevTunnel not logged in. Authenticating via GitHub..."
          devtunnel user login -g -d
      }
      
      # Create named tunnel if it doesn't exist
      $tunnelExists = $false
      try {
          devtunnel show $tunnelName 2>&1 | Out-Null
          $tunnelExists = $true
          Write-Host "Reusing existing tunnel '$tunnelName'."
      } catch {
          Write-Host "Creating named tunnel '$tunnelName'..."
          devtunnel create -a $tunnelName
          devtunnel port create $tunnelName -p $port
          Write-Host "Named tunnel '$tunnelName' created on port $port."
      }
      
      Write-Host "Starting DevTunnel '$tunnelName'..."
      Write-Host ""
      
      # Host the named tunnel and process output
      devtunnel host $tunnelName 2>&1 | ForEach-Object {
          Write-Host $_
      
          # Extract URL when it appears
          if ($_ -match "(https://[a-zA-Z0-9.-]+\.devtunnels\.ms[^ ]*)") {
              $tunnelUrl = $Matches[1]
              $tunnelDomain = $tunnelUrl -replace "https?://", "" -replace "/.*", ""
      
              # Check if .env.local already has the correct URL
              $content = Get-Content $envFile -ErrorAction SilentlyContinue
              $currentUrl = ($content | Where-Object { $_ -match "^MCP_SERVER_URL=" }) -replace "^MCP_SERVER_URL=", ""
      
              if ($currentUrl -eq $tunnelUrl) {
                  Write-Host ""
                  Write-Host "Environment already configured (URL unchanged):"
                  Write-Host "   MCP_SERVER_URL=$tunnelUrl"
                  Write-Host "   MCP_SERVER_DOMAIN=$tunnelDomain"
                  Write-Host ""
              } else {
                  Write-Host ""
                  Write-Host "Updating $envFile..."
      
                  $content = $content -replace "^MCP_SERVER_URL=.*", "MCP_SERVER_URL=$tunnelUrl"
                  $content = $content -replace "^MCP_SERVER_DOMAIN=.*", "MCP_SERVER_DOMAIN=$tunnelDomain"
                  $content | Out-File -FilePath $envFile -Encoding UTF8
      
                  Write-Host ""
                  Write-Host "Environment configured:"
                  Write-Host "   MCP_SERVER_URL=$tunnelUrl"
                  Write-Host "   MCP_SERVER_DOMAIN=$tunnelDomain"
                  Write-Host ""
                  Write-Host "NOTE: First-time setup - run 'npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local' to deploy the agent."
                  Write-Host ""
              }
          }
      }
      ```
      
      ## package.json Scripts
      
      ### Root package.json
      
      ```json
      {
        "scripts": {
          "tunnel": "bash scripts/setup-devtunnel.sh",
          "tunnel:win": "powershell scripts/setup-devtunnel.ps1",
          "dev:server": "cd mcp-server && npm run dev",
          "install:server": "cd mcp-server && npm install"
        }
      }
      ```
      
      ### mcp-server/package.json
      
      ```json
      {
        "scripts": {
          "dev": "tsx watch src/index.ts",
          "tunnel": "bash ../scripts/setup-devtunnel.sh",
          "tunnel:win": "powershell ../scripts/setup-devtunnel.ps1"
        }
      }
      ```
      
      ## MCP Server Environment Integration
      
      ### Load Environment Variables
      
      ```typescript
      import { config } from "dotenv";
      import path from "node:path";
      import { fileURLToPath } from "node:url";
      
      const __dirname = path.dirname(fileURLToPath(import.meta.url));
      
      // Load .env.local from project root
      config({ path: path.resolve(__dirname, "../../env/.env.local") });
      
      const port = Number(process.env.DEVTUNNEL_PORT ?? process.env.PORT ?? 3001);
      const serverUrl = process.env.MCP_SERVER_URL ?? `http://localhost:${port}`;
      const serverDomain = process.env.MCP_SERVER_DOMAIN ?? "localhost";
      
      console.log(`Server URL: ${serverUrl}`);
      console.log(`Server Domain: ${serverDomain}`);
      ```
      
      ### Use Environment for CSP
      
      ```typescript
      // Resource metadata with dynamic CSP from environment
      function resourceMeta() {
        const domain = process.env.MCP_SERVER_DOMAIN ?? "localhost";
        const url = process.env.MCP_SERVER_URL ?? `http://localhost:${port}`;
      
        return {
          "openai/widgetDomain": url,
          "openai/widgetCSP": {
            connect_domains: [url, `https://${domain}`],
            resource_domains: [url, `https://${domain}`],
          },
        };
      }
      
      // Tool metadata with dynamic widget URL
      function toolMeta(widgetPath: string) {
        return {
          "openai/outputTemplate": `ui://widget/${widgetPath}`,
          "openai/widgetAccessible": true,
        };
      }
      ```
      
      ### Add dotenv Dependency
      
      ```bash
      npm install dotenv
      ```
      
      ## Development Workflow
      
      ### Terminal 1: Start MCP Server
      
      ```bash
      cd mcp-server
      npm install
      npm run dev
      ```
      
      ### Terminal 2: Start DevTunnel
      
      ```bash
      # From project root
      npm run tunnel
      # Or on Windows:
      npm run tunnel:win
      ```
      
      The script will:
      1. Create a named tunnel (first run only) or reuse the existing one
      2. Start hosting the tunnel on the configured port
      3. Update `env/.env.local` with `MCP_SERVER_URL` and `MCP_SERVER_DOMAIN` (first run only — URL is stable)
      4. Continue hosting the tunnel
      
      ### After First-Time Setup
      
      On the **first run**, deploy the agent once:
      
      ```bash
      npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local
      ```
      
      On **subsequent runs**, the tunnel URL is the same — just restart the tunnel and MCP server. No re-provisioning needed unless you change the agent manifest (mcpPlugin.json, declarativeAgent.json, etc.).
      
      ## Why Named Tunnels?
      
      Random tunnels (`devtunnel host -p 3001`) generate a new URL every time. This creates a cascading update problem:
      
      1. New tunnel → new URL
      2. `.env.local` must be updated → MCP server must restart (new CSP headers)
      3. Agent manifest has old URL → must re-provision with `npx -y --package @microsoft/m365agentstoolkit-cli atk provision`
      
      With named tunnels, the URL is **stable across restarts**. Create once, reuse forever:
      
      ```bash
      # One-time setup (handled by the script automatically):
      devtunnel create -a my-mcp-agent
      devtunnel port create my-mcp-agent -p 3001
      
      # Every time you develop (URL stays the same):
      devtunnel host my-mcp-agent
      ```
      
      ## Troubleshooting
      
      | Issue | Solution |
      |-------|----------|
      | `devtunnel: command not found` | Install Azure DevTunnels CLI |
      | CSP errors in Copilot | Verify `MCP_SERVER_DOMAIN` matches tunnel domain in `.env.local` |
      | Server not accessible through tunnel | Ensure MCP server is running before starting tunnel |
      | Permission denied on script | Run `chmod +x scripts/setup-devtunnel.sh` |
      | Agent not updated after manifest change | Bump version in manifest.json and redeploy with `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` |
      | `EADDRINUSE` port conflict | Previous server instance still running. Windows: `taskkill //PID <pid> //F`. Linux/Mac: `lsof -ti:<port> \| xargs kill -9` |
      | DevTunnel login fails with CA policy error | Tenant Conditional Access blocks device code auth on managed devices. Use GitHub auth: `devtunnel user login -g -d` |
      | Named tunnel already exists with wrong config | Delete and recreate: `devtunnel delete <name>` then re-run the setup script |
      | Tunnel process dies between sessions | Use `detach: true` when running via agent, or `Start-Process -WindowStyle Hidden` on Windows. The URL remains stable — just restart the tunnel |
      
    • mcp-server-pattern.md 14.3 KB
      # MCP Server Pattern (TypeScript)
      
      > **This is the TypeScript reference implementation.** It demonstrates all Copilot widget protocol
      > requirements using Node.js and `@modelcontextprotocol/sdk`. For a language-agnostic description
      > of what your MCP server must implement, see [copilot-widget-protocol.md](copilot-widget-protocol.md).
      
      ## Table of Contents
      - [Dependencies](#dependencies)
      - [Server Implementation](#server-implementation)
      - [tsconfig.json](#tsconfigjson)
      - [package.json scripts](#packagejson-scripts)
      - [Static Asset Serving (Local Development)](#static-asset-serving-local-development)
        - [Configurable Widget Base URL](#configurable-widget-base-url)
      - [Next Steps](#next-steps)
      
      Complete implementation pattern for MCP servers with Copilot Chat widget support.
      
      ## Dependencies
      
      ```json
      {
        "dependencies": {
          "@modelcontextprotocol/sdk": "^1.12.0",
          "dotenv": "^16.4.0"
        },
        "devDependencies": {
          "@types/node": "^22.x",
          "tsx": "^4.x",
          "typescript": "^5.x"
        }
      }
      ```
      
      ## Server Implementation
      
      ```typescript
      import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
      import { randomUUID } from "node:crypto";
      import fs from "node:fs";
      import path from "node:path";
      import { URL, fileURLToPath } from "node:url";
      import { config } from "dotenv";
      
      import { Server } from "@modelcontextprotocol/sdk/server/index.js";
      import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
      import {
        CallToolRequestSchema,
        ListToolsRequestSchema,
        ListResourcesRequestSchema,
        ReadResourceRequestSchema,
        type CallToolRequest,
        type Tool,
        type Resource,
      } from "@modelcontextprotocol/sdk/types.js";
      
      const __dirname = path.dirname(fileURLToPath(import.meta.url));
      const WIDGETS_DIR = path.resolve(__dirname, "..", "widgets");
      
      // Load environment from .env.local (auto-populated by devtunnel script)
      config({ path: path.resolve(__dirname, "../../env/.env.local") });
      
      // Widget URIs use ui:// protocol
      const WIDGET_URI = "ui://widget/my-widget.html";
      const MIME_TYPE = "text/html+skybridge";
      
      // Read from environment (populated by devtunnel script)
      const port = Number(process.env.DEVTUNNEL_PORT ?? process.env.PORT ?? 3001);
      const serverUrl = process.env.MCP_SERVER_URL ?? `http://localhost:${port}`;
      const serverDomain = process.env.MCP_SERVER_DOMAIN ?? "localhost";
      
      console.log(`MCP Server URL: ${serverUrl}`);
      console.log(`MCP Server Domain: ${serverDomain}`);
      
      // Tool metadata for OpenAI Apps SDK
      function toolMeta(templateUri: string) {
        return {
          "openai/outputTemplate": templateUri,
          "openai/widgetAccessible": true,
          "openai/toolInvocation/invoking": "Processing...",
          "openai/toolInvocation/invoked": "Complete",
        };
      }
      
      // Resource metadata with CSP from environment
      function resourceMeta() {
        return {
          "openai/widgetDomain": serverUrl,
          "openai/widgetCSP": {
            connect_domains: [serverUrl, `https://${serverDomain}`],
            resource_domains: [serverUrl, `https://${serverDomain}`],
          },
        };
      }
      
      // Define tools with input schema
      const tools: Tool[] = [
        {
          name: "render_data",
          title: "Render Data Widget",
          description: "Renders data in a rich widget. Pass the data to display.",
          inputSchema: {
            type: "object",
            properties: {
              title: { type: "string", description: "Widget title" },
              items: {
                type: "array",
                items: {
                  type: "object",
                  properties: {
                    name: { type: "string" },
                    value: { type: "string" },
                  },
                  required: ["name", "value"],
                },
              },
            },
            required: ["title", "items"],
            additionalProperties: false,
          },
          _meta: toolMeta(WIDGET_URI),
          annotations: {
            destructiveHint: false,
            openWorldHint: false,
            readOnlyHint: true,
          },
        },
      ];
      
      // Define resources (widgets)
      const resources: Resource[] = [
        {
          name: "My Widget",
          uri: WIDGET_URI,
          description: "Widget for displaying data",
          mimeType: MIME_TYPE,
          _meta: resourceMeta(),
        },
      ];
      
      function createMCPServer(): Server {
        const server = new Server(
          { name: "my-mcp-server", version: "1.0.0" },
          { capabilities: { resources: {}, tools: {} } }
        );
      
        // List resources
        server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources }));
      
        // Read resource (return widget HTML)
        server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
          const uri = request.params.uri;
          if (uri === WIDGET_URI) {
            const html = fs.readFileSync(path.join(WIDGETS_DIR, "my-widget.html"), "utf8");
            return {
              contents: [{ uri: WIDGET_URI, mimeType: MIME_TYPE, text: html, _meta: resourceMeta() }],
            };
          }
          throw new Error(`Unknown resource: ${uri}`);
        });
      
        // List tools
        server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
      
        // Handle tool calls
        server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
          if (request.params.name === "render_data") {
            const args = request.params.arguments as { title: string; items: Array<{ name: string; value: string }> };
      
            // Validate
            if (!args.title || !args.items) {
              throw new Error("Missing required fields: title and items");
            }
      
            // Build structuredContent for widget
            const structuredContent = {
              title: args.title,
              items: args.items,
              processedAt: new Date().toISOString(),
            };
      
            return {
              content: [{ type: "text", text: `Rendered ${args.items.length} items` }],
              structuredContent,
              _meta: toolMeta(WIDGET_URI),
            };
          }
      
          throw new Error(`Unknown tool: ${request.params.name}`);
        });
      
        return server;
      }
      
      // Session management
      type SessionRecord = { server: Server; transport: StreamableHTTPServerTransport };
      const sessions = new Map<string, SessionRecord>();
      
      function isInitializeRequest(body: unknown): boolean {
        return typeof body === "object" && body !== null && "method" in body &&
               (body as { method: string }).method === "initialize";
      }
      
      async function handleMcpRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) {
        const sessionId = req.headers["mcp-session-id"] as string | undefined;
      
        let transport: StreamableHTTPServerTransport;
      
        if (sessionId && sessions.has(sessionId)) {
          transport = sessions.get(sessionId)!.transport;
        } else if (!sessionId && isInitializeRequest(parsedBody)) {
          const server = createMCPServer();
          transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: () => randomUUID(),
            onsessioninitialized: (newSessionId) => {
              sessions.set(newSessionId, { server, transport });
            },
          });
          transport.onclose = () => {
            const sid = transport.sessionId;
            if (sid) sessions.delete(sid);
          };
          await server.connect(transport);
        } else {
          res.writeHead(400, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null }));
          return;
        }
      
        await transport.handleRequest(req, res, parsedBody);
      }
      
      // CORS origin checking — allow *.m365.cloud.microsoft and m365.cloud.microsoft
      function isAllowedOrigin(origin: string | undefined): boolean {
        if (!origin) return false;
        try {
          const { hostname } = new URL(origin);
          return hostname === "m365.cloud.microsoft" || hostname.endsWith(".m365.cloud.microsoft");
        } catch {
          return false;
        }
      }
      
      function setCorsHeaders(req: IncomingMessage, res: ServerResponse): void {
        const origin = req.headers.origin;
        if (isAllowedOrigin(origin)) {
          res.setHeader("Access-Control-Allow-Origin", origin!);
          res.setHeader("Access-Control-Expose-Headers", "mcp-session-id, mcp-protocol-version");
        }
      }
      
      // HTTP Server
      const httpServer = createServer(async (req, res) => {
        if (!req.url) { res.writeHead(400).end(); return; }
        const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
      
        // CORS preflight
        if (req.method === "OPTIONS" && url.pathname === "/mcp") {
          const origin = req.headers.origin;
          if (isAllowedOrigin(origin)) {
            res.writeHead(204, {
              "Access-Control-Allow-Origin": origin!,
              "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
              "Access-Control-Allow-Headers": "Content-Type, mcp-session-id, Last-Event-ID, mcp-protocol-version",
              "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
            });
          } else {
            res.writeHead(204);
          }
          res.end();
          return;
        }
      
        // Set CORS for MCP
        if (url.pathname === "/mcp") {
          setCorsHeaders(req, res);
        }
      
        // Health check
        if (req.method === "GET" && url.pathname === "/health") {
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ status: "ok" }));
          return;
        }
      
        // MCP POST (messages)
        if (req.method === "POST" && url.pathname === "/mcp") {
          let body = "";
          for await (const chunk of req) body += chunk;
          const parsedBody = JSON.parse(body);
          await handleMcpRequest(req, res, parsedBody);
          return;
        }
      
        // MCP GET (SSE)
        if (req.method === "GET" && url.pathname === "/mcp") {
          const sessionId = req.headers["mcp-session-id"] as string | undefined;
          if (!sessionId || !sessions.has(sessionId)) {
            res.writeHead(400).end();
            return;
          }
          await sessions.get(sessionId)!.transport.handleRequest(req, res);
          return;
        }
      
        // MCP DELETE (session termination)
        if (req.method === "DELETE" && url.pathname === "/mcp") {
          const sessionId = req.headers["mcp-session-id"] as string | undefined;
          if (sessionId && sessions.has(sessionId)) {
            await sessions.get(sessionId)!.transport.handleRequest(req, res);
          }
          return;
        }
      
        // Serve widgets
        if (req.method === "GET" && url.pathname.startsWith("/widgets/")) {
          const widgetFile = url.pathname.replace("/widgets/", "");
          const widgetPath = path.join(WIDGETS_DIR, widgetFile);
          const resolvedPath = path.resolve(widgetPath);
      
          // Security check: prevent path traversal attacks
          // Ensure the resolved path is actually within WIDGETS_DIR
          const relativePath = path.relative(WIDGETS_DIR, resolvedPath);
          if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
            res.writeHead(403).end("Forbidden");
            return;
          }
      
          if (fs.existsSync(resolvedPath)) {
            const headers: Record<string, string> = { "Content-Type": "text/html" };
            const origin = req.headers.origin;
            if (isAllowedOrigin(origin)) headers["Access-Control-Allow-Origin"] = origin!;
            res.writeHead(200, headers);
            res.end(fs.readFileSync(resolvedPath, "utf8"));
            return;
          }
          res.writeHead(404).end("Not found");
          return;
        }
      
        res.writeHead(404).end("Not Found");
      });
      
      httpServer.listen(port, () => {
        console.log(`MCP Server running on http://localhost:${port}`);
        console.log(`MCP endpoint: http://localhost:${port}/mcp`);
      });
      ```
      
      ## tsconfig.json
      
      ```json
      {
        "compilerOptions": {
          "target": "ES2022",
          "module": "NodeNext",
          "moduleResolution": "NodeNext",
          "outDir": "./dist",
          "rootDir": "./src",
          "strict": true,
          "esModuleInterop": true,
          "skipLibCheck": true
        },
        "include": ["src/**/*"]
      }
      ```
      
      ## package.json scripts
      
      ```json
      {
        "type": "module",
        "scripts": {
          "build": "tsc",
          "start": "node dist/index.js",
          "dev": "tsx watch src/index.ts"
        }
      }
      ```
      
      ## Static Asset Serving (Local Development)
      
      For local development, the MCP server can serve static assets (JS, CSS, images) that widgets reference via `<script>` and `<link>` tags. This avoids needing a separate asset server during development.
      
      > **Note**: This pattern is for local development convenience only. For production distribution, assets should be hosted on a CDN or packaged with your deployment pipeline.
      
      Add an `/assets/*` route to the HTTP server:
      
      ```typescript
      // MIME type map for static assets
      const MIME_TYPES: Record<string, string> = {
        ".html": "text/html",
        ".css": "text/css",
        ".js": "application/javascript",
        ".json": "application/json",
        ".png": "image/png",
        ".svg": "image/svg+xml",
        ".ico": "image/x-icon",
      };
      
      const ASSETS_DIR = path.resolve(__dirname, "..", "assets");
      
      // Static asset serving (local development only)
      if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
        const assetFile = url.pathname.replace("/assets/", "");
        const assetPath = path.join(ASSETS_DIR, assetFile);
        const resolvedPath = path.resolve(assetPath);
      
        // Path traversal guard: ensure resolved path stays within ASSETS_DIR
        const relativePath = path.relative(ASSETS_DIR, resolvedPath);
        if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
          res.writeHead(403).end("Forbidden");
          return;
        }
      
        if (fs.existsSync(resolvedPath)) {
          const ext = path.extname(resolvedPath).toLowerCase();
          const contentType = MIME_TYPES[ext] || "application/octet-stream";
          const assetHeaders: Record<string, string> = { "Content-Type": contentType };
          const origin = req.headers.origin;
          if (isAllowedOrigin(origin)) assetHeaders["Access-Control-Allow-Origin"] = origin!;
          res.writeHead(200, assetHeaders);
          res.end(fs.readFileSync(resolvedPath));
          return;
        }
        res.writeHead(404).end("Not found");
        return;
      }
      ```
      
      ### Configurable Widget Base URL
      
      When the MCP server is behind a devtunnel, widgets that load external JS/CSS need to reference the tunnel URL instead of `localhost`. Use the `WIDGET_BASE_URL` environment variable to make this configurable:
      
      ```typescript
      // Widget base URL: use tunnel URL in remote mode, localhost in local mode
      const widgetBaseUrl = process.env.WIDGET_BASE_URL ?? process.env.MCP_SERVER_URL ?? `http://localhost:${port}`;
      
      // Use in widget templates or pass via structuredContent
      const structuredContent = {
        baseUrl: widgetBaseUrl,
        // ... other widget data
      };
      ```
      
      Set in `env/.env.local` (auto-populated by the devtunnel script):
      ```bash
      WIDGET_BASE_URL=https://xxxxx-3001.usw2.devtunnels.ms
      ```
      
      This allows widgets to reference `<script src="${baseUrl}/assets/app.js">` and work correctly whether running locally or through a tunnel.
      
      ## Next Steps
      
      After the MCP server is running:
      
      1. **Start the devtunnel** - See [devtunnels.md](devtunnels.md) for automated setup
      2. **Configure the agent** - Use the `m365-json-agent-developer` skill to set up:
         - `mcpPlugin.json` with `RemoteMCPServer` runtime
         - `declarativeAgent.json` with capabilities and actions
      3. **Deploy** - Run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local`
      
    • plugin-schema.md 6.8 KB
      # MCP Plugin Schema
      
      mcpPlugin.json configuration for M365 Copilot declarative agents.
      
      > **Naming note:** The property key for tool definitions in the runtime spec is `mcp_tool_description`. Older documentation may reference `x-mcp_tool_description` with the `x-` prefix — this older form is no longer supported and can cause MCP agent provisioning failures. Always use `mcp_tool_description` (without `x-`) in new configurations.
      
      ## ⚠️ CRITICAL: Use MCP Inspector ⚠️
      
      **NEVER manually write tool definitions.** Always use MCP Inspector to get the complete tool definitions from your running MCP server:
      
      ```bash
      npx @modelcontextprotocol/inspector@0.20.0
      ```
      
      Copy the COMPLETE tool definition from the inspector (including `name`, `description`, `inputSchema`, `_meta`, `annotations`, `title`) and paste into `mcpPlugin.json`.
      
      ## Complete Example
      
      ```json
      {
        "$schema": "https://developer.microsoft.com/json-schemas/copilot/plugin/v2.4/schema.json",
        "schema_version": "v2.4",
        "name_for_human": "My Plugin",
        "description_for_human": "Short description for users",
        "description_for_model": "Detailed description for Copilot. Explain when and how to use each tool. Be specific about what data to pass.",
        "contact_email": "support@example.com",
        "namespace": "myplugin",
        "legal_info_url": "https://example.com/legal",
        "privacy_policy_url": "https://example.com/privacy",
        "capabilities": {
          "conversation_starters": [
            {
              "title": "Starter title",
              "text": "What Copilot says when clicked"
            }
          ]
        },
        "runtimes": [
          {
            "type": "RemoteMCPServer",
            "auth": {
              "type": "None"
            },
            "run_for_functions": [
              "tool_name_1",
              "tool_name_2"
            ],
            "spec": {
              "url": "${{MCP_SERVER_URL}}/mcp",
              "mcp_tool_description": {
                "tools": [
                  {
                    "name": "tool_name_1",
                    "title": "Human-Readable Title",
                    "description": "Detailed description of what the tool does and when to use it.",
                    "inputSchema": {
                      "type": "object",
                      "properties": {
                        "param1": {
                          "type": "string",
                          "description": "Description with examples"
                        },
                        "param2": {
                          "type": "array",
                          "description": "Array description",
                          "items": {
                            "type": "object",
                            "properties": {
                              "field1": { "type": "string" },
                              "field2": { "type": "string" }
                            },
                            "required": ["field1"]
                          }
                        }
                      },
                      "required": ["param1", "param2"],
                      "additionalProperties": false
                    },
                    "_meta": {
                      "openai/outputTemplate": "ui://widget/my-widget.html",
                      "openai/widgetAccessible": true,
                      "openai/toolInvocation/invoking": "Processing...",
                      "openai/toolInvocation/invoked": "Done"
                    },
                    "annotations": {
                      "destructiveHint": false,
                      "openWorldHint": false,
                      "readOnlyHint": true
                    }
                  }
                ]
              }
            }
          }
        ],
        "functions": [
          {
            "name": "tool_name_1",
            "description": "Same description as in tools array"
          }
        ]
      }
      ```
      
      **Note:** The tool definition above should be copied from MCP Inspector, not manually written.
      
      ## Required Fields
      
      | Field | Description |
      |-------|-------------|
      | `$schema` | Must be `v2.4` schema URL |
      | `schema_version` | Must be `"v2.4"` |
      | `name_for_human` | Display name |
      | `description_for_human` | Short user-facing description |
      | `description_for_model` | Detailed description for Copilot |
      | `runtimes` | Array with RemoteMCPServer config |
      | `functions` | Array of function name/description pairs |
      
      ## Runtime Configuration
      
      ```json
      {
        "type": "RemoteMCPServer",
        "auth": { "type": "None" },
        "run_for_functions": ["tool1", "tool2"],
        "spec": {
          "url": "${{MCP_SERVER_URL}}/mcp",
          "mcp_tool_description": { "tools": [...] }
        }
      }
      ```
      
      ### Auth Types
      
      - `"None"` - No authentication
      - `"OAuthPluginVault"` - OAuth via plugin vault
      
      ## Tool Definition
      
      ```json
      {
        "name": "tool_name",
        "title": "Display Title",
        "description": "When and how to use this tool",
        "inputSchema": { /* JSON Schema */ },
        "_meta": {
          "openai/outputTemplate": "ui://widget/name.html",
          "openai/widgetAccessible": true,
          "openai/toolInvocation/invoking": "Loading...",
          "openai/toolInvocation/invoked": "Loaded"
        },
        "annotations": {
          "destructiveHint": false,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      }
      ```
      
      ## _meta Fields
      
      | Field | Description |
      |-------|-------------|
      | `openai/outputTemplate` | Widget URI (`ui://widget/name.html`) |
      | `openai/widgetAccessible` | Enable widget rendering (`true`) |
      | `openai/toolInvocation/invoking` | Message while executing |
      | `openai/toolInvocation/invoked` | Message when complete |
      
      ## Annotations
      
      | Field | Description |
      |-------|-------------|
      | `destructiveHint` | Tool modifies data (`false` for rendering tools) |
      | `openWorldHint` | Tool accesses external systems |
      | `readOnlyHint` | Tool only reads/renders data (`true` for rendering tools) |
      
      ## Input Schema Patterns
      
      ### Object with required fields
      
      ```json
      {
        "type": "object",
        "properties": {
          "name": { "type": "string", "description": "Full name (e.g., 'John Doe')" },
          "email": { "type": "string", "description": "Email address" }
        },
        "required": ["name", "email"],
        "additionalProperties": false
      }
      ```
      
      ### Nested object
      
      ```json
      {
        "type": "object",
        "properties": {
          "person": {
            "type": "object",
            "properties": {
              "name": { "type": "string" },
              "title": { "type": "string" }
            },
            "required": ["name"]
          }
        },
        "required": ["person"]
      }
      ```
      
      ### Array of objects
      
      ```json
      {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "id": { "type": "string" },
            "value": { "type": "string" }
          },
          "required": ["id", "value"]
        }
      }
      ```
      
      ## Environment Variables
      
      Use `${{VAR_NAME}}` syntax for environment variables:
      
      - `${{MCP_SERVER_URL}}` - Full server URL (e.g., `https://tunnel.devtunnels.ms`)
      - `${{MCP_SERVER_DOMAIN}}` - Domain only for validDomains
      
      Define in `env/.env.local`:
      
      ```
      MCP_SERVER_URL=https://your-tunnel.devtunnels.ms
      MCP_SERVER_DOMAIN=your-tunnel.devtunnels.ms
      ```
      
      ## Common Errors
      
      | Error | Fix |
      |-------|-----|
      | `name_for_model` unrecognized | Remove it (not in v2.4) |
      | `MCP` runtime type invalid | Use `RemoteMCPServer` |
      | `transport` unrecognized | Remove it |
      | `run_for_functions` required | Add array of tool names |
      | Missing `auth` | Add `{ "type": "None" }` |
      
    • widget-patterns.md 6.2 KB
      # Widget Patterns
      
      ## Table of Contents
      - [Required Dependencies](#required-dependencies)
      - [Widget Template](#widget-template)
      - [Data Access Pattern](#data-access-pattern)
      - [Theme Support Pattern](#theme-support-pattern)
      - [CSS Variables (Required)](#css-variables-required)
      - [Debug Data Pattern](#debug-data-pattern)
      - [XSS Prevention](#xss-prevention)
      - [Action Buttons](#action-buttons)
      
      React widgets for OpenAI Apps SDK with Copilot Chat.
      
      MANDATORY: Use Fluent UI (`@fluentui/react-components` and `@fluentui/react-icons`) for widget UI. Avoid raw HTML string rendering for app content.
      
      ## Required Dependencies
      
      Widget projects MUST include these package dependencies before implementation:
      
      - `@fluentui/react-components`
      - `@fluentui/react-icons`
      - `react`
      - `react-dom`
      
      If any required dependency is missing, install it before generating widget code.
      
      ## Widget Template
      
      ```tsx
      // index.html (minimal shell)
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Widget Name</title>
        <style>
          html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; }
          #root { height: 100%; overflow-y: auto; }
        </style>
      </head>
      <body>
        <div id="root"></div>
        <script type="module" src="./main.tsx"></script>
      </body>
      </html>
      
      // main.tsx
      import React from "react";
      import { createRoot } from "react-dom/client";
      import { FluentProvider, webDarkTheme, webLightTheme } from "@fluentui/react-components";
      import { Widget } from "./Widget";
      import { useOpenAiGlobal } from "../hooks/useOpenAiGlobal";
      
      function App() {
        const theme = (useOpenAiGlobal<string>("theme") ?? "light").toLowerCase();
        return (
          <FluentProvider theme={theme === "dark" ? webDarkTheme : webLightTheme}>
            <Widget />
          </FluentProvider>
        );
      }
      
      createRoot(document.getElementById("root")!).render(<App />);
      
      // Widget.tsx
      import React from "react";
      import {
        Body1,
        Card,
        Table,
        TableBody,
        TableCell,
        TableCellLayout,
        TableHeader,
        TableHeaderCell,
        TableRow,
        Title3,
        makeStyles,
        tokens,
      } from "@fluentui/react-components";
      import { useOpenAiGlobal } from "../hooks/useOpenAiGlobal";
      
      type WidgetData = {
        title?: string;
        items?: Array<{ name: string; value: string }>;
      };
      
      const useStyles = makeStyles({
        root: { padding: "16px", display: "grid", gap: "12px" },
        empty: { color: tokens.colorNeutralForeground3 },
      });
      
      export function Widget() {
        const styles = useStyles();
        const data = useOpenAiGlobal<WidgetData>("toolOutput") ?? { title: "Untitled", items: [] };
      
        if (!data.items?.length) {
          return <div className={styles.root}><Body1 className={styles.empty}>No items</Body1></div>;
        }
      
        return (
          <div className={styles.root}>
            <Title3>{data.title ?? "Untitled"}</Title3>
            <Card>
              <Table size="small">
                <TableHeader>
                  <TableRow>
                    <TableHeaderCell>Name</TableHeaderCell>
                    <TableHeaderCell>Value</TableHeaderCell>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {data.items.map((item, idx) => (
                    <TableRow key={idx}>
                      <TableCell><TableCellLayout>{item.name}</TableCellLayout></TableCell>
                      <TableCell>{item.value}</TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </Card>
          </div>
        );
      }
      ```
      
      ## Data Access Pattern
      
      ```tsx
      import { useEffect, useState } from "react";
      
      type OpenAIKey =
        | "toolOutput"
        | "widgetState"
        | "structuredContent"
        | "data"
        | "theme"
        | "displayMode";
      
      declare global {
        interface Window {
          openai?: Record<string, unknown>;
        }
      }
      
      export function useOpenAiGlobal<T = unknown>(key: OpenAIKey): T | undefined {
        const [value, setValue] = useState<T | undefined>(() => window.openai?.[key] as T | undefined);
      
        useEffect(() => {
          const id = setInterval(() => {
            const next = window.openai?.[key] as T | undefined;
            setValue((prev) => (JSON.stringify(prev) !== JSON.stringify(next) ? next : prev));
          }, 200);
      
          return () => clearInterval(id);
        }, [key]);
      
        return value;
      }
      
      // Priority order for widget content data
      const data = useOpenAiGlobal("toolOutput") ??
                   useOpenAiGlobal("widgetState") ??
                   useOpenAiGlobal("structuredContent") ??
                   useOpenAiGlobal("data");
      ```
      
      ## Theme Support Pattern
      
      ```tsx
      import { FluentProvider, webDarkTheme, webLightTheme } from "@fluentui/react-components";
      
      function ThemedRoot({ children }: { children: React.ReactNode }) {
        const theme = (window.openai?.theme as string | undefined)?.toLowerCase() ?? "light";
      
        return (
          <FluentProvider theme={theme === "dark" ? webDarkTheme : webLightTheme}>
            {children}
          </FluentProvider>
        );
      }
      ```
      
      ## CSS Variables (Required)
      
      Use Fluent tokens first. If custom CSS is needed, keep variables at `:root` and support dark mode:
      
      ```css
      :root {
        --widget-surface: #f9fafb;
        --widget-card-bg: #ffffff;
        --widget-border: #e5e7eb;
      }
      
      @media (prefers-color-scheme: dark) {
        :root {
          --widget-surface: #1b1b1b;
          --widget-card-bg: #262626;
          --widget-border: #3f3f46;
        }
      }
      
      body.theme-dark { /* Same as dark :root */ }
      body.theme-light { /* Same as light :root */ }
      ```
      
      ## Debug Data Pattern
      
      Always include fallback data for local testing:
      
      ```tsx
      const DEBUG_DATA = {
        title: "Debug Mode",
        items: [{ name: "Test Item", value: "Test Value" }],
      };
      
      function getWidgetData() {
        if (window.openai) {
          return window.openai.toolOutput ||
                 window.openai.widgetState ||
                 window.openai.structuredContent ||
                 window.openai.data ||
                 null;
        }
      
        return DEBUG_DATA;
      }
      ```
      
      ## XSS Prevention
      
      Prefer React rendering over `innerHTML`. React escapes text content by default:
      
      ```tsx
      // Safe by default in React
      <Body1>{userData}</Body1>
      
      // Avoid raw HTML unless trusted and sanitized first
      // <div dangerouslySetInnerHTML={{ __html: trustedHtml }} />
      ```
      
      ## Action Buttons
      
      ```tsx
      import { Button } from "@fluentui/react-components";
      
      <Button appearance="primary" as="a" href={`mailto:${email}`}>
        Email
      </Button>
      
      <Button
        appearance="outline"
        as="a"
        target="_blank"
        href={`https://teams.microsoft.com/l/chat/0/0?users=${encodeURIComponent(email)}`}
      >
        Chat
      </Button>
      ```
      
  • SKILL.md 22.8 KB
    ---
    name: ui-widget-developer
    description: |
      Build MCP servers for Copilot Chat using the OpenAI Apps SDK or MCP Apps SDK widget rendering support (any language). Use this skill when:
      - Creating MCP servers that integrate with M365 Copilot declarative agents
      - Building rich interactive widgets (React + Fluent UI) that render in Copilot Chat
      - Implementing tools that return structuredContent for widget rendering
      - Adapting an existing MCP server to support Copilot widget rendering
      - Setting up devtunnels for localhost MCP server exposure
      - Configuring mcpPlugin.json manifests with RemoteMCPServer runtime
      Do NOT use this skill for general agent development (scaffolding, manifests, deployment) — use declarative-agent-developer instead. This skill is ONLY for MCP server + widget development.
      Triggers: "MCP server for Copilot", "OpenAI Apps SDK", "Copilot widget", "structuredContent", "MCP plugin", "devtunnels MCP", "OAI app", "widget rendering", "UI widget"
    ---
    
    # Copilot MCP Server Development
    
    ## Telemetry Tagging
    
    Before running any `atk` CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated:
    ```bash
    export ATK_CLI_SKILL=true
    ```
    Run this once at the start of the session. All subsequent `atk` commands in the same terminal will inherit it.
    
    ## 🔀 PATH SELECTION
    
    **Before proceeding, ask the user which path they want to take using AskUserQuestion.** Both are supported for M365 Copilot widget rendering. Present the tradeoffs and let them choose:
    
    | | **OAI Apps** (this skill) | **MCP Apps Extension** (`modelcontextprotocol/ext-apps`) |
    |---|---|---|
    | **Standard** | OpenAI-specific | Official MCP standard |
    | **Works in** | ChatGPT + M365 Copilot | M365 Copilot, ChatGPT, VSCode, and more |
    | **Maturity** | Battle-tested, production-ready | New official standard, growing ecosystem |
    | **Design** | OpenAI Apps SDK | MCP Apps protocol (cross-platform) |
    | **When to choose** | Existing OAI app investment | Prefer the open standard, want broadest client support |
    
    **Ask:** _"Would you like to build an OAI app (OpenAI Apps SDK — battle-tested, works in ChatGPT and M365 Copilot) or an MCP app (new official standard — works in M365 Copilot, ChatGPT, VSCode, and more)?"_
    
    - **OAI apps** → Continue below. This skill covers everything you need.
    - **MCP apps** → Install the `modelcontextprotocol/ext-apps` plugin (see below), then use the appropriate skill from that plugin.
    
    ### MCP Apps: Install ext-apps Plugin
    
    If the user chooses MCP Apps, do this automatically (do not stop at explanation-only):
    
    1. Run `/plugin marketplace add modelcontextprotocol/ext-apps`
    2. Run `/plugin install mcp-apps@mcp-apps`
    3. Confirm the plugin is available, then invoke the correct ext-apps skill based on user intent
    
    If plugin commands are unavailable in the current environment, provide the exact commands below and ask the user to run them once, then continue by invoking the selected ext-apps skill.
    
    Reference commands:
    
    ```
    To build an MCP App, install the ext-apps plugin from the marketplace:
    
    1. /plugin marketplace add modelcontextprotocol/ext-apps
    2. /plugin install mcp-apps@mcp-apps
    
    Then use one of these skills from that plugin:
    - create-mcp-app      — Scaffold a new MCP App with interactive UI from scratch
    - add-app-to-server   — Add interactive UI to an existing MCP server's tools
    - migrate-oai-app     — Convert an existing OAI app to use MCP Apps
    - convert-web-app     — Turn a web app into a hybrid web + MCP App
    
    After installing, invoke the relevant skill to continue.
    ```
    
    > **Note:** The ext-apps plugin lives in the external `modelcontextprotocol/ext-apps` marketplace — it is not part of this plugin collection.
    
    **Handoff mapping after install:**
    - New MCP app from scratch → `create-mcp-app`
    - Add app UI to existing MCP server → `add-app-to-server`
    - Migrate existing OAI app → `migrate-oai-app`
    - Convert an existing web app → `convert-web-app`
    
    ---
    
    ## 📛 PROJECT DETECTION 📛
    
    This skill triggers when building MCP servers with OAI app or widget rendering for Microsoft 365 Copilot Chat. The MCP server can be written in any language that supports the MCP protocol (TypeScript, Python, C#, etc.). The agent project and MCP server may live in the same repo, separate folders, or entirely different projects.
    
    ## Scenario Routing
    
    | Starting Point | What You Need | Path |
    |---------------|---------------|------|
    | **Prefer MCP Apps standard** | Cross-platform widget support (M365 Copilot, ChatGPT, VSCode, and more) | Install `modelcontextprotocol/ext-apps`, then use `create-mcp-app` or `add-app-to-server` — see [Path Selection](#-path-selection) above |
    | **From scratch** (no agent, no MCP server) | Full OAI app setup | Delegate agent scaffolding to `declarative-agent-developer` first, then return here for MCP server + widgets |
    | **Existing M365 agent, new MCP server** | MCP server + widgets + mcpPlugin.json | Start at [Implementation](#implementation) |
    | **Existing MCP server, add Copilot widgets** | Widget support added to existing server | Start at [Copilot Widget Protocol](references/copilot-widget-protocol.md#adaptation-checklist-existing-mcp-server) |
    | **Language choice** (non-TypeScript) | Protocol requirements | See [Copilot Widget Protocol](references/copilot-widget-protocol.md) for what to implement, [MCP Server Pattern (TypeScript)](references/mcp-server-pattern.md) as a reference |
    
    ---
    
    ## 🚨 CRITICAL EXECUTION RULES 🚨
    
    
    **FLUENT UI ENFORCEMENT (REQUIRED):** Widget implementations MUST use React + Fluent UI components. Before writing any widget code, the agent MUST read and follow:
    - `references/widget-patterns.md`
    - `references/best-practices.md`
    **FLUENT UI PACKAGE REQUIREMENT (REQUIRED):** The widget project MUST include Fluent UI dependencies before implementation. At minimum, install and keep these in the widget package dependencies:
    - `@fluentui/react-components`
    - `react`
    - `react-dom`
    
    If any of these packages are missing, install them automatically before continuing with widget code generation.
    
    If the generated widget does not include React entry files (for example `widgets/src/<widget-name>/main.tsx` and a React component file) and Fluent imports from `@fluentui/react-components`, the task is incomplete and MUST be corrected before returning results.
    
    **NO RAW HTML-ONLY WIDGETS (DEFAULT):** Do not implement app content directly with static HTML templates and inline JS as the final widget solution. A minimal shell HTML file is allowed only as a loader for built React assets. Raw/self-contained HTML-only widgets are allowed only when the user explicitly requests a non-React prototype.
    
    **BACKGROUND PROCESSES:** MCP server and devtunnel MUST be spawned as independent OS processes — NOT run inside the agent's shell session. `isBackground: true`, `mode: "async"`, and `Start-Job` all run inside the agent's shell session and will be killed between messages. The only reliable approach is to spawn a detached OS process.
    
    **Windows — use `Start-Process -WindowStyle Hidden`:**
    ```powershell
    # Start devtunnel
    $t = Start-Process -FilePath "devtunnel" `
        -ArgumentList "host","<tunnel-name>","-a" `
        -WindowStyle Hidden -PassThru `
        -RedirectStandardOutput "tunnel.log" -RedirectStandardError "tunnel-err.log"
    
    # Start MCP server — use cmd.exe /c to set the working directory and inherit PATH
    $s = Start-Process -FilePath "cmd.exe" `
        -ArgumentList "/c","cd /d <abs-path-to-mcp-server> && <start-command>" `
        -WindowStyle Hidden -PassThru `
        -RedirectStandardOutput "server.log" -RedirectStandardError "server-err.log"
    
    # Save PIDs so they can be stopped later
    "$($t.Id),$($s.Id)" | Out-File pids.txt
    Write-Host "Started tunnel PID $($t.Id), server PID $($s.Id)"
    ```
    To stop: `Stop-Process -Id (Get-Content pids.txt).Split(',')` or `Stop-Process -Id <pid>`.
    
    **Linux/Mac — use `nohup` with `&`:**
    ```bash
    nohup devtunnel host <tunnel-name> > tunnel.log 2>tunnel-err.log &
    echo "tunnel:$!" >> pids.txt
    nohup <start-command> > server.log 2>server-err.log &
    echo "server:$!" >> pids.txt
    ```
    To stop: `kill $(grep -oP '\d+' pids.txt)`.
    
    After starting, tail the logs to confirm both processes are up before proceeding:
    ```powershell
    # Windows
    Start-Sleep 3; Get-Content tunnel.log, server.log
    ```
    ```bash
    # Linux/Mac
    sleep 3 && tail tunnel.log server.log
    ```
    
    **FULL AUTOMATION:** Never tell the user to run commands manually. Install tools, authenticate, start services — do everything automatically. Only ask the user for interactive input that truly requires them (like device code confirmation during `devtunnel user login -g -d`). If a tool isn't installed, install it. If a service needs starting, start it. The user expects full automation.
    
    **PATH SELECTION (REQUIRED — STOP BEFORE ANY CODE):** You MUST use `AskUserQuestion` to ask the user whether they want OAI Apps or MCP Apps Extension before writing any code, running any commands, or making any architectural decisions.
    
    **There is no exception to this rule.** The most common failure mode is reasoning "the user's request makes it obvious, so asking is redundant." This reasoning is always wrong — invoke `AskUserQuestion` regardless. A user saying "build an MCP server with widgets" is NOT an answer to this question. A user invoking this skill by name is NOT an answer. Only an explicit answer to the question counts. See [PATH SELECTION](#-path-selection) above for the exact question to ask.
    
    **AGENT PROVISIONING:** Re-provisioning is only required when the **agent manifest** changes (e.g., mcpPlugin.json tool definitions, MCP server URL, declarativeAgent.json, instruction.txt). MCP server code changes (tool implementations, React widget code, server logic) do **NOT** require re-provisioning the agent — running or deploying the server picks up changes automatically.
    
    When provisioning is needed:
    1. **Bump the version** in `manifest.json` (increment the patch version, e.g., `1.0.0` → `1.0.1`)
    2. **Deploy the agent:**
       ```bash
       npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local
       ```
    
    **WIDGET TESTING LINKS:** Every time you return to the user with a result while the MCP server is running, you MUST include links to ALL widgets so they can test them locally. Format:
    ```
    🧪 Test widgets locally:
    - http://localhost:3001/widgets/widget-name.html
    - http://localhost:3001/widgets/another-widget.html
    ```
    List every `.html` file in the `mcp-server/widgets/` directory (or equivalent widget folder). This helps users verify widget rendering before testing in Copilot.
    
    **AUTO-DEPLOY ON COMPLETION (REQUIRED — DO NOT SKIP):** When coding is complete, proceed automatically without waiting for the user:
    1. Start MCP server + devtunnel in the background (per BACKGROUND PROCESSES above)
    2. Run E2E verification with MCP Inspector (per MCP TOOL CONFIGURATION RULE below) — fix any failures before continuing
    3. Provision the agent if needed (per AGENT PROVISIONING above)
    4. Print a project summary in this format:
    ```
    ## ✅ <Project Name> — Ready
    
    ### Widgets
    - [widget-name.html](http://localhost:<PORT>/widgets/widget-name.html)
    - [widget-name2.html](http://localhost:<PORT>/widgets/widget-name2.html)
    
    ### Endpoints
    - MCP server: http://localhost:<PORT>/mcp
    - MCP via tunnel: https://<tunnel-url>/mcp
    
    ### Test in Copilot
    Local:      https://m365.cloud.microsoft/chat/?titleId={M365_TITLE_ID from env/.env.local}
    Other envs: {SHARE_LINK from env/.env.{environment}}
    ```
    
    **AGENT PROJECT DELEGATION:** This skill builds MCP servers and widgets, NOT declarative agent projects. If the user's request involves creating or configuring the declarative agent itself (scaffolding, `m365agents.yml`, `m365agents.local.yml`, `declarativeAgent.json`, manifest lifecycle), delegate to the `declarative-agent-developer` skill.
    
    **MCP RESOURCE REGISTRATION:** Every widget MUST have a matching MCP resource. Without resources, Copilot cannot fetch widget shells through the MCP protocol and widgets will not render.
    
    For each new widget, complete this checklist:
    1. ☐ Create a widget shell HTML file in `widgets/` and a React widget entry under `widgets/src/<widget-name>/` (see widget-patterns.md)
    2. ☐ Define a `ui://widget/<name>.html` URI constant
    3. ☐ Add a `Resource` entry to the `resources` array with:
       - `uri`: the `ui://widget/<name>.html` URI
       - `mimeType`: `"text/html+skybridge"`
       - `_meta`: CSP config with `openai/widgetDomain` and `openai/widgetCSP` (from environment)
    4. ☐ Add a handler for `resources/read` that returns the widget shell HTML for this URI
    5. ☐ Add the tool with `_meta.openai/outputTemplate` pointing to the same `ui://widget/<name>.html` URI
    6. ☐ Verify the server capabilities include `resources: {}` in the initialize response
    
    **Widget shell + asset considerations:**
    - **Preferred (React + Fluent UI)**: Resource HTML should be a minimal shell that links to built JS/CSS assets served from the MCP server's `/assets/` route.
    - **Exception only**: Self-contained HTML via `resources/read` is for explicit user-requested prototypes only. Default and production path is React + Fluent UI.
    
    Example shell for React build output:
      ```html
      <!doctype html><html><head>
        <script type="module" src="${serverUrl}/assets/my-widget.js"></script>
        <link rel="stylesheet" href="${serverUrl}/assets/my-widget.css">
      </head><body>
        <div id="widget-root"></div>
      </body></html>
      ```
      Use the `WIDGET_BASE_URL` or `MCP_SERVER_URL` environment variable for the asset URL base (see mcp-server-pattern.md "Configurable Widget Base URL" section).
    
    See [mcp-server-pattern.md](references/mcp-server-pattern.md) for the complete resource and asset serving patterns.
    
    ---
    
    ## ⚠️ MCP TOOL CONFIGURATION RULE ⚠️
    
    **NEVER manually write tool definitions in `mcpPlugin.json`.** Always use MCP Inspector to get the complete tool definitions from the running MCP server.
    
    **TOOL NAMING CONVENTION:** Tool names MUST match the pattern `^[A-Za-z0-9_]+$` (letters, numbers, and underscores only). **NEVER use hyphens (-) in tool names.** Use underscores instead (e.g., `render_profile` not `render-profile`).
    
    **MANDATORY WORKFLOW:**
    1. **Start the MCP server** (in background)
    2. **Use MCP Inspector** to get the latest tool definitions:
       ```bash
       npx @modelcontextprotocol/inspector@0.20.0 --cli https://my-mcp-server.example.com --transport http --method tools/list
       ```
    3. **Copy the COMPLETE tool definition** from the inspector (including `name`, `description`, `inputSchema`, `_meta`, `annotations`, `title`)
    4. **Paste into `mcpPlugin.json`** under `runtimes[].spec.mcp_tool_description.tools` (inside the `RemoteMCPServer` runtime's `spec` object)
    5. **Run E2E verification** through the devtunnel — call each tool and confirm the response contains `structuredContent` and `_meta.openai/widgetAccessible: true`:
       ```bash
       npx @modelcontextprotocol/inspector@0.20.0 --cli https://<tunnel-url>/mcp --transport http --method tools/call --tool-name <tool_name>
       ```
       Also verify `GET https://<tunnel-url>/health` returns `{"status":"ok"}`. Fix any failures before provisioning.
    
    The MCP Inspector shows the exact tool schema from your server. Copy it completely — do not manually write or modify these definitions. This ensures `mcpPlugin.json` stays in sync with the MCP server.
    
    ---
    
    Build MCP servers that integrate with Microsoft 365 Copilot Chat and render rich interactive widgets.
    
    ## Architecture
    
    ```
    M365 Copilot ──▶ mcpPlugin.json ──▶ MCP Server ──▶ structuredContent ──▶ React + Fluent UI Widget
         │              (RemoteMCPServer)    (Streamable HTTP)                  (window.openai.toolOutput)
         │
         └── Capabilities (People, etc.) provide data to pass to MCP tools
    ```
    
    ## Project Structure
    
    Example project structure, not a hard requirement but a common pattern for organizing MCP server + widget development:
    
    ```
    project/
    ├── appPackage/
    │   ├── manifest.json           # Teams manifest (bump version on deploy)
    │   ├── declarativeAgent.json   # Agent config + capabilities
    │   ├── mcpPlugin.json          # Tool definitions with _meta
    │   └── instruction.txt         # Agent behavior instructions
    ├── mcp-server/
    │   ├── src/index.ts            # Server with Streamable HTTP
    │   ├── widgets/                # Widget shells + React source
    │   │   ├── my-widget.html      # Minimal shell returned by resources/read
    │   │   └── src/my-widget/      # React + Fluent UI source
    │   ├── assets/                 # Built widget bundles served at /assets
    │   └── package.json
    ├── scripts/
    │   ├── setup-devtunnel.sh      # Linux/Mac devtunnel setup
    │   └── setup-devtunnel.ps1     # Windows devtunnel setup
    └── env/.env.local              # MCP_SERVER_URL, MCP_SERVER_DOMAIN
    ```
    
    **Language note**: This shows a TypeScript project layout. For Python, replace `mcp-server/src/index.ts` with your Python entry point (e.g., `server.py`). For C#, use a standard .NET project structure. The `appPackage/`, `widgets/`, `scripts/`, and `env/` directories are language-agnostic.
    
    ## Copilot Widget Protocol
    
    Your MCP server must implement these protocol requirements to render widgets in Copilot Chat. This applies regardless of language:
    
    1. **Streamable HTTP transport** — `/mcp` endpoint handling POST, GET, DELETE with session management
    2. **CORS headers** — Origin-checking on `/mcp` allowing `m365.cloud.microsoft` and `*.m365.cloud.microsoft`, with required MCP headers
    3. **Server capabilities** — `initialize` response must declare `resources: {}` and `tools: {}`
    4. **MCP resources** — Register widgets with `ui://widget/<name>.html` URIs, `text/html+skybridge` mime type, and CSP `_meta`
    5. **Tool response format** — Return `content` (text) + `structuredContent` (widget data) + `_meta` with `openai/outputTemplate`
    6. **Widget serving** — HTTP route at `/widgets/*.html` for shell files and `/assets/*` for built bundles, both with origin-checking CORS
    
    For full protocol details, JSON shapes, and an adaptation checklist for existing MCP servers, see [references/copilot-widget-protocol.md](references/copilot-widget-protocol.md).
    
    ## Implementation
    
    ### MCP Server Pattern (TypeScript Reference)
    
    See [references/mcp-server-pattern.md](references/mcp-server-pattern.md) for complete implementation.
    
    > For other languages, implement the requirements described in [Copilot Widget Protocol](references/copilot-widget-protocol.md) using your language's MCP SDK. See the [Language SDK References](references/copilot-widget-protocol.md#language-sdk-references) table for SDK packages.
    
    Core requirements:
    - Expose Streamable HTTP transport on `/mcp`
    - Return `structuredContent` + `_meta` with `openai/outputTemplate`
    - Serve widgets via HTTP endpoint
    - Handle CORS for cross-origin requests
    - Handle partial data gracefully (fill in "Unknown" for missing fields)
    
    Tool response format:
    ```typescript
    return {
      content: [{ type: "text", text: "Summary" }],
      structuredContent: { /* widget data */ },
      _meta: { "openai/outputTemplate": "ui://widget/name.html", "openai/widgetAccessible": true }
    };
    ```
    
    ### Handling Partial Data
    
    Always normalize input data to handle missing fields:
    
    ```typescript
    server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
      const args = request.params.arguments as { title?: string; items?: Partial<Item>[] };
    
      // Normalize data - fill in "Unknown" for missing fields
      const title = args.title || "Default Title";
      const items = (args.items || []).map(item => ({
        name: item.name || "Unknown",
        value: item.value || "Unknown",
      }));
    
      // Build structuredContent for widget
      const structuredContent = { title, items };
      // ...
    });
    ```
    
    ### Widget Pattern
    
    See [references/widget-patterns.md](references/widget-patterns.md) for complete examples.
    
    Core requirements:
    - Use React + Fluent UI components (`@fluentui/react-components`)
    - Ensure widget package dependencies include `@fluentui/react-components`, `react`, and `react-dom`
    - Theme with `FluentProvider` (`webLightTheme`/`webDarkTheme`) and Fluent `tokens`
    - Access data through shared hooks (e.g., `useOpenAiGlobal("toolOutput")`)
    - Debug fallback: embedded mock data when `window.openai` unavailable
    - Handle "Unknown" values gracefully (e.g., hide action buttons)
    
    ### Plugin Schema
    
    See [references/plugin-schema.md](references/plugin-schema.md) for mcpPlugin.json format.
    
    Core requirements:
    - Schema `v2.4` with `RemoteMCPServer` runtime
    - `run_for_functions` array matching tool names
    - `_meta` in tool definitions for widget binding
    - `inputSchema` - make properties optional for flexibility, describe defaults in descriptions
    
    ## DevTunnels Setup
    
    > **Local testing only.** DevTunnels are for development and testing on your machine. Before sharing the agent more broadly, deploy both the MCP server and widget assets to a hosted environment (e.g., Azure App Service, Azure Static Web Apps, or another hosting provider) and update the agent manifest URLs accordingly.
    
    DevTunnels expose your localhost MCP server to M365 Copilot using **named tunnels** for stable URLs. See [references/devtunnels.md](references/devtunnels.md) for setup scripts, command reference, and troubleshooting.
    
    The setup script (`npm run tunnel` / `npm run tunnel:win`):
    1. Creates a named tunnel on first run (or reuses the existing one)
    2. Starts hosting the tunnel on the configured port
    3. Updates `env/.env.local` with `MCP_SERVER_URL` and `MCP_SERVER_DOMAIN` (first run only)
    4. Continues hosting the tunnel
    
    ### Quick Start
    
    **Terminal 1 - Start MCP Server:**
    ```bash
    cd mcp-server
    npm install
    npm run dev
    ```
    
    **Terminal 2 - Start DevTunnel:**
    ```bash
    npm run tunnel
    # Or on Windows:
    npm run tunnel:win
    ```
    
    On first run, provision the agent once the tunnel is up (see AGENT PROVISIONING rule). On subsequent runs the tunnel URL is stable — no re-provisioning needed unless the agent manifest changes.
    
    ## Development Workflow
    
    1. **Start the MCP server** (dev mode with hot reload):
       - TypeScript: `cd mcp-server && npm install && npm run dev`
       - Python: `cd mcp-server && pip install -r requirements.txt && python server.py`
       - C#: `cd mcp-server && dotnet run`
    
    2. **Start the devtunnel** (creates named tunnel on first run, reuses on subsequent runs):
       ```bash
       npm run tunnel
       ```
    
    3. **Provision + test** — see AGENT PROVISIONING rule for when this is needed; bump `version` in manifest.json if Copilot doesn't reflect changes
    
    ## Best Practices
    
    See [references/best-practices.md](references/best-practices.md) for detailed guidance.
    
    Key points:
    1. **Rendering tools**: Accept data as input, don't fetch internally
    2. **Instructions**: Tell agent to use capabilities FIRST, then pass data to MCP tools
    3. **Themes**: Use `FluentProvider` + Fluent `tokens` for dark/light support
    4. **Debug mode**: Include fallback data for local widget testing
    5. **Partial data**: Handle missing fields with "Unknown" defaults
    6. **Action buttons**: Hide email/chat buttons when data is "Unknown"
    7. **Version bumping**: Bump manifest version when changes aren't reflected in Copilot
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related