@mindstone/mcp-server-microsoft-teams 0.2.0 → 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,34 @@ 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
+
7
35
  ## [0.2.0] - 2026-07-30
8
36
 
9
37
  ### Changed
@@ -27,4 +55,4 @@ All notable changes to this connector will be documented in this file.
27
55
 
28
56
  ### Added
29
57
 
30
- - 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.2.0](./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
 
package/dist/teams.d.ts CHANGED
@@ -11,5 +11,15 @@ export declare function sendChatMessage(client: Client, args: ArgBag, signal: Ab
11
11
  export declare function listTeams(client: Client, _args: ArgBag, signal: AbortSignal): Promise<unknown>;
12
12
  export declare function listChannels(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
13
13
  export declare function getPresence(client: Client, _args: ArgBag, signal: AbortSignal): Promise<unknown>;
14
+ export declare function getUserPresence(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
15
+ export declare const PRESENCE_AVAILABILITY_VALUES: readonly ["Available", "Busy", "DoNotDisturb", "BeRightBack", "Away", "Offline"];
16
+ export declare function setPresence(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
17
+ export declare function listChannelMessages(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
18
+ export declare function sendChannelMessage(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
19
+ export declare function replyToChannelMessage(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
20
+ export declare function replyToChatMessage(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
21
+ export declare function searchMessages(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
22
+ export declare function findUser(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
23
+ export declare function createChat(client: Client, args: ArgBag, signal: AbortSignal): Promise<unknown>;
14
24
  export {};
15
25
  //# sourceMappingURL=teams.d.ts.map
package/dist/teams.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  import { wrapUntrusted } from './untrusted-content.js';
2
3
  export class TeamsBusinessError extends Error {
3
4
  nextStep;
@@ -28,6 +29,23 @@ function clampTop(value, defaultValue, maxValue) {
28
29
  return defaultValue;
29
30
  return Math.max(1, Math.min(value, maxValue));
30
31
  }
32
+ // Structural Graph values (IDs, enum-like tokens, timestamps) are vendor-assigned
33
+ // rather than user-authored free text, so instead of an untrusted-content
34
+ // envelope — which would corrupt an ID the model must pass back verbatim in
35
+ // follow-up calls — they are validated against shapes that cannot carry
36
+ // envelope-breakout or markup characters. An unexpected value fails closed: the
37
+ // Zod error surfaces through the standard (enveloped) error path.
38
+ const graphStructuralTokenSchema = z
39
+ .string()
40
+ .max(512)
41
+ .regex(/^[^\s<>"'`\\]*$/, 'unexpected characters in Microsoft Graph identifier');
42
+ const graphIsoDateTimeSchema = z
43
+ .string()
44
+ .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/, 'unexpected Microsoft Graph datetime format');
45
+ const graphPresenceStateSchema = z
46
+ .string()
47
+ .max(64)
48
+ .regex(/^[A-Za-z][A-Za-z0-9]*$/, 'unexpected Microsoft Graph presence value');
31
49
  const HTML_ENTITIES = {
32
50
  '&nbsp;': ' ',
33
51
  '&amp;': '&',
@@ -58,15 +76,15 @@ function requireStringArg(args, name, label, nextStep) {
58
76
  return value;
59
77
  throw new TeamsBusinessError(`Missing required parameter: "${name}" (${label}).`, nextStep);
60
78
  }
61
- function formatMessage(msg) {
79
+ function formatMessage(msg, tool) {
62
80
  return {
63
- id: msg.id,
81
+ id: graphStructuralTokenSchema.nullish().parse(msg.id),
64
82
  from: msg.from?.user?.displayName
65
- ? wrapUntrusted(msg.from.user.displayName, 'microsoft-teams:list_chat_messages:from')
83
+ ? wrapUntrusted(msg.from.user.displayName, `microsoft-teams:${tool}:from`)
66
84
  : 'Unknown',
67
- content: wrapUntrusted(stripHtml(msg.body?.content ?? ''), 'microsoft-teams:list_chat_messages:content'),
68
- contentType: msg.body?.contentType,
69
- createdAt: msg.createdDateTime,
85
+ content: wrapUntrusted(stripHtml(msg.body?.content ?? ''), `microsoft-teams:${tool}:content`),
86
+ contentType: graphStructuralTokenSchema.nullish().parse(msg.body?.contentType),
87
+ createdAt: graphIsoDateTimeSchema.nullish().parse(msg.createdDateTime),
70
88
  };
71
89
  }
72
90
  export async function listChats(client, args, signal) {
@@ -85,13 +103,13 @@ export async function listChats(client, args, signal) {
85
103
  return {
86
104
  count: chats.length,
87
105
  chats: chats.map((chat) => ({
88
- id: chat.id,
106
+ id: graphStructuralTokenSchema.parse(chat.id),
89
107
  topic: chat.topic
90
108
  ? wrapUntrusted(chat.topic, 'microsoft-teams:list_chats:topic')
91
109
  : '(No topic)',
92
- type: chat.chatType,
93
- createdAt: chat.createdDateTime,
94
- lastUpdated: chat.lastUpdatedDateTime,
110
+ type: graphStructuralTokenSchema.nullish().parse(chat.chatType),
111
+ createdAt: graphIsoDateTimeSchema.nullish().parse(chat.createdDateTime),
112
+ lastUpdated: graphIsoDateTimeSchema.nullish().parse(chat.lastUpdatedDateTime),
95
113
  })),
96
114
  };
97
115
  }
@@ -103,17 +121,17 @@ export async function getChat(client, args, signal) {
103
121
  .expand('members')
104
122
  .get());
105
123
  return {
106
- id: chat.id,
124
+ id: graphStructuralTokenSchema.parse(chat.id),
107
125
  topic: chat.topic
108
126
  ? wrapUntrusted(chat.topic, 'microsoft-teams:get_chat:topic')
109
127
  : '(No topic)',
110
- type: chat.chatType,
111
- createdAt: chat.createdDateTime,
112
- lastUpdated: chat.lastUpdatedDateTime,
128
+ type: graphStructuralTokenSchema.nullish().parse(chat.chatType),
129
+ createdAt: graphIsoDateTimeSchema.nullish().parse(chat.createdDateTime),
130
+ lastUpdated: graphIsoDateTimeSchema.nullish().parse(chat.lastUpdatedDateTime),
113
131
  members: chat.members?.map((member) => ({
114
132
  displayName: wrapUntrusted(member.displayName, 'microsoft-teams:get_chat:members.displayName'),
115
133
  email: wrapUntrusted(member.email, 'microsoft-teams:get_chat:members.email'),
116
- roles: member.roles,
134
+ roles: z.array(graphStructuralTokenSchema).nullish().parse(member.roles),
117
135
  })),
118
136
  };
119
137
  }
@@ -129,13 +147,13 @@ export async function listChatMessages(client, args, signal) {
129
147
  return {
130
148
  chatId,
131
149
  count: messages.length,
132
- messages: messages.map(formatMessage),
150
+ messages: messages.map((msg) => formatMessage(msg, 'list_chat_messages')),
133
151
  };
134
152
  }
135
153
  export async function sendChatMessage(client, args, signal) {
136
154
  const chatId = requireStringArg(args, 'chatId', 'chat ID', 'send_chat_message');
137
155
  const content = requireStringArg(args, 'content', 'message body', 'send_chat_message');
138
- const response = (await client.api(`/me/chats/${chatId}/messages`).options({ signal }).post({
156
+ const response = sendMessageResponseSchema.parse(await client.api(`/me/chats/${chatId}/messages`).options({ signal }).post({
139
157
  body: {
140
158
  contentType: content.includes('<') ? 'html' : 'text',
141
159
  content,
@@ -149,10 +167,10 @@ export async function sendChatMessage(client, args, signal) {
149
167
  }
150
168
  function formatChannel(channel) {
151
169
  return {
152
- id: channel.id,
170
+ id: graphStructuralTokenSchema.parse(channel.id),
153
171
  name: wrapUntrusted(channel.displayName, 'microsoft-teams:list_channels:name'),
154
172
  description: wrapUntrusted(channel.description, 'microsoft-teams:list_channels:description'),
155
- membershipType: channel.membershipType,
173
+ membershipType: graphStructuralTokenSchema.nullish().parse(channel.membershipType),
156
174
  };
157
175
  }
158
176
  export async function listTeams(client, _args, signal) {
@@ -165,7 +183,7 @@ export async function listTeams(client, _args, signal) {
165
183
  return {
166
184
  count: teams.length,
167
185
  teams: teams.map((team) => ({
168
- id: team.id,
186
+ id: graphStructuralTokenSchema.parse(team.id),
169
187
  name: wrapUntrusted(team.displayName, 'microsoft-teams:list_teams:name'),
170
188
  description: wrapUntrusted(team.description, 'microsoft-teams:list_teams:description'),
171
189
  })),
@@ -188,9 +206,344 @@ export async function listChannels(client, args, signal) {
188
206
  export async function getPresence(client, _args, signal) {
189
207
  const presence = (await client.api('/me/presence').options({ signal }).get());
190
208
  return {
209
+ availability: graphPresenceStateSchema.nullish().parse(presence.availability),
210
+ activity: graphPresenceStateSchema.nullish().parse(presence.activity),
211
+ statusMessage: wrapUntrusted(presence.statusMessage?.message?.content ?? undefined, 'microsoft-teams:get_presence:statusMessage'),
212
+ };
213
+ }
214
+ const presenceSchema = z
215
+ .object({
216
+ availability: graphPresenceStateSchema.nullish(),
217
+ activity: graphPresenceStateSchema.nullish(),
218
+ statusMessage: z
219
+ .object({
220
+ message: z
221
+ .object({
222
+ content: z.string().nullish(),
223
+ })
224
+ .nullish(),
225
+ })
226
+ .nullish(),
227
+ })
228
+ .passthrough();
229
+ export async function getUserPresence(client, args, signal) {
230
+ const userId = requireStringArg(args, 'userId', 'user ID or email', 'get_user_presence');
231
+ const presence = presenceSchema.parse(await client.api(`/users/${encodeURIComponent(userId)}/presence`).options({ signal }).get());
232
+ return {
233
+ userId,
191
234
  availability: presence.availability,
192
235
  activity: presence.activity,
193
- statusMessage: wrapUntrusted(presence.statusMessage?.message?.content ?? undefined, 'microsoft-teams:get_presence:statusMessage'),
236
+ statusMessage: wrapUntrusted(presence.statusMessage?.message?.content ?? undefined, 'microsoft-teams:get_user_presence:statusMessage'),
237
+ };
238
+ }
239
+ export const PRESENCE_AVAILABILITY_VALUES = [
240
+ 'Available',
241
+ 'Busy',
242
+ 'DoNotDisturb',
243
+ 'BeRightBack',
244
+ 'Away',
245
+ 'Offline',
246
+ ];
247
+ export async function setPresence(client, args, signal) {
248
+ const availability = requireStringArg(args, 'availability', `presence availability (${PRESENCE_AVAILABILITY_VALUES.join(', ')})`, 'set_presence');
249
+ const duration = numberArg(args, 'durationMinutes');
250
+ // The tool schema bounds this to an integer in 5-480; keep the business layer
251
+ // fail-closed too rather than silently coercing a value into a different
252
+ // meaning before a network write.
253
+ if (duration != null && (!Number.isInteger(duration) || duration < 5 || duration > 480)) {
254
+ throw new TeamsBusinessError('Invalid "durationMinutes": must be a whole number of minutes between 5 and 480.', 'set_presence');
255
+ }
256
+ const durationMinutes = duration;
257
+ const body = { availability, activity: availability };
258
+ if (durationMinutes != null)
259
+ body.expirationDuration = `PT${durationMinutes}M`;
260
+ await client.api('/me/presence/setUserPreferredPresence').options({ signal }).post(body);
261
+ return {
262
+ success: true,
263
+ availability,
264
+ ...(durationMinutes != null ? { durationMinutes } : {}),
265
+ message: `Presence set to ${availability}`,
266
+ };
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // Channel messages
270
+ // ---------------------------------------------------------------------------
271
+ // Every function validates the Graph strings it returns — free text via the
272
+ // untrusted-content envelope, structural values (IDs, enum-like tokens,
273
+ // datetimes) via the shape schemas above — so vendor-controlled markup never
274
+ // reaches model-visible output raw. The older functions still cast at fetch
275
+ // time for typing, but their output mappers apply the same schemas.
276
+ const graphMessageSchema = z
277
+ .object({
278
+ id: graphStructuralTokenSchema.optional(),
279
+ replyToId: graphStructuralTokenSchema.nullish(),
280
+ from: z
281
+ .object({
282
+ user: z
283
+ .object({
284
+ id: graphStructuralTokenSchema.nullish(),
285
+ displayName: z.string().nullish(),
286
+ })
287
+ .nullish(),
288
+ })
289
+ .nullish(),
290
+ body: z
291
+ .object({
292
+ content: z.string().nullish(),
293
+ contentType: graphStructuralTokenSchema.nullish(),
294
+ })
295
+ .nullish(),
296
+ createdDateTime: graphIsoDateTimeSchema.nullish(),
297
+ })
298
+ .passthrough();
299
+ const graphMessageCollectionSchema = z
300
+ .object({
301
+ value: z.array(graphMessageSchema).nullish(),
302
+ '@odata.nextLink': z.string().nullish(),
303
+ })
304
+ .passthrough();
305
+ const sendMessageResponseSchema = z
306
+ .object({
307
+ id: graphStructuralTokenSchema.optional(),
308
+ })
309
+ .passthrough();
310
+ function messagePostBody(content) {
311
+ return {
312
+ body: {
313
+ contentType: content.includes('<') ? 'html' : 'text',
314
+ content,
315
+ },
316
+ };
317
+ }
318
+ export async function listChannelMessages(client, args, signal) {
319
+ const teamId = requireStringArg(args, 'teamId', 'team ID', 'list_channel_messages');
320
+ const channelId = requireStringArg(args, 'channelId', 'channel ID', 'list_channel_messages');
321
+ const top = clampTop(numberArg(args, 'top'), 25, 50);
322
+ const response = graphMessageCollectionSchema.parse(await client
323
+ .api(`/teams/${teamId}/channels/${channelId}/messages`)
324
+ .options({ signal })
325
+ .top(top)
326
+ .get());
327
+ const messages = response.value ?? [];
328
+ return {
329
+ teamId,
330
+ channelId,
331
+ count: messages.length,
332
+ hasMore: Boolean(response['@odata.nextLink']),
333
+ messages: messages.map((msg) => ({
334
+ ...formatMessage(msg, 'list_channel_messages'),
335
+ replyToId: msg.replyToId ?? undefined,
336
+ })),
337
+ };
338
+ }
339
+ export async function sendChannelMessage(client, args, signal) {
340
+ const teamId = requireStringArg(args, 'teamId', 'team ID', 'send_channel_message');
341
+ const channelId = requireStringArg(args, 'channelId', 'channel ID', 'send_channel_message');
342
+ const content = requireStringArg(args, 'content', 'message body', 'send_channel_message');
343
+ const response = sendMessageResponseSchema.parse(await client
344
+ .api(`/teams/${teamId}/channels/${channelId}/messages`)
345
+ .options({ signal })
346
+ .post(messagePostBody(content)));
347
+ return {
348
+ success: true,
349
+ messageId: response.id,
350
+ message: 'Message sent successfully',
351
+ };
352
+ }
353
+ export async function replyToChannelMessage(client, args, signal) {
354
+ const teamId = requireStringArg(args, 'teamId', 'team ID', 'reply_to_channel_message');
355
+ const channelId = requireStringArg(args, 'channelId', 'channel ID', 'reply_to_channel_message');
356
+ const messageId = requireStringArg(args, 'messageId', 'channel message ID', 'reply_to_channel_message');
357
+ const content = requireStringArg(args, 'content', 'reply body', 'reply_to_channel_message');
358
+ const response = sendMessageResponseSchema.parse(await client
359
+ .api(`/teams/${teamId}/channels/${channelId}/messages/${messageId}/replies`)
360
+ .options({ signal })
361
+ .post(messagePostBody(content)));
362
+ return {
363
+ success: true,
364
+ messageId: response.id,
365
+ message: 'Reply sent successfully',
366
+ };
367
+ }
368
+ export async function replyToChatMessage(client, args, signal) {
369
+ const chatId = requireStringArg(args, 'chatId', 'chat ID', 'reply_to_message');
370
+ const messageId = requireStringArg(args, 'messageId', 'message ID', 'reply_to_message');
371
+ const content = requireStringArg(args, 'content', 'reply body', 'reply_to_message');
372
+ const response = sendMessageResponseSchema.parse(await client
373
+ .api(`/me/chats/${chatId}/messages/${messageId}/replies`)
374
+ .options({ signal })
375
+ .post(messagePostBody(content)));
376
+ return {
377
+ success: true,
378
+ messageId: response.id,
379
+ message: 'Reply sent successfully',
380
+ };
381
+ }
382
+ // ---------------------------------------------------------------------------
383
+ // Message search
384
+ // ---------------------------------------------------------------------------
385
+ const searchHitSchema = z
386
+ .object({
387
+ hitId: z.string().nullish(),
388
+ summary: z.string().nullish(),
389
+ resource: graphMessageSchema.extend({ chatId: graphStructuralTokenSchema.nullish() }).nullish(),
390
+ })
391
+ .passthrough();
392
+ const searchResponseSchema = z
393
+ .object({
394
+ value: z
395
+ .array(z
396
+ .object({
397
+ hitsContainers: z
398
+ .array(z
399
+ .object({
400
+ hits: z.array(searchHitSchema).nullish(),
401
+ total: z.number().nullish(),
402
+ })
403
+ .passthrough())
404
+ .nullish(),
405
+ })
406
+ .passthrough())
407
+ .nullish(),
408
+ })
409
+ .passthrough();
410
+ export async function searchMessages(client, args, signal) {
411
+ const query = requireStringArg(args, 'query', 'search text', 'search_messages');
412
+ const top = clampTop(numberArg(args, 'top'), 10, 25);
413
+ const response = searchResponseSchema.parse(await client
414
+ .api('/search/query')
415
+ .options({ signal })
416
+ .post({
417
+ requests: [
418
+ {
419
+ entityTypes: ['chatMessage'],
420
+ query: { queryString: query },
421
+ from: 0,
422
+ size: top,
423
+ },
424
+ ],
425
+ }));
426
+ const container = response.value?.[0]?.hitsContainers?.[0];
427
+ const hits = container?.hits ?? [];
428
+ return {
429
+ query,
430
+ count: hits.length,
431
+ total: container?.total ?? hits.length,
432
+ results: hits.map((hit) => ({
433
+ ...formatMessage(hit.resource ?? {}, 'search_messages'),
434
+ chatId: hit.resource?.chatId ?? undefined,
435
+ summary: wrapUntrusted(hit.summary ? stripHtml(hit.summary) : undefined, 'microsoft-teams:search_messages:summary'),
436
+ })),
437
+ };
438
+ }
439
+ // ---------------------------------------------------------------------------
440
+ // User lookup and chat creation
441
+ // ---------------------------------------------------------------------------
442
+ const graphUserSchema = z
443
+ .object({
444
+ id: graphStructuralTokenSchema,
445
+ displayName: z.string().nullish(),
446
+ mail: z.string().nullish(),
447
+ userPrincipalName: z.string().nullish(),
448
+ })
449
+ .passthrough();
450
+ const graphUserCollectionSchema = z
451
+ .object({
452
+ value: z.array(graphUserSchema).nullish(),
453
+ '@odata.nextLink': z.string().nullish(),
454
+ })
455
+ .passthrough();
456
+ function formatUser(user, tool) {
457
+ return {
458
+ id: user.id,
459
+ displayName: wrapUntrusted(user.displayName ?? undefined, `microsoft-teams:${tool}:displayName`),
460
+ email: wrapUntrusted(user.mail ?? user.userPrincipalName ?? undefined, `microsoft-teams:${tool}:email`),
461
+ };
462
+ }
463
+ export async function findUser(client, args, signal) {
464
+ const query = requireStringArg(args, 'query', 'name or email address', 'find_user');
465
+ if (query.includes('@')) {
466
+ try {
467
+ const user = graphUserSchema.parse(await client
468
+ .api(`/users/${encodeURIComponent(query)}`)
469
+ .options({ signal })
470
+ .select('id,displayName,mail,userPrincipalName')
471
+ .get());
472
+ return { count: 1, hasMore: false, users: [formatUser(user, 'find_user')] };
473
+ }
474
+ catch (err) {
475
+ // A 404 here means "no such user", which is a result, not a failure.
476
+ if (err?.statusCode === 404) {
477
+ return { count: 0, hasMore: false, users: [] };
478
+ }
479
+ throw err;
480
+ }
481
+ }
482
+ // $search on /users requires the ConsistencyLevel: eventual header; strip
483
+ // characters that would break out of the quoted search expression.
484
+ const safeQuery = query.replace(/["\\]/g, ' ').trim();
485
+ const response = graphUserCollectionSchema.parse(await client
486
+ .api('/users')
487
+ .options({ signal })
488
+ .header('ConsistencyLevel', 'eventual')
489
+ .search(`"displayName:${safeQuery}"`)
490
+ .select('id,displayName,mail,userPrincipalName')
491
+ .top(10)
492
+ .get());
493
+ const users = response.value ?? [];
494
+ // Name searches are capped at 10 results; surface Graph's continuation link
495
+ // so a truncated result is never mistaken for a complete one.
496
+ return {
497
+ count: users.length,
498
+ hasMore: Boolean(response['@odata.nextLink']),
499
+ users: users.map((user) => formatUser(user, 'find_user')),
500
+ };
501
+ }
502
+ function requireStringArrayArg(args, name, label, nextStep) {
503
+ const value = args[name];
504
+ if (Array.isArray(value) && value.length > 0) {
505
+ const members = value.map((entry) => (typeof entry === 'string' ? entry.trim() : ''));
506
+ if (members.every((entry) => entry.length > 0))
507
+ return members;
508
+ }
509
+ throw new TeamsBusinessError(`Missing required parameter: "${name}" (${label}). Provide a non-empty array of email addresses or user IDs.`, nextStep);
510
+ }
511
+ const createdChatSchema = z
512
+ .object({
513
+ id: graphStructuralTokenSchema,
514
+ chatType: z.enum(['oneOnOne', 'group', 'meeting']).nullish(),
515
+ })
516
+ .passthrough();
517
+ export async function createChat(client, args, signal) {
518
+ const members = [...new Set(requireStringArrayArg(args, 'members', 'chat participants', 'create_chat'))];
519
+ const topic = stringArg(args, 'topic');
520
+ const chatType = members.length === 1 ? 'oneOnOne' : 'group';
521
+ const memberBindings = [
522
+ {
523
+ '@odata.type': '#microsoft.graph.aadUserConversationMember',
524
+ roles: ['owner'],
525
+ 'user@odata.bind': 'https://graph.microsoft.com/v1.0/me',
526
+ },
527
+ ...members.map((member) => ({
528
+ '@odata.type': '#microsoft.graph.aadUserConversationMember',
529
+ roles: ['owner'],
530
+ 'user@odata.bind': `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(member)}`,
531
+ })),
532
+ ];
533
+ const chat = createdChatSchema.parse(await client
534
+ .api('/chats')
535
+ .options({ signal })
536
+ .post({
537
+ chatType,
538
+ // Topic is only valid on group chats.
539
+ ...(chatType === 'group' && topic ? { topic } : {}),
540
+ 'members@odata.bind': memberBindings,
541
+ }));
542
+ return {
543
+ success: true,
544
+ chatId: chat.id,
545
+ chatType: chat.chatType ?? chatType,
546
+ message: 'Chat created successfully',
194
547
  };
195
548
  }
196
549
  //# sourceMappingURL=teams.js.map
package/dist/tools.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { hasScope } from '@mindstone/mcp-server-microsoft-shared';
2
3
  import { z } from 'zod';
3
- import { callGraph } from './client.js';
4
- import { successJson, withErrorHandling } from './utils.js';
5
- import { getChat, getPresence, listChannels, listChats, listChatMessages, listTeams, sendChatMessage, } from './teams.js';
4
+ import { callGraph, getTokenProvider } from './client.js';
5
+ import { errorResponse, successJson, withErrorHandling } from './utils.js';
6
+ import { AUTH_TOOL_NAME } from './types.js';
7
+ import { createChat, findUser, getChat, getPresence, getUserPresence, listChannelMessages, listChannels, listChats, listChatMessages, listTeams, PRESENCE_AVAILABILITY_VALUES, replyToChannelMessage, replyToChatMessage, searchMessages, sendChannelMessage, sendChatMessage, setPresence, } from './teams.js';
6
8
  /**
7
9
  * `ui://` resource URI the compose_chat_message iframe is served under. The
8
10
  * shared compose-app template posts `send_chat_message` when the user clicks
@@ -29,6 +31,52 @@ const WRITE_ANNOTATIONS = {
29
31
  destructiveHint: false,
30
32
  openWorldHint: true,
31
33
  };
34
+ // Tools that change external state (send messages, create chats, set presence)
35
+ // must declare destructiveHint so hosts can gate or confirm them — AGENTS.md
36
+ // security invariant #7. compose_chat_message keeps the plain write
37
+ // annotations: it returns an editable draft and performs no Graph send itself.
38
+ const DESTRUCTIVE_WRITE_ANNOTATIONS = {
39
+ readOnlyHint: false,
40
+ destructiveHint: true,
41
+ openWorldHint: true,
42
+ };
43
+ /**
44
+ * Some Teams Graph surfaces (channel messages, user lookup, other users'
45
+ * presence) need delegated permissions beyond the cohort's base scope set,
46
+ * and several are admin-consent-gated under Microsoft's managed consent
47
+ * policy. When the connected account's token lacks the scope, return an
48
+ * actionable error up front instead of letting Graph 403 — same pattern as
49
+ * the SharePoint connector's Sites.Read.All gate. When there is no token on
50
+ * disk yet, fall through to the real call so the standard auth_required
51
+ * envelope handles it. A token that fails to LOAD (corrupt file, unreadable
52
+ * disk, provider error) is different from "no token": fail closed with
53
+ * observable guidance instead of silently skipping the permission gate.
54
+ */
55
+ async function requireScopesGranted(requiredScopes, feature) {
56
+ let tokenScope;
57
+ try {
58
+ const tokenData = await getTokenProvider().loadToken();
59
+ if (!tokenData)
60
+ return null;
61
+ tokenScope = tokenData.scope;
62
+ }
63
+ catch (err) {
64
+ return errorResponse({
65
+ error: `${feature} could not verify the Microsoft Graph permissions granted to the connected account: ${err instanceof Error ? err.message : String(err)}`,
66
+ action_required: 'Reconnect the Microsoft account so the granted permissions can be verified. In many organizations an administrator must approve these permissions first.',
67
+ next_step: AUTH_TOOL_NAME,
68
+ });
69
+ }
70
+ const missing = requiredScopes.filter((scope) => !hasScope(tokenScope, scope));
71
+ if (missing.length === 0)
72
+ return null;
73
+ return errorResponse({
74
+ error: `${feature} requires Microsoft Graph permission(s) not granted to the connected account: ${missing.join(', ')}.`,
75
+ action_required: 'Reconnect the Microsoft account with the additional permissions. In many organizations an administrator must approve these permissions first.',
76
+ next_step: AUTH_TOOL_NAME,
77
+ missing_scopes: missing,
78
+ });
79
+ }
32
80
  export function registerTeamsTools(server) {
33
81
  server.registerTool('list_chats', {
34
82
  description: 'List recent Teams chats (1:1, group, and meeting chats).',
@@ -114,8 +162,56 @@ PARAMETERS: target (the chat ID to send to), text (message content).`,
114
162
  chatId: z.string().describe('Chat ID'),
115
163
  content: z.string().describe('Message content (HTML supported)'),
116
164
  }).strict(),
117
- annotations: WRITE_ANNOTATIONS,
165
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
118
166
  }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => sendChatMessage(c, args, signal)))));
167
+ server.registerTool('reply_to_message', {
168
+ description: 'Reply to a specific message in a chat, creating a threaded reply.',
169
+ inputSchema: z
170
+ .object({
171
+ chatId: z.string().describe('Chat ID'),
172
+ messageId: z.string().describe('ID of the chat message to reply to'),
173
+ content: z.string().describe('Reply content (HTML supported)'),
174
+ })
175
+ .strict(),
176
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
177
+ }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => replyToChatMessage(c, args, signal)))));
178
+ server.registerTool('search_messages', {
179
+ description: 'Search Teams chat and channel messages by keyword across conversations you can access.',
180
+ inputSchema: z
181
+ .object({
182
+ query: z.string().describe('Search text (keyword or phrase)'),
183
+ top: z.number().optional().describe('Max results to return (default: 10, max: 25)'),
184
+ })
185
+ .strict(),
186
+ annotations: READ_ANNOTATIONS,
187
+ }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => searchMessages(c, args, signal)))));
188
+ server.registerTool('find_user', {
189
+ description: 'Look up people in the organization by display name or email address. Use this to resolve a colleague to a user ID or email before creating a chat. Requires the User.ReadBasic.All Graph permission.',
190
+ inputSchema: z
191
+ .object({
192
+ query: z.string().describe('Display name or email address of the person to find'),
193
+ })
194
+ .strict(),
195
+ annotations: READ_ANNOTATIONS,
196
+ }, withErrorHandling(async (args, extra) => {
197
+ const gate = await requireScopesGranted(['User.ReadBasic.All'], 'Looking up users');
198
+ if (gate)
199
+ return gate;
200
+ return successJson(await callGraph(extra, (c, signal) => findUser(c, args, signal)));
201
+ }));
202
+ server.registerTool('create_chat', {
203
+ description: 'Create a new 1:1 or group chat with one or more colleagues (by email address or user ID) and return the new chat ID, which can then be used with send_chat_message.',
204
+ inputSchema: z
205
+ .object({
206
+ members: z
207
+ .array(z.string())
208
+ .min(1)
209
+ .describe('Email addresses or user IDs of the other participants (one for a 1:1 chat, several for a group chat)'),
210
+ topic: z.string().optional().describe('Chat topic (group chats only)'),
211
+ })
212
+ .strict(),
213
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
214
+ }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => createChat(c, args, signal)))));
119
215
  server.registerTool('list_teams', {
120
216
  description: 'List Teams you are a member of.',
121
217
  inputSchema: z.object({}).shape,
@@ -128,10 +224,94 @@ PARAMETERS: target (the chat ID to send to), text (message content).`,
128
224
  }).shape,
129
225
  annotations: READ_ANNOTATIONS,
130
226
  }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => listChannels(c, args, signal)))));
227
+ server.registerTool('list_channel_messages', {
228
+ description: 'List recent messages in a Teams channel. Requires the ChannelMessage.Read.All Graph permission, which may need tenant admin approval.',
229
+ inputSchema: z.object({
230
+ teamId: z.string().describe('Team ID'),
231
+ channelId: z.string().describe('Channel ID'),
232
+ top: z.number().optional().describe('Max messages to return (default: 25, max: 50)'),
233
+ }).shape,
234
+ annotations: READ_ANNOTATIONS,
235
+ }, withErrorHandling(async (args, extra) => {
236
+ const gate = await requireScopesGranted(['ChannelMessage.Read.All'], 'Reading channel messages');
237
+ if (gate)
238
+ return gate;
239
+ return successJson(await callGraph(extra, (c, signal) => listChannelMessages(c, args, signal)));
240
+ }));
241
+ server.registerTool('send_channel_message', {
242
+ description: 'Post a new message to a Teams channel. Requires the ChannelMessage.Send Graph permission, which may need tenant admin approval.',
243
+ inputSchema: z
244
+ .object({
245
+ teamId: z.string().describe('Team ID'),
246
+ channelId: z.string().describe('Channel ID'),
247
+ content: z.string().describe('Message content (HTML supported)'),
248
+ })
249
+ .strict(),
250
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
251
+ }, withErrorHandling(async (args, extra) => {
252
+ const gate = await requireScopesGranted(['ChannelMessage.Send'], 'Posting channel messages');
253
+ if (gate)
254
+ return gate;
255
+ return successJson(await callGraph(extra, (c, signal) => sendChannelMessage(c, args, signal)));
256
+ }));
257
+ server.registerTool('reply_to_channel_message', {
258
+ description: 'Reply to an existing message in a Teams channel. Requires the ChannelMessage.Send Graph permission, which may need tenant admin approval.',
259
+ inputSchema: z
260
+ .object({
261
+ teamId: z.string().describe('Team ID'),
262
+ channelId: z.string().describe('Channel ID'),
263
+ messageId: z.string().describe('ID of the channel message to reply to'),
264
+ content: z.string().describe('Reply content (HTML supported)'),
265
+ })
266
+ .strict(),
267
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
268
+ }, withErrorHandling(async (args, extra) => {
269
+ const gate = await requireScopesGranted(['ChannelMessage.Send'], 'Replying to channel messages');
270
+ if (gate)
271
+ return gate;
272
+ return successJson(await callGraph(extra, (c, signal) => replyToChannelMessage(c, args, signal)));
273
+ }));
131
274
  server.registerTool('get_presence', {
132
275
  description: 'Get your current presence status (available, busy, away, etc.).',
133
276
  inputSchema: z.object({}).shape,
134
277
  annotations: READ_ANNOTATIONS,
135
278
  }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => getPresence(c, args, signal)))));
279
+ server.registerTool('get_user_presence', {
280
+ description: "Get a colleague's current presence status (available, busy, in a meeting, etc.). Requires the Presence.Read.All Graph permission, which may need tenant admin approval.",
281
+ inputSchema: z
282
+ .object({
283
+ userId: z.string().describe('User ID or email address of the colleague'),
284
+ })
285
+ .strict(),
286
+ annotations: READ_ANNOTATIONS,
287
+ }, withErrorHandling(async (args, extra) => {
288
+ const gate = await requireScopesGranted(['Presence.Read.All'], "Reading a colleague's presence");
289
+ if (gate)
290
+ return gate;
291
+ return successJson(await callGraph(extra, (c, signal) => getUserPresence(c, args, signal)));
292
+ }));
293
+ server.registerTool('set_presence', {
294
+ description: 'Set your own presence status (e.g. Busy, DoNotDisturb, Away), optionally for a limited duration. Requires the Presence.ReadWrite Graph permission.',
295
+ inputSchema: z
296
+ .object({
297
+ availability: z
298
+ .enum(PRESENCE_AVAILABILITY_VALUES)
299
+ .describe('Presence availability to set'),
300
+ durationMinutes: z
301
+ .number()
302
+ .int()
303
+ .min(5)
304
+ .max(480)
305
+ .optional()
306
+ .describe('How long the status applies, in whole minutes (5-480). Omit to keep it until changed.'),
307
+ })
308
+ .strict(),
309
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
310
+ }, withErrorHandling(async (args, extra) => {
311
+ const gate = await requireScopesGranted(['Presence.ReadWrite'], 'Setting presence');
312
+ if (gate)
313
+ return gate;
314
+ return successJson(await callGraph(extra, (c, signal) => setPresence(c, args, signal)));
315
+ }));
136
316
  }
137
317
  //# sourceMappingURL=tools.js.map
@@ -21,13 +21,36 @@
21
21
  /**
22
22
  * Wrap a single untrusted string in an `<untrusted-content source="…">`
23
23
  * envelope, escaping any embedded close-tag variant so the envelope cannot be
24
- * broken out of. `undefined` passes through untouched. Idempotent for the same
25
- * `source`.
24
+ * broken out of.
25
+ *
26
+ * `undefined` and `null` are passed through untouched so callers can apply the wrapper
27
+ * uniformly to optional fields without branching.
28
+ *
29
+ * Idempotent: when `text` is already a properly-shaped envelope for the SAME
30
+ * `source` (starts with the matching OPEN tag, ends with CLOSE, and contains no
31
+ * internal close-tag variants), the original string is returned unchanged so
32
+ * `wrapUntrusted(wrapUntrusted(s, src), src) === wrapUntrusted(s, src)`.
26
33
  */
27
- export declare function wrapUntrusted(text: string | undefined, source: string): string | undefined;
34
+ export declare function wrapUntrusted(text: string | null | undefined, source: string): string | undefined;
28
35
  /**
29
- * Recursively wrap every string value reachable inside `value`. Object keys are
30
- * structural and NOT wrapped; non-string leaves pass through unchanged.
36
+ * Strip one `<untrusted-content>` envelope from `text` if present, returning raw
37
+ * strings unchanged. This is intentionally one-layer and idempotent for already
38
+ * raw input so callers can accept either displayed wrapped content or manually
39
+ * authored content.
40
+ */
41
+ export declare function unwrapUntrusted(text: string): string;
42
+ /**
43
+ * Recursively wrap every string key and value reachable inside `value` (strings,
44
+ * arrays, plain-object property keys and values) in an `<untrusted-content>` envelope.
45
+ *
46
+ * Use this when a whole response blob is third-party data and you want to
47
+ * envelope it wholesale rather than enumerate fields. Non-string leaves (numbers,
48
+ * booleans, null) pass through unchanged.
31
49
  */
32
50
  export declare function wrapUntrustedJsonStrings<T>(value: T, source: string): T;
51
+ /**
52
+ * Recursively unwrap every string key and value reachable inside `value`.
53
+ * Non-string leaves pass through unchanged.
54
+ */
55
+ export declare function unwrapUntrustedJsonStrings<T>(value: T): T;
33
56
  //# sourceMappingURL=untrusted-content.d.ts.map
@@ -18,29 +18,45 @@
18
18
  * `untrusted-content` in any connector that talks to an external system; this
19
19
  * file (and the call sites that import from it) is what satisfies that gate.
20
20
  */
21
- const UNTRUSTED_CLOSE_TAG_VARIANT = /<\/untrusted-content[ \t]*>/gi;
21
+ const UNTRUSTED_CLOSE_TAG_VARIANT = /<\/untrusted-content\s*>/gi;
22
22
  const ESCAPED_UNTRUSTED_CLOSE_TAG = '<\\/untrusted-content>';
23
+ const UNTRUSTED_ENVELOPE = /^<untrusted-content source="[^"]*">([\s\S]*)<\/untrusted-content>$/;
23
24
  function escapeAttr(s) {
24
25
  return s.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
25
26
  }
27
+ /**
28
+ * Rewrite every `</untrusted-content>` variant (case-insensitive, optional
29
+ * whitespace before `>`) inside `s` to a benign textual form, so an attacker
30
+ * who controls the wrapped content cannot terminate the envelope early.
31
+ */
26
32
  function escapeCloseTagSentinels(s) {
27
33
  return s.replace(UNTRUSTED_CLOSE_TAG_VARIANT, ESCAPED_UNTRUSTED_CLOSE_TAG);
28
34
  }
35
+ function unescapeCloseTagSentinels(s) {
36
+ return s.replaceAll(ESCAPED_UNTRUSTED_CLOSE_TAG, '</untrusted-content>');
37
+ }
29
38
  /**
30
39
  * Wrap a single untrusted string in an `<untrusted-content source="…">`
31
40
  * envelope, escaping any embedded close-tag variant so the envelope cannot be
32
- * broken out of. `undefined` passes through untouched. Idempotent for the same
33
- * `source`.
41
+ * broken out of.
42
+ *
43
+ * `undefined` and `null` are passed through untouched so callers can apply the wrapper
44
+ * uniformly to optional fields without branching.
45
+ *
46
+ * Idempotent: when `text` is already a properly-shaped envelope for the SAME
47
+ * `source` (starts with the matching OPEN tag, ends with CLOSE, and contains no
48
+ * internal close-tag variants), the original string is returned unchanged so
49
+ * `wrapUntrusted(wrapUntrusted(s, src), src) === wrapUntrusted(s, src)`.
34
50
  */
35
51
  export function wrapUntrusted(text, source) {
36
- if (text === undefined)
52
+ if (text === undefined || text === null)
37
53
  return undefined;
38
54
  const open = `<untrusted-content source="${escapeAttr(source)}">`;
39
55
  const close = '</untrusted-content>';
40
56
  if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) {
41
57
  const inner = text.slice(open.length, text.length - close.length);
42
58
  if (!UNTRUSTED_CLOSE_TAG_VARIANT.test(inner)) {
43
- UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
59
+ UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0; // reset stateful /g regex
44
60
  return text;
45
61
  }
46
62
  UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
@@ -48,8 +64,24 @@ export function wrapUntrusted(text, source) {
48
64
  return `${open}${escapeCloseTagSentinels(text)}${close}`;
49
65
  }
50
66
  /**
51
- * Recursively wrap every string value reachable inside `value`. Object keys are
52
- * structural and NOT wrapped; non-string leaves pass through unchanged.
67
+ * Strip one `<untrusted-content>` envelope from `text` if present, returning raw
68
+ * strings unchanged. This is intentionally one-layer and idempotent for already
69
+ * raw input so callers can accept either displayed wrapped content or manually
70
+ * authored content.
71
+ */
72
+ export function unwrapUntrusted(text) {
73
+ const match = UNTRUSTED_ENVELOPE.exec(text);
74
+ if (!match)
75
+ return text;
76
+ return unescapeCloseTagSentinels(match[1]);
77
+ }
78
+ /**
79
+ * Recursively wrap every string key and value reachable inside `value` (strings,
80
+ * arrays, plain-object property keys and values) in an `<untrusted-content>` envelope.
81
+ *
82
+ * Use this when a whole response blob is third-party data and you want to
83
+ * envelope it wholesale rather than enumerate fields. Non-string leaves (numbers,
84
+ * booleans, null) pass through unchanged.
53
85
  */
54
86
  export function wrapUntrustedJsonStrings(value, source) {
55
87
  if (typeof value === 'string') {
@@ -59,7 +91,29 @@ export function wrapUntrustedJsonStrings(value, source) {
59
91
  return value.map((item) => wrapUntrustedJsonStrings(item, source));
60
92
  }
61
93
  if (value && typeof value === 'object') {
62
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wrapUntrustedJsonStrings(item, source)]));
94
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
95
+ wrapUntrusted(key, source) ?? key,
96
+ wrapUntrustedJsonStrings(item, source),
97
+ ]));
98
+ }
99
+ return value;
100
+ }
101
+ /**
102
+ * Recursively unwrap every string key and value reachable inside `value`.
103
+ * Non-string leaves pass through unchanged.
104
+ */
105
+ export function unwrapUntrustedJsonStrings(value) {
106
+ if (typeof value === 'string') {
107
+ return unwrapUntrusted(value);
108
+ }
109
+ if (Array.isArray(value)) {
110
+ return value.map((item) => unwrapUntrustedJsonStrings(item));
111
+ }
112
+ if (value && typeof value === 'object') {
113
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
114
+ unwrapUntrusted(key),
115
+ unwrapUntrustedJsonStrings(item),
116
+ ]));
63
117
  }
64
118
  return value;
65
119
  }
package/dist/utils.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { MicrosoftRefreshDisabledError, detectAuthRequiredReason, formatGraphError, } from '@mindstone/mcp-server-microsoft-shared';
2
2
  import { TeamsBusinessError } from './teams.js';
3
3
  import { AUTH_TOOL_NAME, REQUEST_TIMEOUT_MS, getMsPackageId } from './types.js';
4
+ import { wrapUntrusted } from './untrusted-content.js';
5
+ // formatGraphError surfaces vendor-controlled response-body text
6
+ // (`error.message` from the Graph JSON body). Envelope it so the model treats
7
+ // it as data, not instructions (AGENTS.md security invariant #6).
8
+ function formatGraphErrorForModel(err) {
9
+ return wrapUntrusted(formatGraphError(err), 'microsoft-teams:error:graph') ?? 'Unknown error';
10
+ }
4
11
  export function abortableSignal(callerSignal) {
5
12
  const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
6
13
  if (!callerSignal)
@@ -140,7 +147,7 @@ export function buildErrorResponse(err) {
140
147
  text: JSON.stringify({
141
148
  ...buildAuthRequiredResponse(),
142
149
  reason,
143
- error: formatGraphError(err),
150
+ error: formatGraphErrorForModel(err),
144
151
  package_id: getMsPackageId(),
145
152
  }),
146
153
  },
@@ -149,7 +156,7 @@ export function buildErrorResponse(err) {
149
156
  };
150
157
  }
151
158
  return errorResponse({
152
- error: formatGraphError(err),
159
+ error: formatGraphErrorForModel(err),
153
160
  action_required: 'Retry the call. If it continues to fail, run authenticate_microsoft_account to refresh the connection.',
154
161
  next_step: AUTH_TOOL_NAME,
155
162
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstone/mcp-server-microsoft-teams",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.mindstone/mcp-server-microsoft-teams",
5
5
  "description": "Microsoft 365 Teams via Graph: list chats, read/send messages, list teams/channels, presence.",
6
6
  "license": "FSL-1.1-MIT",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.mindstone/mcp-server-microsoft-teams",
4
4
  "title": "Microsoft 365 Teams",
5
5
  "description": "Microsoft 365 Teams via Graph: list chats, read/send messages, list teams/channels, presence.",
6
- "version": "0.2.0",
6
+ "version": "0.3.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/mindstone/mcp-servers",
9
9
  "source": "github",
@@ -14,7 +14,7 @@
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "@mindstone/mcp-server-microsoft-teams",
17
- "version": "0.2.0",
17
+ "version": "0.3.0",
18
18
  "runtimeHint": "npx",
19
19
  "transport": {
20
20
  "type": "stdio"