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
Install
npx skills add https://github.com/microsoft/skills/tree/main/.github/plugins/microsoft-365-agents-toolkit/skills/ui-widget-developer
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install microsoft-skills@llmmart
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-appsplugin (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):
- Run
/plugin marketplace add modelcontextprotocol/ext-apps - Run
/plugin install mcp-apps@mcp-apps - 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-appsmarketplace — 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.mdreferences/best-practices.mdFLUENT 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-componentsreactreact-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:
- Bump the version in
manifest.json(increment the patch version, e.g.,1.0.0→1.0.1) - 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:
- Start MCP server + devtunnel in the background (per BACKGROUND PROCESSES above)
- Run E2E verification with MCP Inspector (per MCP TOOL CONFIGURATION RULE below) — fix any failures before continuing
- Provision the agent if needed (per AGENT PROVISIONING above)
- 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:
- ☐ Create a widget shell HTML file in
widgets/and a React widget entry underwidgets/src/<widget-name>/(see widget-patterns.md) - ☐ Define a
ui://widget/<name>.htmlURI constant - ☐ Add a
Resourceentry to theresourcesarray with:uri: theui://widget/<name>.htmlURImimeType:"text/html+skybridge"_meta: CSP config withopenai/widgetDomainandopenai/widgetCSP(from environment)
- ☐ Add a handler for
resources/readthat returns the widget shell HTML for this URI - ☐ Add the tool with
_meta.openai/outputTemplatepointing to the sameui://widget/<name>.htmlURI - ☐ 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/readis 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:
- Start the MCP server (in background)
- 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 - Copy the COMPLETE tool definition from the inspector (including
name,description,inputSchema,_meta,annotations,title) - Paste into
mcpPlugin.jsonunderruntimes[].spec.mcp_tool_description.tools(inside theRemoteMCPServerruntime'sspecobject) - Run E2E verification through the devtunnel — call each tool and confirm the response contains
structuredContentand_meta.openai/widgetAccessible: true:
Also verifynpx @modelcontextprotocol/inspector@0.20.0 --cli https://<tunnel-url>/mcp --transport http --method tools/call --tool-name <tool_name>GET https://<tunnel-url>/healthreturns{"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:
- Streamable HTTP transport —
/mcpendpoint handling POST, GET, DELETE with session management - CORS headers — Origin-checking on
/mcpallowingm365.cloud.microsoftand*.m365.cloud.microsoft, with required MCP headers - Server capabilities —
initializeresponse must declareresources: {}andtools: {} - MCP resources — Register widgets with
ui://widget/<name>.htmlURIs,text/html+skybridgemime type, and CSP_meta - Tool response format — Return
content(text) +structuredContent(widget data) +_metawithopenai/outputTemplate - Widget serving — HTTP route at
/widgets/*.htmlfor 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+_metawithopenai/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, andreact-dom - Theme with
FluentProvider(webLightTheme/webDarkTheme) and Fluenttokens - Access data through shared hooks (e.g.,
useOpenAiGlobal("toolOutput")) - Debug fallback: embedded mock data when
window.openaiunavailable - Handle "Unknown" values gracefully (e.g., hide action buttons)
Plugin Schema
See references/plugin-schema.md for mcpPlugin.json format.
Core requirements:
- Schema
v2.4withRemoteMCPServerruntime run_for_functionsarray matching tool names_metain tool definitions for widget bindinginputSchema- 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):
- Creates a named tunnel on first run (or reuses the existing one)
- Starts hosting the tunnel on the configured port
- Updates
env/.env.localwithMCP_SERVER_URLandMCP_SERVER_DOMAIN(first run only) - 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
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
- TypeScript:
Start the devtunnel (creates named tunnel on first run, reuses on subsequent runs):
npm run tunnelProvision + test — see AGENT PROVISIONING rule for when this is needed; bump
versionin manifest.json if Copilot doesn't reflect changes
Best Practices
See references/best-practices.md for detailed guidance.
Key points:
- Rendering tools: Accept data as input, don't fetch internally
- Instructions: Tell agent to use capabilities FIRST, then pass data to MCP tools
- Themes: Use
FluentProvider+ Fluenttokensfor dark/light support - Debug mode: Include fallback data for local widget testing
- Partial data: Handle missing fields with "Unknown" defaults
- Action buttons: Hide email/chat buttons when data is "Unknown"
- 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.
Reviews (0)
No reviews yet.
No comments yet.