@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/dist/tools.js CHANGED
@@ -1,7 +1,27 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { hasScope } from '@mindstone/mcp-server-microsoft-shared';
1
3
  import { z } from 'zod';
2
- import { callGraph } from './client.js';
3
- import { successJson, withErrorHandling } from './utils.js';
4
- 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';
8
+ /**
9
+ * `ui://` resource URI the compose_chat_message iframe is served under. The
10
+ * shared compose-app template posts `send_chat_message` when the user clicks
11
+ * Send; the HTML twin lives in src/resources/compose-message-template.ts
12
+ * (generated from the shared compose-app package, drift-gated).
13
+ */
14
+ export const COMPOSE_MESSAGE_RESOURCE_URI = 'ui://microsoft-teams/compose-message';
15
+ const ANSI_ESCAPE_SEQUENCE_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
16
+ const HTML_TAG_PATTERN = /<[^>]*>/g;
17
+ function truncateText(value, maxChars) {
18
+ if (value.length <= maxChars)
19
+ return value;
20
+ return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
21
+ }
22
+ function sanitizeViewSummaryPart(value) {
23
+ return value.replace(ANSI_ESCAPE_SEQUENCE_PATTERN, '').replace(HTML_TAG_PATTERN, '').trim();
24
+ }
5
25
  const READ_ANNOTATIONS = {
6
26
  readOnlyHint: true,
7
27
  openWorldHint: true,
@@ -11,6 +31,52 @@ const WRITE_ANNOTATIONS = {
11
31
  destructiveHint: false,
12
32
  openWorldHint: true,
13
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
+ }
14
80
  export function registerTeamsTools(server) {
15
81
  server.registerTool('list_chats', {
16
82
  description: 'List recent Teams chats (1:1, group, and meeting chats).',
@@ -34,14 +100,118 @@ export function registerTeamsTools(server) {
34
100
  }).shape,
35
101
  annotations: READ_ANNOTATIONS,
36
102
  }, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => listChatMessages(c, args, signal)))));
103
+ // ---------------------------------------------------------------------
104
+ // compose_chat_message
105
+ // ---------------------------------------------------------------------
106
+ // Returns an editable draft the host renders as an interactive compose view
107
+ // (the compose-message iframe). Performs no Graph send itself — the iframe
108
+ // invokes send_chat_message when the user clicks Send. Registered WITHOUT
109
+ // withErrorHandling: it does no send I/O, and its InvalidParams validation
110
+ // must surface as-is rather than as an auth-flavoured retry envelope. Teams
111
+ // routes purely by chatId, so there is no recipient resolution (unlike Slack
112
+ // DMs) — the draft carries the target chat + text verbatim.
113
+ server.registerTool('compose_chat_message', {
114
+ description: `Open an inline editable message compose form before sending to a Teams chat. Use this when the user wants to write or send a Teams message so they can review and edit it first. Does NOT send directly — the form posts the message (via send_chat_message) when the user clicks Send.
115
+
116
+ PARAMETERS: target (the chat ID to send to), text (message content).`,
117
+ inputSchema: z
118
+ .object({
119
+ target: z.string().min(1).describe('The chat ID to send the message to'),
120
+ text: z.string().min(1).describe('Message content (HTML supported)'),
121
+ })
122
+ .strict(),
123
+ annotations: WRITE_ANNOTATIONS,
124
+ }, async (args) => {
125
+ const target = typeof args.target === 'string' ? args.target.trim() : '';
126
+ const text = typeof args.text === 'string' ? args.text : '';
127
+ if (target.length === 0 || text.trim().length === 0) {
128
+ throw new McpError(ErrorCode.InvalidParams, 'compose_chat_message requires a non-empty "target" (chat ID) and "text". Provide both so the editable draft has content for the user to review.');
129
+ }
130
+ const structuredContent = { target, text };
131
+ const viewSummaryTarget = truncateText(sanitizeViewSummaryPart(target), 120);
132
+ const viewSummary = truncateText(`Teams message draft to ${viewSummaryTarget || '(no target)'}.`, 280);
133
+ const fallbackTarget = truncateText(target, 256);
134
+ const fallbackText = truncateText(text, 5_000);
135
+ return {
136
+ content: [
137
+ {
138
+ type: 'text',
139
+ text: `Drafting a Teams message to ${target}\n\n${JSON.stringify(structuredContent)}\n\n[View: ${COMPOSE_MESSAGE_RESOURCE_URI}]`,
140
+ },
141
+ ],
142
+ _meta: {
143
+ ui: {
144
+ resourceUri: COMPOSE_MESSAGE_RESOURCE_URI,
145
+ presentation: 'primary',
146
+ viewSummary,
147
+ viewRoleLabel: 'Teams message',
148
+ structuredFallback: {
149
+ kind: 'plain',
150
+ payload: {
151
+ markdown: `Message to ${fallbackTarget}:\n\n${fallbackText}`,
152
+ },
153
+ },
154
+ },
155
+ },
156
+ structuredContent,
157
+ };
158
+ });
37
159
  server.registerTool('send_chat_message', {
38
160
  description: 'Send a message to a chat.',
39
161
  inputSchema: z.object({
40
162
  chatId: z.string().describe('Chat ID'),
41
163
  content: z.string().describe('Message content (HTML supported)'),
42
- }).shape,
43
- annotations: WRITE_ANNOTATIONS,
164
+ }).strict(),
165
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
44
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)))));
45
215
  server.registerTool('list_teams', {
46
216
  description: 'List Teams you are a member of.',
47
217
  inputSchema: z.object({}).shape,
@@ -54,10 +224,94 @@ export function registerTeamsTools(server) {
54
224
  }).shape,
55
225
  annotations: READ_ANNOTATIONS,
56
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
+ }));
57
274
  server.registerTool('get_presence', {
58
275
  description: 'Get your current presence status (available, busy, away, etc.).',
59
276
  inputSchema: z.object({}).shape,
60
277
  annotations: READ_ANNOTATIONS,
61
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
+ }));
62
316
  }
63
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.1.2",
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",
@@ -31,9 +31,12 @@
31
31
  "prepublishOnly": "npm run build && npm test",
32
32
  "watch": "tsc --watch",
33
33
  "start": "node dist/index.js",
34
+ "pretest": "npm run compose:template:check",
34
35
  "test": "vitest run",
35
36
  "test:watch": "vitest",
36
37
  "test:coverage": "vitest run --coverage",
38
+ "compose:template": "node scripts/gen-compose-html.mjs",
39
+ "compose:template:check": "node scripts/gen-compose-html.mjs --check",
37
40
  "security:internal-refs": "node scripts/check-internal-refs.mjs"
38
41
  },
39
42
  "dependencies": {
@@ -42,6 +45,7 @@
42
45
  "zod": "^3.23.0"
43
46
  },
44
47
  "devDependencies": {
48
+ "@mindstone/mcp-app-compose": "file:../../packages/mcp-app-compose",
45
49
  "@mindstone/mcp-test-harness": "file:../../test-harness",
46
50
  "@types/node": "^22",
47
51
  "@vitest/coverage-v8": "^4.1.3",
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.1.2",
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.1.2",
17
+ "version": "0.3.0",
18
18
  "runtimeHint": "npx",
19
19
  "transport": {
20
20
  "type": "stdio"