@mindstone/mcp-server-microsoft-teams 0.2.0 → 0.3.1
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 +39 -1
- package/README.md +15 -5
- package/dist/teams.d.ts +10 -0
- package/dist/teams.js +374 -21
- package/dist/tools.js +184 -4
- package/dist/untrusted-content.d.ts +29 -6
- package/dist/untrusted-content.js +79 -14
- package/dist/utils.js +9 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ All notable changes to this connector will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-08-10
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Re-sync the vendored envelope helper with the hardened canonical: attribute-bearing close tags and spoofed open tags escaped.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Re-synced the vendored `<untrusted-content>` envelope helper with the canonical hardened reference: attribute-bearing close-tag variants (`</untrusted-content foo>`) and spoofed open tags inside wrapped content are now escaped, closing an envelope-breakout gap an LLM parser could read as an envelope boundary.
|
|
16
|
+
|
|
17
|
+
## [0.3.0] - 2026-08-07
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Microsoft Teams connector: canonical result envelopes, untrusted-content fencing, and hardening sync; expanded messaging/channel actions.
|
|
22
|
+
|
|
23
|
+
### Security
|
|
24
|
+
|
|
25
|
+
- 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.
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- Re-synced the vendored untrusted-content helper with the canonical reference implementation (all-whitespace close-tag variants, plus the unwrap helpers).
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- 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`).
|
|
34
|
+
- `set_presence` rejects out-of-range or fractional `durationMinutes` instead of silently clamping/rounding them into a different duration.
|
|
35
|
+
- `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.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `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`).
|
|
40
|
+
- `reply_to_message` — threaded replies to a specific chat message.
|
|
41
|
+
- `search_messages` — keyword search across chat and channel messages via the Graph `/search/query` `chatMessage` entity (works with the already-granted `Chat.Read` permission).
|
|
42
|
+
- `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.
|
|
43
|
+
- 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.
|
|
44
|
+
|
|
7
45
|
## [0.2.0] - 2026-07-30
|
|
8
46
|
|
|
9
47
|
### Changed
|
|
@@ -27,4 +65,4 @@ All notable changes to this connector will be documented in this file.
|
|
|
27
65
|
|
|
28
66
|
### Added
|
|
29
67
|
|
|
30
|
-
- Initial Microsoft 365 Teams MCP server with seven tools
|
|
68
|
+
- 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
|
[](https://www.npmjs.com/package/@mindstone/mcp-server-microsoft-teams)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
Microsoft 365 Teams MCP server — list and read Teams chats
|
|
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.
|
|
12
|
+
- **Version:** [0.3.1](./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:** [
|
|
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 (
|
|
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
|
' ': ' ',
|
|
33
51
|
'&': '&',
|
|
@@ -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,
|
|
83
|
+
? wrapUntrusted(msg.from.user.displayName, `microsoft-teams:${tool}:from`)
|
|
66
84
|
: 'Unknown',
|
|
67
|
-
content: wrapUntrusted(stripHtml(msg.body?.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:
|
|
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 {
|
|
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:
|
|
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
|
|
@@ -20,14 +20,37 @@
|
|
|
20
20
|
*/
|
|
21
21
|
/**
|
|
22
22
|
* Wrap a single untrusted string in an `<untrusted-content source="…">`
|
|
23
|
-
* envelope, escaping any embedded close-tag variant so the envelope
|
|
24
|
-
* broken out of
|
|
25
|
-
*
|
|
23
|
+
* envelope, escaping any embedded open/close-tag variant so the envelope can
|
|
24
|
+
* neither be broken out of nor have a spoofed nested envelope planted inside.
|
|
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 open/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,38 +18,81 @@
|
|
|
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_OPEN_TAG_VARIANT = /<untrusted-content(?:\s[^>]*)?>/gi;
|
|
24
|
+
const ESCAPED_UNTRUSTED_OPEN_TAG = '<\\untrusted-content>';
|
|
25
|
+
const UNTRUSTED_ENVELOPE = /^<untrusted-content source="[^"]*">([\s\S]*)<\/untrusted-content>$/;
|
|
23
26
|
function escapeAttr(s) {
|
|
24
27
|
return s.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
25
28
|
}
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite every `</untrusted-content>` and `<untrusted-content …>` variant
|
|
31
|
+
* (case-insensitive, optional whitespace / attributes before `>`) inside `s`
|
|
32
|
+
* to a benign textual form, so an attacker who controls the wrapped content
|
|
33
|
+
* can neither terminate the envelope early nor spoof a nested open tag that a
|
|
34
|
+
* downstream parser could re-read as a fresh envelope. An LLM parsing the
|
|
35
|
+
* markup plausibly accepts attribute-bearing close forms such as
|
|
36
|
+
* `</untrusted-content foo>` — the matcher must cover them too.
|
|
37
|
+
*/
|
|
38
|
+
function escapeTagSentinels(s) {
|
|
39
|
+
return s
|
|
40
|
+
.replace(UNTRUSTED_OPEN_TAG_VARIANT, ESCAPED_UNTRUSTED_OPEN_TAG)
|
|
41
|
+
.replace(UNTRUSTED_CLOSE_TAG_VARIANT, ESCAPED_UNTRUSTED_CLOSE_TAG);
|
|
42
|
+
}
|
|
43
|
+
function unescapeTagSentinels(s) {
|
|
44
|
+
return s
|
|
45
|
+
.replaceAll(ESCAPED_UNTRUSTED_OPEN_TAG, '<untrusted-content>')
|
|
46
|
+
.replaceAll(ESCAPED_UNTRUSTED_CLOSE_TAG, '</untrusted-content>');
|
|
28
47
|
}
|
|
29
48
|
/**
|
|
30
49
|
* Wrap a single untrusted string in an `<untrusted-content source="…">`
|
|
31
|
-
* envelope, escaping any embedded close-tag variant so the envelope
|
|
32
|
-
* broken out of
|
|
33
|
-
*
|
|
50
|
+
* envelope, escaping any embedded open/close-tag variant so the envelope can
|
|
51
|
+
* neither be broken out of nor have a spoofed nested envelope planted inside.
|
|
52
|
+
*
|
|
53
|
+
* `undefined` and `null` are passed through untouched so callers can apply the wrapper
|
|
54
|
+
* uniformly to optional fields without branching.
|
|
55
|
+
*
|
|
56
|
+
* Idempotent: when `text` is already a properly-shaped envelope for the SAME
|
|
57
|
+
* `source` (starts with the matching OPEN tag, ends with CLOSE, and contains no
|
|
58
|
+
* internal open/close-tag variants), the original string is returned unchanged so
|
|
59
|
+
* `wrapUntrusted(wrapUntrusted(s, src), src) === wrapUntrusted(s, src)`.
|
|
34
60
|
*/
|
|
35
61
|
export function wrapUntrusted(text, source) {
|
|
36
|
-
if (text === undefined)
|
|
62
|
+
if (text === undefined || text === null)
|
|
37
63
|
return undefined;
|
|
38
64
|
const open = `<untrusted-content source="${escapeAttr(source)}">`;
|
|
39
65
|
const close = '</untrusted-content>';
|
|
40
66
|
if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) {
|
|
41
67
|
const inner = text.slice(open.length, text.length - close.length);
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
const hasVariant = UNTRUSTED_CLOSE_TAG_VARIANT.test(inner) || UNTRUSTED_OPEN_TAG_VARIANT.test(inner);
|
|
69
|
+
UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0; // reset stateful /g regexes
|
|
70
|
+
UNTRUSTED_OPEN_TAG_VARIANT.lastIndex = 0;
|
|
71
|
+
if (!hasVariant) {
|
|
44
72
|
return text;
|
|
45
73
|
}
|
|
46
|
-
UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
|
|
47
74
|
}
|
|
48
|
-
return `${open}${
|
|
75
|
+
return `${open}${escapeTagSentinels(text)}${close}`;
|
|
49
76
|
}
|
|
50
77
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
78
|
+
* Strip one `<untrusted-content>` envelope from `text` if present, returning raw
|
|
79
|
+
* strings unchanged. This is intentionally one-layer and idempotent for already
|
|
80
|
+
* raw input so callers can accept either displayed wrapped content or manually
|
|
81
|
+
* authored content.
|
|
82
|
+
*/
|
|
83
|
+
export function unwrapUntrusted(text) {
|
|
84
|
+
const match = UNTRUSTED_ENVELOPE.exec(text);
|
|
85
|
+
if (!match)
|
|
86
|
+
return text;
|
|
87
|
+
return unescapeTagSentinels(match[1]);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Recursively wrap every string key and value reachable inside `value` (strings,
|
|
91
|
+
* arrays, plain-object property keys and values) in an `<untrusted-content>` envelope.
|
|
92
|
+
*
|
|
93
|
+
* Use this when a whole response blob is third-party data and you want to
|
|
94
|
+
* envelope it wholesale rather than enumerate fields. Non-string leaves (numbers,
|
|
95
|
+
* booleans, null) pass through unchanged.
|
|
53
96
|
*/
|
|
54
97
|
export function wrapUntrustedJsonStrings(value, source) {
|
|
55
98
|
if (typeof value === 'string') {
|
|
@@ -59,7 +102,29 @@ export function wrapUntrustedJsonStrings(value, source) {
|
|
|
59
102
|
return value.map((item) => wrapUntrustedJsonStrings(item, source));
|
|
60
103
|
}
|
|
61
104
|
if (value && typeof value === 'object') {
|
|
62
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
105
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
106
|
+
wrapUntrusted(key, source) ?? key,
|
|
107
|
+
wrapUntrustedJsonStrings(item, source),
|
|
108
|
+
]));
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Recursively unwrap every string key and value reachable inside `value`.
|
|
114
|
+
* Non-string leaves pass through unchanged.
|
|
115
|
+
*/
|
|
116
|
+
export function unwrapUntrustedJsonStrings(value) {
|
|
117
|
+
if (typeof value === 'string') {
|
|
118
|
+
return unwrapUntrusted(value);
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
return value.map((item) => unwrapUntrustedJsonStrings(item));
|
|
122
|
+
}
|
|
123
|
+
if (value && typeof value === 'object') {
|
|
124
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
125
|
+
unwrapUntrusted(key),
|
|
126
|
+
unwrapUntrustedJsonStrings(item),
|
|
127
|
+
]));
|
|
63
128
|
}
|
|
64
129
|
return value;
|
|
65
130
|
}
|
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.1",
|
|
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.
|
|
6
|
+
"version": "0.3.1",
|
|
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.1",
|
|
18
18
|
"runtimeHint": "npx",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|