@chrischall/office-outlook-mcp 0.1.4 → 0.2.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +1080 -341
- package/dist/client.js +25 -7
- package/dist/tools/_paging.js +32 -0
- package/dist/tools/calendar.js +9 -9
- package/dist/tools/directory.js +11 -7
- package/dist/tools/mail.js +17 -14
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -198,17 +198,35 @@ export class OutlookClient {
|
|
|
198
198
|
headers: prefer.length ? { Prefer: prefer.join(', ') } : undefined,
|
|
199
199
|
});
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/**
|
|
202
|
+
* GET an absolute URL — used only to follow `@odata.nextLink`.
|
|
203
|
+
*
|
|
204
|
+
* The link is absolute and already carries the base path
|
|
205
|
+
* (`https://outlook.office.com/api/v2.0/me/messages?$skip=25`), while the
|
|
206
|
+
* underlying client appends every path to that same base. So the base path is
|
|
207
|
+
* stripped here, and a link that is not under it — another origin, or another
|
|
208
|
+
* path on the same host — is refused rather than sent the bearer token.
|
|
209
|
+
*/
|
|
210
|
+
async getAbsolute(url, opts = {}) {
|
|
203
211
|
this.#requireConfigured();
|
|
204
212
|
const base = new URL(this.#baseUrl);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
let target;
|
|
214
|
+
try {
|
|
215
|
+
target = new URL(url);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new McpToolError(`Refusing to follow a link that is not a URL: ${url}`, {
|
|
219
|
+
hint: 'Pass the nextLink value exactly as a previous listing returned it.',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
223
|
+
if (target.origin !== base.origin ||
|
|
224
|
+
!(target.pathname === prefix || target.pathname.startsWith(`${prefix}/`))) {
|
|
225
|
+
throw new McpToolError(`Refusing to follow a link to ${target.origin}${target.pathname}.`, {
|
|
226
|
+
hint: `Pagination links must stay under the Outlook API base (${this.#baseUrl}).`,
|
|
209
227
|
});
|
|
210
228
|
}
|
|
211
|
-
return this
|
|
229
|
+
return this.get(target.pathname.slice(prefix.length) + target.search, opts);
|
|
212
230
|
}
|
|
213
231
|
/**
|
|
214
232
|
* The single write path. Everything mutating goes through here so auth and
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { stripOData } from '../view.js';
|
|
3
|
+
/**
|
|
4
|
+
* Paging for every list tool.
|
|
5
|
+
*
|
|
6
|
+
* Outlook caps each page (`$top`) and signals the rest with
|
|
7
|
+
* `@odata.nextLink`. A listing that drops that link is indistinguishable from a
|
|
8
|
+
* complete one, so the model says with confidence that an item does not
|
|
9
|
+
* exist. Every list tool therefore returns `nextLink` when there is more, and
|
|
10
|
+
* accepts it back to fetch the next page.
|
|
11
|
+
*/
|
|
12
|
+
export const nextLinkParam = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Continue a previous listing: pass its `nextLink` value unchanged to fetch the next page. When set, the paging/filter arguments are taken from the link and ignored here.');
|
|
17
|
+
/** Fetch the first page at `path`, or the page a previous `nextLink` points at. */
|
|
18
|
+
export function fetchPage(client, nextLink, path, opts = {}) {
|
|
19
|
+
return nextLink !== undefined ? client.getAbsolute(nextLink, opts) : client.get(path, opts);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Pass a collection through without a typed projection, trimming the OData
|
|
23
|
+
* envelope from each item but keeping the paging link.
|
|
24
|
+
*/
|
|
25
|
+
export function plainCollection(data) {
|
|
26
|
+
const items = Array.isArray(data?.value) ? data.value : [];
|
|
27
|
+
const out = { count: items.length, items: items.map(stripOData) };
|
|
28
|
+
const next = data?.['@odata.nextLink'];
|
|
29
|
+
if (next)
|
|
30
|
+
out.nextLink = next;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
|
-
import { compactEvent, fullEvent, projectCollection,
|
|
3
|
+
import { compactEvent, fullEvent, projectCollection, } from '../view.js';
|
|
4
|
+
import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
|
|
4
5
|
import { VIEWS } from './mail.js';
|
|
5
6
|
import { mailboxTimeZone } from '../timezone.js';
|
|
6
7
|
const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview';
|
|
@@ -29,10 +30,11 @@ export function registerCalendarTools(server, client) {
|
|
|
29
30
|
.optional()
|
|
30
31
|
.describe('Windows time-zone name for the returned times, e.g. "Eastern Standard Time". NOT an IANA name like America/New_York.'),
|
|
31
32
|
limit: z.number().int().min(1).max(200).optional().describe('Max events (default 50)'),
|
|
33
|
+
nextLink: nextLinkParam,
|
|
32
34
|
}),
|
|
33
|
-
}, async ({ view, start, end, timeZone, limit }) => {
|
|
35
|
+
}, async ({ view, start, end, timeZone, limit, nextLink }) => {
|
|
34
36
|
const zone = timeZone ?? (await mailboxTimeZone(client));
|
|
35
|
-
const data = await client
|
|
37
|
+
const data = await fetchPage(client, nextLink, `/me/calendarview${qs({
|
|
36
38
|
startDateTime: start,
|
|
37
39
|
endDateTime: end,
|
|
38
40
|
$select: EVENT_SELECT,
|
|
@@ -65,14 +67,12 @@ export function registerCalendarTools(server, client) {
|
|
|
65
67
|
annotations: { readOnlyHint: true },
|
|
66
68
|
inputSchema: z.object({
|
|
67
69
|
view: viewParam(VIEWS),
|
|
70
|
+
nextLink: nextLinkParam,
|
|
68
71
|
}),
|
|
69
|
-
}, async ({ view }) => {
|
|
70
|
-
const data = await client
|
|
72
|
+
}, async ({ view, nextLink }) => {
|
|
73
|
+
const data = await fetchPage(client, nextLink, `/me/calendars${qs({ $select: 'Id,Name,Color,CanEdit,Owner' })}`);
|
|
71
74
|
if (resolveView(view, VIEWS) === 'raw')
|
|
72
75
|
return minifiedResult(data);
|
|
73
|
-
return minifiedResult(
|
|
74
|
-
count: data.value?.length ?? 0,
|
|
75
|
-
items: (data.value ?? []).map(stripOData),
|
|
76
|
-
});
|
|
76
|
+
return minifiedResult(plainCollection(data));
|
|
77
77
|
});
|
|
78
78
|
}
|
package/dist/tools/directory.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
3
|
import { stripOData } from '../view.js';
|
|
4
4
|
import { VIEWS } from './mail.js';
|
|
5
|
+
import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
|
|
5
6
|
function qs(params) {
|
|
6
7
|
const parts = [];
|
|
7
8
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -14,7 +15,7 @@ function qs(params) {
|
|
|
14
15
|
function collection(data, raw) {
|
|
15
16
|
if (raw)
|
|
16
17
|
return data;
|
|
17
|
-
return
|
|
18
|
+
return plainCollection(data);
|
|
18
19
|
}
|
|
19
20
|
export function registerDirectoryTools(server, client) {
|
|
20
21
|
server.registerTool('outlook_get_profile', {
|
|
@@ -34,9 +35,10 @@ export function registerDirectoryTools(server, client) {
|
|
|
34
35
|
view: viewParam(VIEWS),
|
|
35
36
|
limit: z.number().int().min(1).max(200).optional().describe('Max contacts (default 50)'),
|
|
36
37
|
skip: z.number().int().min(0).optional().describe('Offset for paging'),
|
|
38
|
+
nextLink: nextLinkParam,
|
|
37
39
|
}),
|
|
38
|
-
}, async ({ view, limit, skip }) => {
|
|
39
|
-
const data = await client
|
|
40
|
+
}, async ({ view, limit, skip, nextLink }) => {
|
|
41
|
+
const data = await fetchPage(client, nextLink, `/me/contacts${qs({
|
|
40
42
|
$top: limit ?? 50,
|
|
41
43
|
$skip: skip,
|
|
42
44
|
// `MobilePhone1`, not `MobilePhone`: the latter is the Graph name and
|
|
@@ -51,9 +53,10 @@ export function registerDirectoryTools(server, client) {
|
|
|
51
53
|
inputSchema: z.object({
|
|
52
54
|
view: viewParam(VIEWS),
|
|
53
55
|
limit: z.number().int().min(1).max(100).optional().describe('Max people (default 25)'),
|
|
56
|
+
nextLink: nextLinkParam,
|
|
54
57
|
}),
|
|
55
|
-
}, async ({ view, limit }) => {
|
|
56
|
-
const data = await client
|
|
58
|
+
}, async ({ view, limit, nextLink }) => {
|
|
59
|
+
const data = await fetchPage(client, nextLink, `/me/people${qs({
|
|
57
60
|
$top: limit ?? 25,
|
|
58
61
|
$select: 'Id,DisplayName,ScoredEmailAddresses,JobTitle,CompanyName',
|
|
59
62
|
})}`);
|
|
@@ -65,9 +68,10 @@ export function registerDirectoryTools(server, client) {
|
|
|
65
68
|
inputSchema: z.object({
|
|
66
69
|
view: viewParam(VIEWS),
|
|
67
70
|
limit: z.number().int().min(1).max(200).optional().describe('Max tasks (default 50)'),
|
|
71
|
+
nextLink: nextLinkParam,
|
|
68
72
|
}),
|
|
69
|
-
}, async ({ view, limit }) => {
|
|
70
|
-
const data = await client
|
|
73
|
+
}, async ({ view, limit, nextLink }) => {
|
|
74
|
+
const data = await fetchPage(client, nextLink, `/me/tasks${qs({
|
|
71
75
|
$top: limit ?? 50,
|
|
72
76
|
$select: 'Id,Subject,Status,Importance,DueDateTime,CompletedDateTime',
|
|
73
77
|
})}`);
|
package/dist/tools/mail.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { minifiedResult, resolveView, viewParam, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { compactFolder, compactMessage, fullMessage, projectCollection,
|
|
3
|
+
import { compactFolder, compactMessage, fullMessage, projectCollection, } from '../view.js';
|
|
4
|
+
import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
|
|
4
5
|
export const VIEWS = ['compact', 'full', 'raw'];
|
|
5
6
|
/**
|
|
6
7
|
* Well-known folder names the API accepts wherever a folder id is taken.
|
|
@@ -40,15 +41,16 @@ export function registerMailTools(server, client) {
|
|
|
40
41
|
inputSchema: z.object({
|
|
41
42
|
view: viewParam(VIEWS),
|
|
42
43
|
limit: z.number().int().min(1).max(200).optional().describe('Max folders (default 50)'),
|
|
44
|
+
nextLink: nextLinkParam,
|
|
43
45
|
}),
|
|
44
|
-
}, async ({ view, limit }) => {
|
|
45
|
-
const data = await client
|
|
46
|
+
}, async ({ view, limit, nextLink }) => {
|
|
47
|
+
const data = await fetchPage(client, nextLink, `/me/mailfolders${qs({ $top: limit ?? 50 })}`);
|
|
46
48
|
if (resolveView(view, VIEWS) === 'raw')
|
|
47
49
|
return minifiedResult(data);
|
|
48
50
|
return minifiedResult(projectCollection(data, compactFolder, 'mail folder'));
|
|
49
51
|
});
|
|
50
52
|
server.registerTool('outlook_list_messages', {
|
|
51
|
-
description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included — call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined (the API rejects $search with $filter); pass `search` alone to search the whole mailbox.",
|
|
53
|
+
description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included — call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless.",
|
|
52
54
|
annotations: { readOnlyHint: true },
|
|
53
55
|
inputSchema: z.object({
|
|
54
56
|
view: viewParam(VIEWS),
|
|
@@ -62,10 +64,13 @@ export function registerMailTools(server, client) {
|
|
|
62
64
|
search: z
|
|
63
65
|
.string()
|
|
64
66
|
.optional()
|
|
65
|
-
.describe('Full-text search across the whole mailbox; cannot combine with unreadOnly'),
|
|
67
|
+
.describe('Full-text search across the whole mailbox; cannot combine with unreadOnly on a first page (ignored with nextLink)'),
|
|
68
|
+
nextLink: nextLinkParam,
|
|
66
69
|
}),
|
|
67
|
-
}, async ({ view, folder, limit, skip, unreadOnly, search }) => {
|
|
68
|
-
|
|
70
|
+
}, async ({ view, folder, limit, skip, unreadOnly, search, nextLink }) => {
|
|
71
|
+
// With nextLink the filters come from the link and are ignored here, so
|
|
72
|
+
// the conflict only matters for a first-page request.
|
|
73
|
+
if (nextLink === undefined && search !== undefined && unreadOnly === true) {
|
|
69
74
|
throw new McpToolError('`search` and `unreadOnly` cannot be combined.', {
|
|
70
75
|
hint: 'Outlook rejects $search together with $filter. Search first, then filter the results.',
|
|
71
76
|
});
|
|
@@ -86,7 +91,7 @@ export function registerMailTools(server, client) {
|
|
|
86
91
|
if (unreadOnly === true)
|
|
87
92
|
params.$filter = 'IsRead eq false';
|
|
88
93
|
}
|
|
89
|
-
const data = await client
|
|
94
|
+
const data = await fetchPage(client, nextLink, `${base}${qs(params)}`);
|
|
90
95
|
const v = resolveView(view, VIEWS);
|
|
91
96
|
if (v === 'raw')
|
|
92
97
|
return minifiedResult(data);
|
|
@@ -117,14 +122,12 @@ export function registerMailTools(server, client) {
|
|
|
117
122
|
annotations: { readOnlyHint: true },
|
|
118
123
|
inputSchema: z.object({
|
|
119
124
|
id: z.string().min(1).describe('Message Id'),
|
|
125
|
+
nextLink: nextLinkParam,
|
|
120
126
|
}),
|
|
121
|
-
}, async ({ id }) => {
|
|
122
|
-
const data = await client
|
|
127
|
+
}, async ({ id, nextLink }) => {
|
|
128
|
+
const data = await fetchPage(client, nextLink, `/me/messages/${encodeURIComponent(id)}/attachments${qs({
|
|
123
129
|
$select: 'Id,Name,Size,ContentType',
|
|
124
130
|
})}`);
|
|
125
|
-
return minifiedResult(
|
|
126
|
-
count: data.value?.length ?? 0,
|
|
127
|
-
items: (data.value ?? []).map(stripOData),
|
|
128
|
-
});
|
|
131
|
+
return minifiedResult(plainCollection(data));
|
|
129
132
|
});
|
|
130
133
|
}
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/office-outlook-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/office-outlook-mcp",
|
|
5
5
|
"description": "Outlook / Microsoft 365 MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.check.json"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^2.
|
|
49
|
-
"@fetchproxy/server": "^3.0
|
|
48
|
+
"@chrischall/mcp-utils": "^2.4.0",
|
|
49
|
+
"@fetchproxy/server": "^3.2.0",
|
|
50
50
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
51
51
|
"dotenv": "^18.0.1",
|
|
52
|
-
"zod": "^4.6.
|
|
52
|
+
"zod": "^4.6.5"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@modelcontextprotocol/client": "^2.0.0",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/office-outlook-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/office-outlook-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|