@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 +35 -1
- package/README.md +15 -5
- package/dist/resources/compose-message-template.d.ts +2 -0
- package/dist/resources/compose-message-template.js +968 -0
- package/dist/server.js +5 -1
- package/dist/teams.d.ts +10 -0
- package/dist/teams.js +374 -21
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +259 -5
- package/dist/untrusted-content.d.ts +28 -5
- package/dist/untrusted-content.js +62 -8
- package/dist/utils.js +9 -2
- package/package.json +5 -1
- package/server.json +2 -2
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 {
|
|
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
|
-
}).
|
|
43
|
-
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.
|
|
25
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
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.
|
|
33
|
-
*
|
|
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
|
-
*
|
|
52
|
-
*
|
|
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]) => [
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
17
|
+
"version": "0.3.0",
|
|
18
18
|
"runtimeHint": "npx",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|