@mindstone/mcp-server-microsoft-teams 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ All notable changes to this connector will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.0] - 2026-08-07
8
+
9
+ ### Changed
10
+
11
+ - Microsoft Teams connector: canonical result envelopes, untrusted-content fencing, and hardening sync; expanded messaging/channel actions.
12
+
13
+ ### Security
14
+
15
+ - Envelope Microsoft Graph error text in `<untrusted-content>` before returning it to the model, so vendor-controlled error bodies cannot inject instructions into the conversation.
16
+ - Scope pre-checks now fail closed: when the stored token cannot be loaded or parsed, permission-gated tools return explicit reconnect guidance instead of silently skipping the check and proceeding to Graph.
17
+ - Tools that change external state (`send_chat_message`, `reply_to_message`, `create_chat`, `send_channel_message`, `reply_to_channel_message`, `set_presence`) now declare `destructiveHint: true` so hosts can gate or confirm them.
18
+ - Structural Graph fields (message/chat/team/channel/user IDs, content types, timestamps, presence states, chat types, channel membership types, member roles) are validated against safe formats; values carrying envelope-breakout or markup characters fail closed instead of reaching model-visible output. This now also covers the pre-existing `list_chats`, `get_chat`, `list_teams`, `list_channels`, and `send_chat_message` response paths, not only the newly added tools.
19
+ - Re-synced the vendored untrusted-content helper with the canonical reference implementation (all-whitespace close-tag variants, plus the unwrap helpers).
20
+
21
+ ### Fixed
22
+
23
+ - Corrected the 0.1.0 changelog entry below, which claimed "search" and "replies" tools that never actually shipped (message search and threaded replies only arrive in this release, as `search_messages` / `reply_to_message` / `reply_to_channel_message`).
24
+ - `set_presence` rejects out-of-range or fractional `durationMinutes` instead of silently clamping/rounding them into a different duration.
25
+ - `find_user` name searches and `list_channel_messages` now report `hasMore` (from Graph's `@odata.nextLink`) so truncated results are no longer indistinguishable from complete ones.
26
+
27
+ ### Added
28
+
29
+ - `get_user_presence` (a colleague's availability; requires `Presence.Read.All`) and `set_presence` (set your own status via `setUserPreferredPresence`, with an optional duration; requires `Presence.ReadWrite`).
30
+ - `reply_to_message` — threaded replies to a specific chat message.
31
+ - `search_messages` — keyword search across chat and channel messages via the Graph `/search/query` `chatMessage` entity (works with the already-granted `Chat.Read` permission).
32
+ - `find_user` (resolve colleagues by display name or email; requires `User.ReadBasic.All`) and `create_chat` (start a 1:1 or group chat by member email/user ID, returning the chat ID for use with `send_chat_message`) — together they enable "message a colleague" flows that previously required an existing chat ID.
33
+ - Channel messaging tools: `list_channel_messages`, `send_channel_message`, and `reply_to_channel_message`. Channel reads require the `ChannelMessage.Read.All` Graph permission and sends `ChannelMessage.Send`; when the connected account's token lacks the scope, the tools return actionable reconnect guidance (naming the missing permission and the likely admin-consent step) instead of a raw Graph 403.
34
+
35
+ ## [0.2.0] - 2026-07-30
36
+
37
+ ### Changed
38
+
39
+ - Add compose_chat_message interactive compose view (MCP App); send_chat_message input schema is now strict (unknown keys rejected)
40
+
7
41
  ## [0.1.2] - 2026-07-03
8
42
 
9
43
  ### Changed
@@ -21,4 +55,4 @@ All notable changes to this connector will be documented in this file.
21
55
 
22
56
  ### Added
23
57
 
24
- - Initial Microsoft 365 Teams MCP server with seven tools for chats, messages, search, channels, sends, and replies.
58
+ - Initial Microsoft 365 Teams MCP server with seven tools covering chats, messages, teams, channels, sends, and presence. (Corrected after release: the original entry also claimed search and reply tools, which did not exist until later.)
package/README.md CHANGED
@@ -3,15 +3,15 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@mindstone/mcp-server-microsoft-teams.svg)](https://www.npmjs.com/package/@mindstone/mcp-server-microsoft-teams)
4
4
  [![License: FSL-1.1-MIT](https://img.shields.io/badge/License-FSL--1.1--MIT-blue.svg)](./LICENSE)
5
5
 
6
- Microsoft 365 Teams MCP server — list and read Teams chats, send chat messages, list teams and channels, and read presence via the Microsoft Graph API.
6
+ Microsoft 365 Teams MCP server — list and read Teams chats and channel messages, send messages and replies, start new chats, look up colleagues, search messages, and read or set presence via the Microsoft Graph API.
7
7
 
8
8
  *Cohort-style Microsoft 365 Teams MCP. Reuses the OAuth surface owned by [`@mindstone/mcp-server-microsoft-mail`](../microsoft-mail/) so the host signs in once and gets Teams plus mail plus calendar plus files plus SharePoint from the same credentials.*
9
9
 
10
10
  ## Status
11
11
 
12
- - **Version:** [0.1.2](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-microsoft-teams)
12
+ - **Version:** [0.3.0](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-microsoft-teams)
13
13
  - **Auth:** OAuth (host-orchestrated, shared with [`mcp-server-microsoft-mail`](../microsoft-mail/)) ([`MS_CLIENT_ID`](./server.json))
14
- - **Tools:** [7](./src/tools.ts) (chats, messages, teams, channels, presence)
14
+ - **Tools:** [17](./src/tools.ts) (chats, messages, teams, channels, users, search, presence)
15
15
  - **Surface:** cloud-api
16
16
  - **Machine-readable:** [`STATUS.json`](./STATUS.json)
17
17
  - **Shared library:** [`@mindstone/mcp-server-microsoft-shared`](https://www.npmjs.com/package/@mindstone/mcp-server-microsoft-shared)
@@ -164,7 +164,7 @@ Sign in via [`@mindstone/mcp-server-microsoft-mail`](../microsoft-mail/)'s `auth
164
164
  }
165
165
  ```
166
166
 
167
- ## Tools (7)
167
+ ## Tools (17)
168
168
 
169
169
  | Tool | Description |
170
170
  | ---- | ----------- |
@@ -172,11 +172,21 @@ Sign in via [`@mindstone/mcp-server-microsoft-mail`](../microsoft-mail/)'s `auth
172
172
  | `get_chat` | Get details about a specific chat. |
173
173
  | `list_chat_messages` | List recent messages from a chat. |
174
174
  | `send_chat_message` | Send a message to a chat. |
175
+ | `reply_to_message` | Reply to a specific chat message, creating a threaded reply. |
176
+ | `compose_chat_message` | Open an inline editable compose form before sending; the form posts via `send_chat_message` when the user clicks Send. |
177
+ | `find_user` | Look up people by display name or email. Requires `User.ReadBasic.All`. |
178
+ | `create_chat` | Create a 1:1 or group chat by member email/user ID and return the new chat ID. |
179
+ | `search_messages` | Search chat and channel messages by keyword. |
175
180
  | `list_teams` | List teams you are a member of. |
176
181
  | `list_channels` | List channels in a team. |
182
+ | `list_channel_messages` | List recent messages in a channel. Requires `ChannelMessage.Read.All`. |
183
+ | `send_channel_message` | Post a new message to a channel. Requires `ChannelMessage.Send`. |
184
+ | `reply_to_channel_message` | Reply to an existing channel message. Requires `ChannelMessage.Send`. |
177
185
  | `get_presence` | Get your current presence status. |
186
+ | `get_user_presence` | Get a colleague's presence status. Requires `Presence.Read.All`. |
187
+ | `set_presence` | Set your own presence status, optionally for a limited duration. Requires `Presence.ReadWrite`. |
178
188
 
179
- Some Teams Graph APIs may require tenant admin approval.
189
+ Some Teams Graph APIs may require tenant admin approval. Tools marked with a required permission check the connected account's token up front and return actionable reconnect guidance when the permission is missing (under Microsoft's managed consent policy, `ChannelMessage.Read.All` is admin-consent-gated in most tenants).
180
190
 
181
191
  ## Security notes
182
192
 
@@ -0,0 +1,2 @@
1
+ export declare const COMPOSE_MESSAGE_HTML = "\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Compose message</title>\n <style>\n :root {\n --bg: #ffffff;\n --panel: #ffffff;\n --border: #d9dde5;\n --text: #1f2937;\n --muted: #6b7280;\n --input-bg: #ffffff;\n --button-bg: #1f6feb;\n --button-text: #ffffff;\n --button-bg-hover: #1b63d3;\n --secondary-bg: #f3f4f6;\n --secondary-text: #1f2937;\n --secondary-border: #d1d5db;\n --error-bg: #fef2f2;\n --error-border: #fecaca;\n --error-text: #991b1b;\n --success-bg: #ecfdf5;\n --success-border: #bbf7d0;\n --success-text: #166534;\n --warning-bg: #fffbeb;\n --warning-border: #fde68a;\n --warning-text: #92400e;\n }\n\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0f172a;\n --panel: #111827;\n --border: #334155;\n --text: #e5e7eb;\n --muted: #9ca3af;\n --input-bg: #0b1220;\n --button-bg: #3b82f6;\n --button-text: #eff6ff;\n --button-bg-hover: #2563eb;\n --secondary-bg: #1f2937;\n --secondary-text: #e5e7eb;\n --secondary-border: #374151;\n --error-bg: #2b1011;\n --error-border: #7f1d1d;\n --error-text: #fca5a5;\n --success-bg: #052e1b;\n --success-border: #166534;\n --success-text: #86efac;\n --warning-bg: #2a2106;\n --warning-border: #854d0e;\n --warning-text: #fcd34d;\n }\n }\n\n html[data-theme=\"light\"] {\n color-scheme: light;\n }\n\n html[data-theme=\"dark\"] {\n color-scheme: dark;\n --bg: #0f172a;\n --panel: #111827;\n --border: #334155;\n --text: #e5e7eb;\n --muted: #9ca3af;\n --input-bg: #0b1220;\n --button-bg: #3b82f6;\n --button-text: #eff6ff;\n --button-bg-hover: #2563eb;\n --secondary-bg: #1f2937;\n --secondary-text: #e5e7eb;\n --secondary-border: #374151;\n --error-bg: #2b1011;\n --error-border: #7f1d1d;\n --error-text: #fca5a5;\n --success-bg: #052e1b;\n --success-border: #166534;\n --success-text: #86efac;\n --warning-bg: #2a2106;\n --warning-border: #854d0e;\n --warning-text: #fcd34d;\n }\n\n * {\n box-sizing: border-box;\n }\n\n body {\n margin: 0;\n padding: 16px;\n background: var(--bg);\n color: var(--text);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n line-height: 1.4;\n }\n\n .wrapper {\n width: 100%;\n max-width: 720px;\n margin: 0 auto;\n }\n\n .card {\n background: var(--panel);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 14px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n }\n\n .field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .field label {\n font-size: 12px;\n font-weight: 600;\n color: var(--muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n .from-value {\n color: var(--text);\n font-size: 14px;\n padding: 2px 0;\n word-break: break-word;\n }\n\n .from-helper {\n font-size: 12px;\n color: var(--muted);\n margin-top: 2px;\n }\n\n .input,\n .textarea {\n width: 100%;\n background: var(--input-bg);\n color: var(--text);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 10px 12px;\n font-size: 14px;\n font-family: inherit;\n }\n\n .input:focus,\n .textarea:focus {\n outline: 2px solid rgba(59, 130, 246, 0.35);\n outline-offset: 1px;\n }\n\n .textarea {\n min-height: 180px;\n resize: vertical;\n white-space: pre-wrap;\n }\n\n .toggles {\n display: flex;\n gap: 12px;\n align-items: center;\n flex-wrap: wrap;\n }\n\n .link-button {\n border: none;\n padding: 0;\n background: none;\n color: var(--muted);\n text-decoration: underline;\n font-size: 12px;\n cursor: pointer;\n }\n\n .link-button:hover {\n color: var(--text);\n }\n\n .actions {\n display: flex;\n justify-content: flex-end;\n gap: 10px;\n margin-top: 2px;\n }\n\n .button {\n border-radius: 8px;\n border: 1px solid transparent;\n min-height: 36px;\n padding: 0 14px;\n font-size: 13px;\n font-weight: 600;\n font-family: inherit;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n transition: background 120ms ease, opacity 120ms ease, border-color 120ms ease;\n }\n\n .button:disabled {\n cursor: not-allowed;\n opacity: 0.75;\n }\n\n .button-primary {\n background: var(--button-bg);\n color: var(--button-text);\n }\n\n .button-primary:hover:enabled {\n background: var(--button-bg-hover);\n }\n\n .button-secondary {\n background: var(--secondary-bg);\n color: var(--secondary-text);\n border-color: var(--secondary-border);\n }\n\n .spinner {\n width: 14px;\n height: 14px;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n display: inline-block;\n animation: spin 700ms linear infinite;\n }\n\n @keyframes spin {\n to { transform: rotate(360deg); }\n }\n\n .status {\n border-radius: 8px;\n border: 1px solid transparent;\n padding: 8px 10px;\n font-size: 13px;\n }\n\n .error {\n background: var(--error-bg);\n border-color: var(--error-border);\n color: var(--error-text);\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n }\n\n .success {\n background: var(--success-bg);\n border-color: var(--success-border);\n color: var(--success-text);\n }\n\n .warning {\n background: var(--warning-bg);\n border-color: var(--warning-border);\n color: var(--warning-text);\n display: block;\n }\n\n .warning strong {\n display: block;\n margin-bottom: 2px;\n }\n\n .warning .warning-detail {\n opacity: 0.85;\n }\n\n .collapsed {\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--panel);\n padding: 10px 12px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n font-size: 13px;\n }\n\n .hidden {\n display: none !important;\n }\n\n .sent-header {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n }\n\n .sent-badge {\n display: inline-flex;\n align-items: center;\n background: var(--success-bg);\n color: var(--success-text);\n border: 1px solid var(--success-border);\n border-radius: 999px;\n padding: 2px 10px;\n font-size: 12px;\n font-weight: 600;\n }\n\n .sent-timestamp {\n color: var(--muted);\n font-size: 12px;\n }\n\n .sent-value {\n color: var(--text);\n font-size: 14px;\n word-break: break-word;\n }\n\n .sent-body {\n color: var(--text);\n font-size: 14px;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 260px;\n overflow: auto;\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 10px 12px;\n background: var(--input-bg);\n }\n </style>\n</head>\n<body>\n <div class=\"wrapper\">\n <div id=\"collapsedState\" class=\"collapsed hidden\">\n <span id=\"collapsedMessage\">Draft collapsed.</span>\n <button id=\"reopenButton\" type=\"button\" class=\"button button-secondary\">Reopen</button>\n </div>\n\n <form id=\"composeForm\" class=\"card\" novalidate aria-label=\"Compose message\">\n <div class=\"field\">\n <span id=\"workspaceLine\" class=\"from-helper hidden\"></span>\n </div>\n\n <div class=\"field\">\n <label for=\"toInput\">To</label>\n <input id=\"toInput\" class=\"input\" type=\"text\" autocomplete=\"off\" placeholder=\"Chat ID\">\n </div>\n\n <div class=\"field\">\n <label for=\"bodyInput\">Message</label>\n <textarea id=\"bodyInput\" class=\"textarea\" placeholder=\"Write your message\"></textarea>\n </div>\n\n <div id=\"errorBox\" class=\"status error hidden\" role=\"alert\">\n <span id=\"errorText\"></span>\n <button id=\"retryButton\" type=\"button\" class=\"link-button\">Retry</button>\n </div>\n\n <div id=\"successBox\" class=\"status success hidden\" role=\"status\" aria-live=\"polite\"></div>\n\n <div id=\"unknownBox\" class=\"status warning hidden\" role=\"status\" aria-live=\"polite\">\n <strong id=\"unknownTitle\"></strong>\n <span id=\"unknownText\" class=\"warning-detail\"></span>\n </div>\n\n <div class=\"actions\">\n <button id=\"cancelButton\" type=\"button\" class=\"button button-secondary\">Cancel</button>\n <button id=\"sendButton\" type=\"button\" class=\"button button-primary\">\n <span id=\"sendSpinner\" class=\"spinner hidden\" aria-hidden=\"true\"></span>\n <span id=\"sendLabel\">Send message</span>\n </button>\n </div>\n </form>\n\n <div id=\"sentView\" class=\"card hidden\" role=\"status\" aria-live=\"polite\">\n <div class=\"sent-header\">\n <span class=\"sent-badge\">Sent</span>\n <span id=\"sentTimestamp\" class=\"sent-timestamp\"></span>\n </div>\n\n <div class=\"field\">\n <label>To</label>\n <span id=\"sentTo\" class=\"sent-value\"></span>\n </div>\n\n <div class=\"field\">\n <label>Message</label>\n <div id=\"sentBody\" class=\"sent-body\"></div>\n </div>\n\n <div class=\"actions\">\n <button id=\"sentCollapseButton\" type=\"button\" class=\"button button-secondary\">Collapse</button>\n </div>\n </div>\n </div>\n\n <script>\n (function () {\n 'use strict';\n\n var RESOURCE_URI = 'ui://microsoft-teams/compose-message';\n var currentEmail = '';\n // The host prefills the draft exactly once via ui/notifications/tool-result\n // on ready. We apply only the FIRST prefill and ignore any later ones, so a\n // stray or hostile re-post can't silently rewrite a draft the user has\n // already reviewed before clicking Send.\n var draftApplied = false;\n var pendingRequestId = null;\n var pendingPayload = null;\n var sending = false;\n var collapsed = false;\n var outcomeUnknown = false;\n var sent = false;\n var sendTimeoutId = null;\n // Just beyond the host-side tool-call timeout (60s) so a legitimately slow\n // send resolves to its real result first; this only fires when the reply\n // is genuinely lost, so the button can never stay stuck-silent.\n var SEND_TIMEOUT_MS = 75000;\n\n var composeForm = document.getElementById('composeForm');\n var collapsedState = document.getElementById('collapsedState');\n var collapsedMessage = document.getElementById('collapsedMessage');\n var reopenButton = document.getElementById('reopenButton');\n\n var sentView = document.getElementById('sentView');\n var sentTimestamp = document.getElementById('sentTimestamp');\n var sentFrom = document.getElementById('sentFrom');\n var sentTo = document.getElementById('sentTo');\n var sentSubject = document.getElementById('sentSubject');\n var sentBody = document.getElementById('sentBody');\n var sentCollapseButton = document.getElementById('sentCollapseButton');\n\n var toInput = document.getElementById('toInput');\n var subjectInput = document.getElementById('subjectInput');\n var bodyInput = document.getElementById('bodyInput');\n var fromValue = document.getElementById('fromValue');\n var fromHelper = document.getElementById('fromHelper');\n\n var sendButton = document.getElementById('sendButton');\n var sendLabel = document.getElementById('sendLabel');\n var sendSpinner = document.getElementById('sendSpinner');\n var cancelButton = document.getElementById('cancelButton');\n var retryButton = document.getElementById('retryButton');\n\n var errorBox = document.getElementById('errorBox');\n var errorText = document.getElementById('errorText');\n var successBox = document.getElementById('successBox');\n var unknownBox = document.getElementById('unknownBox');\n var unknownTitle = document.getElementById('unknownTitle');\n var unknownText = document.getElementById('unknownText');\n\n function applyThemeFromHostContext() {\n try {\n var context = window.__MCP_HOST_CONTEXT__;\n if (!context || (context.theme !== 'light' && context.theme !== 'dark')) {\n return;\n }\n document.documentElement.setAttribute('data-theme', context.theme);\n } catch (_) {\n // Ignore theme extraction errors\n }\n }\n\n function trimString(value) {\n return String(value || '').trim();\n }\n\n function applyFromValue(email) {\n var trimmed = trimString(email);\n if (trimmed) {\n fromValue.textContent = trimmed;\n fromValue.style.color = '';\n fromHelper.textContent = '';\n fromHelper.classList.add('hidden');\n } else {\n fromValue.textContent = 'Account not shown';\n fromValue.style.color = 'var(--muted)';\n fromHelper.textContent = '';\n fromHelper.classList.remove('hidden');\n }\n }\n\n function formatAddressItem(item) {\n if (item && typeof item === 'object') {\n var email = trimString(item.email || item.address || item.value);\n var name = trimString(item.name || item.displayName);\n if (email && name) {\n return name + ' <' + email + '>';\n }\n return email;\n }\n return trimString(item);\n }\n\n function normalizeAddressList(value) {\n if (Array.isArray(value)) {\n return value.map(formatAddressItem).filter(Boolean);\n }\n if (typeof value === 'string') {\n return value\n .split(',')\n .map(function (item) { return trimString(item); })\n .filter(Boolean);\n }\n return [];\n }\n\n function listToInputValue(list) {\n return normalizeAddressList(list).join(', ');\n }\n\n var resizeQueued = false;\n function postResize() {\n if (resizeQueued) return;\n resizeQueued = true;\n requestAnimationFrame(function () {\n resizeQueued = false;\n var height = Math.max(\n document.documentElement.scrollHeight,\n document.body ? document.body.scrollHeight : 0\n );\n window.parent.postMessage(\n { jsonrpc: '2.0', method: 'ui/resize', params: { height: height } },\n '*'\n );\n });\n }\n\n function postReady() {\n window.parent.postMessage(\n {\n method: 'mcp-app:ready',\n params: { resourceUri: RESOURCE_URI }\n },\n '*'\n );\n }\n\n // Exactly one of the three panels is visible at a time: the editable form,\n // the read-only sent view, or the collapsed summary bar.\n function showView(view) {\n composeForm.classList.toggle('hidden', view !== 'form');\n sentView.classList.toggle('hidden', view !== 'sent');\n collapsedState.classList.toggle('hidden', view !== 'collapsed');\n postResize();\n }\n\n function setCollapsed(nextCollapsed, message) {\n collapsed = nextCollapsed;\n if (nextCollapsed) {\n collapsedMessage.textContent = message || 'Draft collapsed.';\n showView('collapsed');\n } else {\n // Expanding after a send restores the read-only sent view (never the\n // editable form) so the user can re-read what went out without any risk\n // of a duplicate send; before sending, it restores the editable form.\n showView(sent ? 'sent' : 'form');\n }\n }\n\n function setSending(nextSending) {\n sending = nextSending;\n sendButton.disabled = nextSending;\n cancelButton.disabled = nextSending;\n toInput.disabled = nextSending;\n bodyInput.disabled = nextSending;\n sendSpinner.classList.toggle('hidden', !nextSending);\n sendLabel.textContent = nextSending ? 'Sending\u2026' : 'Send message';\n sendButton.setAttribute('aria-busy', nextSending ? 'true' : 'false');\n postResize();\n }\n\n function clearError() {\n errorText.textContent = '';\n errorBox.classList.add('hidden');\n }\n\n function showError(message) {\n errorText.textContent = message || 'Failed to send message.';\n errorBox.classList.remove('hidden');\n postResize();\n }\n\n function clearSuccess() {\n successBox.textContent = '';\n successBox.classList.add('hidden');\n }\n\n function showSuccess(message) {\n successBox.textContent = message;\n successBox.classList.remove('hidden');\n postResize();\n }\n\n function clearSendTimeout() {\n if (sendTimeoutId !== null) {\n clearTimeout(sendTimeoutId);\n sendTimeoutId = null;\n }\n }\n\n function clearUnknown() {\n outcomeUnknown = false;\n unknownTitle.textContent = '';\n unknownText.textContent = '';\n unknownBox.classList.add('hidden');\n }\n\n // No reply ever came back for the send (e.g. the host's response was lost\n // before reaching this iframe). We genuinely don't know whether the email\n // went out, so we must NOT claim success or failure: re-enable the form and\n // tell the user to verify before resending (the send is not idempotent, so\n // a blind retry could send a duplicate).\n function showSendUnknown() {\n outcomeUnknown = true;\n clearError();\n clearSuccess();\n unknownTitle.textContent = 'Not sure if this sent.';\n unknownText.textContent = \"Rebel didn't hear back in time. Check whether it sent before sending again, so it doesn't go out twice.\";\n unknownBox.classList.remove('hidden');\n postResize();\n }\n\n function handleSendTimeout() {\n sendTimeoutId = null;\n if (!pendingRequestId) {\n return;\n }\n pendingRequestId = null;\n setSending(false);\n showSendUnknown();\n }\n\n function readFormPayload() {\n var chatId = String(toInput.value || '').trim();\n var payload = {\n chatId: chatId,\n content: String(bodyInput.value || '')\n };\n return payload;\n }\n\n function validatePayload(payload) {\n if (!trimString(payload.chatId)) {\n return 'Add a chat in To.';\n }\n if (!trimString(payload.content)) {\n return 'Add a message before sending.';\n }\n return null;\n }\n\n function sendPayload(payload) {\n pendingPayload = payload;\n pendingRequestId = 'send-' + Date.now() + '-' + Math.random().toString(16).slice(2);\n setSending(true);\n clearError();\n clearSuccess();\n clearUnknown();\n clearSendTimeout();\n sendTimeoutId = setTimeout(handleSendTimeout, SEND_TIMEOUT_MS);\n\n window.parent.postMessage(\n {\n jsonrpc: '2.0',\n method: 'tools/call',\n params: {\n name: 'send_chat_message',\n arguments: payload\n },\n id: pendingRequestId\n },\n '*'\n );\n }\n\n function applyDraftData(rawDraft) {\n var draft = rawDraft && typeof rawDraft === 'object' ? rawDraft : {};\n toInput.value = String(draft.target || '');\n bodyInput.value = String(draft.text || '');\n clearError();\n clearSuccess();\n postResize();\n }\n\n function getDraftFromToolResult(payload) {\n if (!payload || typeof payload !== 'object') {\n return null;\n }\n // CANONICAL: post-A0 super-mcp hoists structuredContent onto the\n // outer block; the host posts it through `params.structuredContent`.\n if (payload.structuredContent && typeof payload.structuredContent === 'object') {\n return payload.structuredContent;\n }\n // MIGRATION SHIM: pre-A0 stored sessions replayed through the\n // current iframe lack `params.structuredContent` because the outer\n // block had no hoist at capture time. Read inner envelope text \u2192\n // JSON.parse \u2192 result.structuredContent. NOT regex-based.\n //\n // Lifetime: PERMANENT defensive read-side helper. Pre-A0 sessions\n // are persisted on disk and may be replayed indefinitely; there is\n // no time-based sunset. Removing this fallback would silently break\n // pre-A0 conversation replay.\n var text = typeof payload.text === 'string' ? payload.text :\n (Array.isArray(payload.content)\n ? (payload.content.find(function (c) { return c && c.type === 'text' && typeof c.text === 'string'; }) || {}).text\n : null);\n if (typeof text !== 'string') return null;\n try {\n var parsed = JSON.parse(text);\n if (parsed && typeof parsed === 'object' && parsed.result && typeof parsed.result === 'object') {\n var inner = parsed.result;\n if (inner.structuredContent && typeof inner.structuredContent === 'object') {\n return inner.structuredContent;\n }\n }\n } catch (_) {\n // Likely-envelope heuristic: text starts with `{` and contains\n // `\"package_id\"` in the first 64 chars. If JSON.parse failed on a\n // likely-envelope, surface a warning so the developer chasing\n // \"compose form is empty on replay\" sees a signal. For non-envelope\n // text (the dominant case), no log fires.\n var prefix = text.length > 64 ? text.slice(0, 64) : text;\n var likelyEnvelope = prefix.charAt(0) === '{' && prefix.indexOf('\"package_id\"') !== -1;\n if (likelyEnvelope && typeof console !== 'undefined' && typeof console.warn === 'function') {\n console.warn('[compose-email] Migration shim: JSON.parse failed on likely super-mcp envelope; pre-fill skipped');\n }\n }\n return null;\n }\n\n function findTextBlock(blocks) {\n if (!Array.isArray(blocks)) return null;\n var block = blocks.find(function (c) {\n return c && c.type === 'text' && typeof c.text === 'string';\n });\n return block ? block.text : null;\n }\n\n // Extract the send result identifiers ({ messageId, threadId, labelIds })\n // from the tool-call reply. The connector attaches structuredContent, which\n // super-mcp hoists onto the outer block, so the fast path is a single read.\n // The envelope-unwrapping fallback keeps this robust for hosts (or replayed\n // sessions) that forward only the serialized super-mcp text envelope.\n function getSendMetaFromResult(result) {\n if (!result || typeof result !== 'object') return null;\n if (result.structuredContent && typeof result.structuredContent === 'object') {\n return result.structuredContent;\n }\n try {\n var outerText = findTextBlock(result.content);\n if (typeof outerText !== 'string' && typeof result.text === 'string') {\n outerText = result.text;\n }\n if (typeof outerText !== 'string') return null;\n var envelope = JSON.parse(outerText);\n if (!envelope || typeof envelope !== 'object') return null;\n // outerText was the connector payload directly (no super-mcp envelope).\n if (envelope.messageId || envelope.threadId) return envelope;\n var inner = envelope.result;\n if (!inner || typeof inner !== 'object') return null;\n if (inner.structuredContent && typeof inner.structuredContent === 'object') {\n return inner.structuredContent;\n }\n var innerText = findTextBlock(inner.content);\n if (typeof innerText !== 'string') return null;\n var payload = JSON.parse(innerText);\n return (payload && typeof payload === 'object') ? payload : null;\n } catch (_) {\n return null;\n }\n }\n\n function formatSentTime() {\n try {\n return new Date().toLocaleString(undefined, {\n month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'\n });\n } catch (_) {\n return '';\n }\n }\n\n function renderSentView(payload, meta, stamp) {\n payload = payload && typeof payload === 'object' ? payload : {};\n sentTimestamp.textContent = stamp ? 'Sent ' + stamp : 'Sent';\n sentTo.textContent = String(payload.channel || payload.chatId || '');\n sentBody.textContent = String(payload.text || payload.content || '');\n }\n\n function handleSendRequest() {\n if (sending) return;\n\n var payload = readFormPayload();\n var validationError = validatePayload(payload);\n if (validationError) {\n showError(validationError);\n return;\n }\n\n sendPayload(payload);\n }\n\n // The Send button is type=\"button\" with an explicit click handler rather\n // than a form submit button. The Rebel host renders this iframe with a\n // sandbox of \"allow-scripts\" only \u2014 no \"allow-forms\" \u2014 so native form\n // submission is blocked by the browser BEFORE the submit event fires\n // (\"Blocked form submission ... the form's frame is sandboxed and the\n // allow-forms permission is not set\"). A submit-driven Send therefore\n // silently did nothing: no postMessage, no spinner, no telemetry.\n // A click handler needs no sandbox permission and is the same path\n // Cancel/Retry already use. The submit listener below stays only as a\n // defensive fallback for hosts that DO grant allow-forms; in this sandbox\n // it never fires (neither the button click nor Enter routes through it).\n // The sending guard keeps any double-fire (in an allow-forms host) a no-op.\n sendButton.addEventListener('click', function () {\n handleSendRequest();\n });\n\n composeForm.addEventListener('submit', function (event) {\n event.preventDefault();\n handleSendRequest();\n });\n\n retryButton.addEventListener('click', function () {\n if (sending) return;\n var payload = readFormPayload();\n var validationError = validatePayload(payload);\n if (validationError) {\n showError(validationError);\n return;\n }\n sendPayload(payload);\n });\n\n cancelButton.addEventListener('click', function () {\n if (sending) return;\n // After a lost-reply timeout we don't know if the email sent, so don't\n // claim \"Nothing sent.\" on collapse \u2014 point the user to verify instead.\n var collapseMessage = outcomeUnknown\n ? 'Collapsed. Check whether it sent if you are not sure.'\n : 'Draft collapsed. Nothing sent.';\n clearError();\n clearSuccess();\n clearUnknown();\n setCollapsed(true, collapseMessage);\n });\n\n reopenButton.addEventListener('click', function () {\n setCollapsed(false);\n });\n\n sentCollapseButton.addEventListener('click', function () {\n setCollapsed(true, collapsedMessage.textContent || 'Message sent.');\n });\n\n [toInput, bodyInput].forEach(function (element) {\n element.addEventListener('input', function () {\n clearError();\n clearSuccess();\n clearUnknown();\n postResize();\n });\n });\n\n window.addEventListener('message', function (event) {\n // Honest scope (origin-independent): the iframe sandbox origin is 'null',\n // so validating event.origin is unreliable across dev/prod/custom-scheme\n // hosts. Instead reject any *identified* sender that isn't our host frame\n // (window.parent). A real cross-frame postMessage always stamps\n // event.source with the sending window, so a hostile sibling frame is\n // rejected here; a null source only occurs for same-context synthetic\n // dispatch (our own code) and cannot be forged from outside, so we\n // tolerate it. This does not authenticate the host's identity \u2014 the host\n // separately authenticates this iframe by origin+source.\n if (event.source && event.source !== window.parent) {\n return;\n }\n\n var data = event.data;\n if (!data || typeof data !== 'object') {\n return;\n }\n\n if (data.method === 'ui/notifications/tool-result') {\n // Always parse (so the migration-shim's malformed-envelope warning\n // still fires), but apply only the first successful prefill: a stray\n // or hostile re-post can't silently rewrite a draft the user has\n // already reviewed.\n var draft = getDraftFromToolResult(data.params);\n if (draft && !draftApplied) {\n draftApplied = true;\n applyDraftData(draft);\n }\n return;\n }\n\n if (data.jsonrpc !== '2.0' || !pendingRequestId || data.id !== pendingRequestId) {\n return;\n }\n\n pendingRequestId = null;\n clearSendTimeout();\n clearUnknown();\n setSending(false);\n\n if (data.error) {\n var errorMessage = data.error && typeof data.error.message === 'string'\n ? data.error.message\n : 'Failed to send message.';\n showError(errorMessage);\n return;\n }\n\n clearError();\n clearSuccess();\n // Preserve what was sent instead of discarding the draft. pendingPayload\n // is the exact payload we posted. We default to the collapsed summary, but\n // Reopen now restores a read-only view of the sent message.\n var sentPayload = pendingPayload || readFormPayload();\n var stamp = formatSentTime();\n renderSentView(sentPayload, getSendMetaFromResult(data.result), stamp);\n sent = true;\n setCollapsed(true, stamp ? 'Message sent \u00B7 ' + stamp : 'Message sent.');\n });\n\n if (typeof ResizeObserver === 'function' && document.body) {\n var observer = new ResizeObserver(postResize);\n observer.observe(document.body);\n }\n\n applyThemeFromHostContext();\n setCollapsed(false);\n postResize();\n postReady();\n\n window.parent.postMessage(\n {\n jsonrpc: '2.0',\n method: 'ui/initialize',\n params: { resourceUri: RESOURCE_URI },\n id: 'compose-email-init'\n },\n '*'\n );\n })();\n </script>\n</body>\n</html>\n";
2
+ //# sourceMappingURL=compose-message-template.d.ts.map